From 1aa940fa05d24b3a6b721eb6a8e0bc330a072542 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Tue, 24 Sep 2024 01:37:53 -0400 Subject: [PATCH] LoginWithGoogle feature now working. --- .../Identity/ApplicationUser.cs | 1 + .../20240924044131_AddGoogleId.Designer.cs | 435 ++++++++++++++++++ .../Migrations/20240924044131_AddGoogleId.cs | 29 ++ .../ApplicationDbContextModelSnapshot.cs | 5 +- src/Infrastructure/Utils/PasswordGenerator.cs | 70 +++ src/Infrastructure/Utils/RandomGenerator.cs | 58 --- src/Web/Controllers/FacebookController.cs | 2 +- src/Web/Controllers/GoogleController.cs | 89 ---- .../Users/Handlers/LoginWithGoogle.cs | 140 ++++++ 9 files changed, 680 insertions(+), 149 deletions(-) create mode 100644 src/Infrastructure/Migrations/20240924044131_AddGoogleId.Designer.cs create mode 100644 src/Infrastructure/Migrations/20240924044131_AddGoogleId.cs create mode 100644 src/Infrastructure/Utils/PasswordGenerator.cs delete mode 100644 src/Infrastructure/Utils/RandomGenerator.cs delete mode 100644 src/Web/Controllers/GoogleController.cs create mode 100644 src/Web/Features/Users/Handlers/LoginWithGoogle.cs diff --git a/src/Infrastructure/Identity/ApplicationUser.cs b/src/Infrastructure/Identity/ApplicationUser.cs index 8a1ed8e..5ed71b2 100644 --- a/src/Infrastructure/Identity/ApplicationUser.cs +++ b/src/Infrastructure/Identity/ApplicationUser.cs @@ -11,4 +11,5 @@ public class ApplicationUser : IdentityUser public DateTime? BirthDate { get; set; } [MaxLength(255)] public string? Address { get; set; } [MaxLength(255)] public string? PortraitUrl { get; set; } + [MaxLength(255)] public string? GoogleId { get; set; } } diff --git a/src/Infrastructure/Migrations/20240924044131_AddGoogleId.Designer.cs b/src/Infrastructure/Migrations/20240924044131_AddGoogleId.Designer.cs new file mode 100644 index 0000000..9eb0111 --- /dev/null +++ b/src/Infrastructure/Migrations/20240924044131_AddGoogleId.Designer.cs @@ -0,0 +1,435 @@ +// +using System; +using Hutopy.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Hutopy.Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20240924044131_AddGoogleId")] + partial class AddGoogleId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Hutopy.Domain.Entities.FutureCreator", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EmailAddress") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LastModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastModifiedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReasonToJoin") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SocialNetworkAccount") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("FutureCreators"); + }); + + modelBuilder.Entity("Hutopy.Domain.Entities.UserTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ApplicationUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsConfirmed") + .HasColumnType("bit"); + + b.Property("LastModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastModifiedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Paid") + .HasColumnType("bit"); + + b.Property("StripeBillingDetailEmail") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StripeBillingDetailName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StripeChargeId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StripeEventId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StripePaymentIntent") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StripePaymentMethod") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StripeReceiptUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TipMessage") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationUserId"); + + b.ToTable("UserTransactions"); + }); + + modelBuilder.Entity("Hutopy.Infrastructure.Identity.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Hutopy.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("Address") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Alias") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("BirthDate") + .HasColumnType("datetime2"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("Firstname") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("GoogleId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Lastname") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("PortraitUrl") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Hutopy.Domain.Entities.UserTransaction", b => + { + b.HasOne("Hutopy.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("ApplicationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Hutopy.Infrastructure.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Hutopy.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Hutopy.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Hutopy.Infrastructure.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Hutopy.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Hutopy.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Migrations/20240924044131_AddGoogleId.cs b/src/Infrastructure/Migrations/20240924044131_AddGoogleId.cs new file mode 100644 index 0000000..9e71d45 --- /dev/null +++ b/src/Infrastructure/Migrations/20240924044131_AddGoogleId.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hutopy.Infrastructure.Migrations +{ + /// + public partial class AddGoogleId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "GoogleId", + table: "AspNetUsers", + type: "nvarchar(255)", + maxLength: 255, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "GoogleId", + table: "AspNetUsers"); + } + } +} diff --git a/src/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index b3d38bc..7d39e2d 100644 --- a/src/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -189,7 +189,6 @@ namespace Hutopy.Infrastructure.Migrations .HasColumnType("nvarchar(255)"); b.Property("BirthDate") - .HasMaxLength(255) .HasColumnType("datetime2"); b.Property("ConcurrencyStamp") @@ -207,6 +206,10 @@ namespace Hutopy.Infrastructure.Migrations .HasMaxLength(255) .HasColumnType("nvarchar(255)"); + b.Property("GoogleId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + b.Property("Lastname") .HasMaxLength(255) .HasColumnType("nvarchar(255)"); diff --git a/src/Infrastructure/Utils/PasswordGenerator.cs b/src/Infrastructure/Utils/PasswordGenerator.cs new file mode 100644 index 0000000..7901a7f --- /dev/null +++ b/src/Infrastructure/Utils/PasswordGenerator.cs @@ -0,0 +1,70 @@ +using System.Text; + +namespace Hutopy.Infrastructure.Utils; + +// If we need to add special characters we can alternate between 2 pools. +public class PasswordGenerator +{ + private const string LowerLetters = "abcdefghijklmnopqrstuvwxyz"; + private const string UpperLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + private const string Numbers = "0123456789"; + private const string SpecialCharacters = "!@#$%^&*()_+-=[];',./`~{}|:\"<>?"; + + private static readonly Random Random = new(); + + public static string GeneratePassword( + int minLength, + int maxLength, + bool requireNumber = true, + bool requireCapital = true, + bool requireSpecialCharacter = true) + { + // Create pools based on the requirements + var characterPool = new StringBuilder(LowerLetters); + + if (requireCapital) + characterPool.Append(UpperLetters); + + if (requireNumber) + characterPool.Append(Numbers); + + if (requireSpecialCharacter) + characterPool.Append(SpecialCharacters); + + // Ensure that the length is within the specified bounds + int length = Random.Next(minLength, maxLength + 1); + var password = new char[length]; + + // Ensure at least one character from each required category is included + int index = 0; + + if (requireCapital) + password[index++] = UpperLetters[Random.Next(UpperLetters.Length)]; + + if (requireNumber) + password[index++] = Numbers[Random.Next(Numbers.Length)]; + + if (requireSpecialCharacter) + password[index++] = SpecialCharacters[Random.Next(SpecialCharacters.Length)]; + + // Fill the rest of the password + for (int i = index; i < length; i++) + { + password[i] = characterPool[Random.Next(characterPool.Length)]; + } + + // Shuffle the password to randomize the placement of the required characters + Shuffle(password); + return new string(password); + } + + private static void Shuffle( + char[] array) + { + for (int i = array.Length - 1; i > 0; i--) + { + int j = Random.Next(i + 1); + (array[i], array[j]) = (array[j], array[i]); // Swap elements + } + } +} diff --git a/src/Infrastructure/Utils/RandomGenerator.cs b/src/Infrastructure/Utils/RandomGenerator.cs deleted file mode 100644 index 1e5a9a2..0000000 --- a/src/Infrastructure/Utils/RandomGenerator.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.Text; - -namespace Hutopy.Infrastructure.Utils; - -// If we need to add special characters we can alternate between 2 pools. -public class RandomGenerator -{ - // For the moment, numbers and special characters don't work because - // the random generator is designed to handle a single integer. - // We can modify this in the future. - private const string LetterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" - + "0123456789" - + "!@#$%^&*()_+" - + "-=[];',./`~{}|:\"<>?"; - private const int LetterIdxBits = 6; - private const int LetterIdxMask = 1 << LetterIdxBits; - private const int LetterIdxMax = 64 / LetterIdxBits; - - private static readonly Random Src = new(); - - public static byte[] RandBytesMaskSrc(int n) - { - var b = new byte[n]; - - for (var i = n - 1; i >= 0;) - { - long cache = Src.NextInt64(); - int remain = LetterIdxMax; - - while (remain != 0) - { - if (i < 0) - break; - - if (cache == 0) - cache = Src.NextInt64(); - - var idx = (int)(cache & LetterIdxMask); - if (idx < LetterBytes.Length) - { - b[i] = (byte)LetterBytes[idx]; - i--; - } - - cache >>= LetterIdxBits; - remain--; - } - } - - return b; - } - - public static string RandomString(int length) - { - var bytes = RandBytesMaskSrc(length); - return Encoding.UTF8.GetString(bytes); // Equivalent for *(string*)(&bytes[0]) - } -} diff --git a/src/Web/Controllers/FacebookController.cs b/src/Web/Controllers/FacebookController.cs index 9efee0b..b8b52d9 100644 --- a/src/Web/Controllers/FacebookController.cs +++ b/src/Web/Controllers/FacebookController.cs @@ -49,7 +49,7 @@ public class FacebookController(IIdentityService identityService) : Controller } await identityService.CreateUserAsync(email, givenName, givenName, familyName, - RandomGenerator.RandomString(24)); + PasswordGenerator.GeneratePassword(8, 10)); await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(claimsIdentity)); return Redirect("/"); diff --git a/src/Web/Controllers/GoogleController.cs b/src/Web/Controllers/GoogleController.cs deleted file mode 100644 index fd48001..0000000 --- a/src/Web/Controllers/GoogleController.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System.Security.Claims; -using Hutopy.Application.Common.Interfaces; -using Hutopy.Infrastructure.Identity; -using Hutopy.Infrastructure.Utils; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; -using Newtonsoft.Json.Linq; - -namespace Hutopy.Web.Controllers; - -public class GoogleController( - IIdentityService identityService, - IHttpClientFactory httpClientFactory, - IOptionsSnapshot jwtOptions) - : Controller -{ - [Microsoft.AspNetCore.Mvc.HttpPost("/api/google/sign-in")] - public async Task SignIn([Microsoft.AspNetCore.Mvc.FromBody] GoogleSignInRequest request) - { - using var httpClient = httpClientFactory.CreateClient(); - - // Verify the token with Google - var response = await httpClient.GetAsync($"https://www.googleapis.com/oauth2/v1/userinfo?access_token={request.AccessToken}"); - if (!response.IsSuccessStatusCode) - { - return BadRequest("Invalid Google token."); - } - - var userInfo = JObject.Parse(await response.Content.ReadAsStringAsync()); - var email = userInfo["email"]?.ToString() ?? ""; - var name = userInfo["name"]?.ToString() ?? ""; - var givenName = userInfo["given_name"]?.ToString() ?? ""; - var familyName = userInfo["family_name"]?.ToString() ?? ""; - - if (string.IsNullOrEmpty(email)) - { - return BadRequest("Google token did not contain an email."); - } - - // Check if user exists or create a new one - var user = await identityService.FindUserByEmailAsync(email); - if (user == null) - { - await identityService.CreateUserAsync(email, email, givenName, familyName, RandomGenerator.RandomString(24)); - user = await identityService.FindUserByEmailAsync(email); - } - - if (user?.Id is null) - { - return BadRequest("Unable to find or create the user."); - } - - // Sign in the user - var claimsIdentity = new ClaimsIdentity( - new List - { - new(ClaimTypes.Name, name), - new(ClaimTypes.Email, email), - new(ClaimTypes.GivenName, givenName), - new(ClaimTypes.Surname, familyName) - }, - CookieAuthenticationDefaults.AuthenticationScheme); - - await HttpContext.SignInAsync( - CookieAuthenticationDefaults.AuthenticationScheme, - new ClaimsPrincipal(claimsIdentity)); - - var token = JwtTokenHelper.GenerateJwtToken( - jwtOptions.Value.Lifetime, - jwtOptions.Value.Issuer, - jwtOptions.Value.Audience, - jwtOptions.Value.Key, - user.Id.ToString(), - user.Email, - user.Alias, - user.Firstname, - user.Lastname, - user.PortraitUrl); - - return Ok(new { accessToken = token, email }); - } - - public class GoogleSignInRequest - { - public required string AccessToken { get; set; } - } -} diff --git a/src/Web/Features/Users/Handlers/LoginWithGoogle.cs b/src/Web/Features/Users/Handlers/LoginWithGoogle.cs new file mode 100644 index 0000000..bdb814d --- /dev/null +++ b/src/Web/Features/Users/Handlers/LoginWithGoogle.cs @@ -0,0 +1,140 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Hutopy.Infrastructure.Identity; +using Hutopy.Infrastructure.Utils; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; + +namespace Hutopy.Web.Features.Users.Handlers; + +class GoogleToken +{ + [JsonPropertyName("access_token")] public required string AccessToken { get; init; } + [JsonPropertyName("token_type")] public required string TokenType { get; init; } + [JsonPropertyName("expires_in")] public required int ExpiresIn { get; init; } + [JsonPropertyName("scope")] public required string Scope { get; init; } + [JsonPropertyName("authuser")] public required string AuthUser { get; init; } + [JsonPropertyName("prompt")] public required string Prompt { get; init; } +} + +public class GoogleUserInfo +{ + [JsonPropertyName("id")] public required string Id { get; init; } + [JsonPropertyName("email")] public required string Email { get; init; } + [JsonPropertyName("verified_email")] public required bool VerifiedEmail { get; init; } + [JsonPropertyName("name")] public required string Name { get; init; } + [JsonPropertyName("given_name")] public required string GivenName { get; init; } + [JsonPropertyName("family_name")] public required string FamilyName { get; init; } + [JsonPropertyName("picture")] public required string Picture { get; init; } +} + +[PublicAPI] +public record LoginWithGoogleRequest( + string Token) + : IRequest; + +public record LoginWithGoogleResponse( + string AccessToken, + string RefreshToken); + +[PublicAPI] +public class LoginWithGoogleHandler( + IHttpClientFactory httpClientFactory, + ApplicationUserManager userManager, + SignInManager signInManager, + IOptionsSnapshot jwtOptions) + : Endpoint +{ + public override void Configure() + { + AllowAnonymous(); + Post("/api/users/login-with-google"); + Options(o => o.WithTags("Users")); + } + + public override async Task HandleAsync( + LoginWithGoogleRequest request, + CancellationToken ct) + { + var googleToken = JsonSerializer.Deserialize(request.Token)!; + + // Verify the token with Google + using var httpClient = httpClientFactory.CreateClient(); + var response = await httpClient.GetAsync( + $"https://www.googleapis.com/oauth2/v1/userinfo?access_token={googleToken.AccessToken}", + ct); + if (!response.IsSuccessStatusCode) + { + await SendStringAsync( + "The token is not valid", + 400, + cancellation: ct); + return; + } + + // Extract the user info (email, name, etc.). + var content = await response.Content.ReadAsStringAsync(ct); + var userInfo = JsonSerializer.Deserialize(content); + if (userInfo is null + || !userInfo.VerifiedEmail + || string.IsNullOrEmpty(userInfo.Email)) + { + await SendStringAsync( + "The token does not contain an email", + 400, + cancellation: ct); + return; + } + + // Check if user exists or create a new one + var user = await userManager.FindByEmailAsync(userInfo.Email); + + if (user is null) + { + var generatedPassword = PasswordGenerator.GeneratePassword(8, 10); + var generatedUser = new ApplicationUser + { + UserName = userInfo.Email, + Email = userInfo.Email, + Firstname = userInfo.GivenName, + Lastname = userInfo.FamilyName, + Alias = userInfo.Name, + PortraitUrl = userInfo.Picture, + GoogleId = userInfo.Id, + }; + + var result = await userManager.CreateAsync( + generatedUser, + generatedPassword); + + if (!result.Succeeded) + { + await SendStringAsync( + result.Errors.First().Description, + 400, + cancellation: ct); + return; + } + + user = generatedUser; + } + + await signInManager.SignInAsync(user, isPersistent: false); + + var accessToken = JwtTokenHelper.GenerateJwtToken( + expiresIn: jwtOptions.Value.Lifetime, + issuer: jwtOptions.Value.Issuer, + audience: jwtOptions.Value.Audience, + key: jwtOptions.Value.Key, + userId: user.Id.ToString(), + email: user.Email, + alias: user.Alias, + firstname: user.Firstname, + lastname: user.Lastname, + portraitUrl: user.PortraitUrl); + + await SendOkAsync( + new LoginWithGoogleResponse(accessToken, string.Empty), + cancellation: ct); + } +}