From bd2410a98eeda264b57c72f4f4b9046055c86a0f Mon Sep 17 00:00:00 2001 From: Kamigen <46357922+Edouard127@users.noreply.github.com> Date: Mon, 15 Apr 2024 19:10:32 -0400 Subject: [PATCH 01/15] Test: Google oauth --- Directory.Packages.props | 3 +- src/Application/Application.csproj | 1 + .../Common/Interfaces/IGoogleService.cs | 8 ++++ .../Common/Interfaces/IIdentityService.cs | 5 ++- .../Google/Commands/CreateGoogleUser.cs | 20 ++++++++++ src/Domain/Domain.csproj | 1 + src/Domain/Interfaces/IUserService.cs | 5 ++- .../Identity/ApplicationUser.cs | 1 + .../Identity/IdentityService.cs | 18 +++++++++ src/Infrastructure/Services/GoogleService.cs | 24 ++++++++++++ src/Infrastructure/Services/UserService.cs | 39 +++++++++++++------ src/Web/Endpoints/Google.cs | 22 +++++++++++ 12 files changed, 132 insertions(+), 15 deletions(-) create mode 100644 src/Application/Common/Interfaces/IGoogleService.cs create mode 100644 src/Application/Google/Commands/CreateGoogleUser.cs create mode 100644 src/Infrastructure/Services/GoogleService.cs create mode 100644 src/Web/Endpoints/Google.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 74620e0..758ff65 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,11 +7,12 @@ - + + diff --git a/src/Application/Application.csproj b/src/Application/Application.csproj index 8544947..34570d3 100644 --- a/src/Application/Application.csproj +++ b/src/Application/Application.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Application/Common/Interfaces/IGoogleService.cs b/src/Application/Common/Interfaces/IGoogleService.cs new file mode 100644 index 0000000..aba62f7 --- /dev/null +++ b/src/Application/Common/Interfaces/IGoogleService.cs @@ -0,0 +1,8 @@ +using Google.Apis.Oauth2.v2.Data; + +namespace Hutopy.Application.Common.Interfaces; + +public interface IGoogleService +{ + Task GetUserInfoAsync(string accessToken); +} diff --git a/src/Application/Common/Interfaces/IIdentityService.cs b/src/Application/Common/Interfaces/IIdentityService.cs index 5dbcb4c..399b19c 100644 --- a/src/Application/Common/Interfaces/IIdentityService.cs +++ b/src/Application/Common/Interfaces/IIdentityService.cs @@ -1,4 +1,5 @@ -using Hutopy.Application.Common.Models; +using Google.Apis.Oauth2.v2.Data; +using Hutopy.Application.Common.Models; namespace Hutopy.Application.Common.Interfaces; @@ -11,6 +12,8 @@ public interface IIdentityService Task AuthorizeAsync(string userId, string policyName); Task<(Result Result, string UserId)> CreateUserAsync(string userName, string password); + + Task<(Result Result, string UserId)> CreateUserAsync(Userinfo userInfo); Task DeleteUserAsync(string userId); } diff --git a/src/Application/Google/Commands/CreateGoogleUser.cs b/src/Application/Google/Commands/CreateGoogleUser.cs new file mode 100644 index 0000000..a5f2bca --- /dev/null +++ b/src/Application/Google/Commands/CreateGoogleUser.cs @@ -0,0 +1,20 @@ +using Hutopy.Application.Common.Interfaces; + +namespace Hutopy.Application.Google.Commands; + +public record CreateGoogleUserCommand : IRequest +{ + public required string AccessToken { get; init; } +} + +public class CreateGoogleUser( + IApplicationDbContext context + ) : IRequestHandler +{ + public async Task Handle(CreateGoogleUserCommand request, CancellationToken cancellationToken) + { + await context.SaveChangesAsync(cancellationToken); + + return Guid.NewGuid(); + } +} diff --git a/src/Domain/Domain.csproj b/src/Domain/Domain.csproj index efab6e8..56e216d 100644 --- a/src/Domain/Domain.csproj +++ b/src/Domain/Domain.csproj @@ -6,6 +6,7 @@ + diff --git a/src/Domain/Interfaces/IUserService.cs b/src/Domain/Interfaces/IUserService.cs index c3bd5ac..c59cc2a 100644 --- a/src/Domain/Interfaces/IUserService.cs +++ b/src/Domain/Interfaces/IUserService.cs @@ -1,4 +1,5 @@ -using Hutopy.Domain.Models; +using Google.Apis.Oauth2.v2.Data; +using Hutopy.Domain.Models; namespace Hutopy.Domain.Interfaces; @@ -6,6 +7,8 @@ public interface IUserService { Task CreateUserAsync(string email, string userName, string firstName, string lastName, string password); + Task CreateUserAsync(Userinfo userInfo); + Task FindUserByIdAsync(string id); Task FindUserByEmailAsync(string id); diff --git a/src/Infrastructure/Identity/ApplicationUser.cs b/src/Infrastructure/Identity/ApplicationUser.cs index 0462aff..39cb433 100644 --- a/src/Infrastructure/Identity/ApplicationUser.cs +++ b/src/Infrastructure/Identity/ApplicationUser.cs @@ -6,4 +6,5 @@ public class ApplicationUser : IdentityUser { public string FirstName { get; set; } = string.Empty; public string LastName { get; set; } = string.Empty; + //public string Gender { get; set; } = string.Empty; } diff --git a/src/Infrastructure/Identity/IdentityService.cs b/src/Infrastructure/Identity/IdentityService.cs index 71a3886..5b32670 100644 --- a/src/Infrastructure/Identity/IdentityService.cs +++ b/src/Infrastructure/Identity/IdentityService.cs @@ -1,3 +1,4 @@ +using Google.Apis.Oauth2.v2.Data; using Hutopy.Application.Common.Interfaces; using Hutopy.Application.Common.Models; using Microsoft.AspNetCore.Authorization; @@ -30,6 +31,23 @@ public class IdentityService( return (result.ToApplicationResult(), user.Id); } + + public async Task<(Result Result, string UserId)> CreateUserAsync(Userinfo userInfo) + { + var user = new ApplicationUser + { + UserName = userInfo.Name, + Email = userInfo.Email, + FirstName = userInfo.GivenName, + LastName = userInfo.FamilyName + }; + + var password = Guid.NewGuid().ToString("N")[..32]; + + var result = await userManager.CreateAsync(user, password); + + return (result.ToApplicationResult(), user.Id); + } public async Task IsInRoleAsync(string userId, string role) { diff --git a/src/Infrastructure/Services/GoogleService.cs b/src/Infrastructure/Services/GoogleService.cs new file mode 100644 index 0000000..b8abdf2 --- /dev/null +++ b/src/Infrastructure/Services/GoogleService.cs @@ -0,0 +1,24 @@ +using Google.Apis.Auth.OAuth2; +using Google.Apis.Services; +using Google.Apis.Oauth2.v2; +using Google.Apis.Oauth2.v2.Data; +using Hutopy.Application.Common.Interfaces; + +namespace Hutopy.Infrastructure.Services; + +public class GoogleService : IGoogleService +{ + public async Task GetUserInfoAsync(string accessToken) + { + var user = GoogleCredential.FromAccessToken(accessToken); + + var service = new Oauth2Service( + new BaseClientService.Initializer + { + HttpClientInitializer = user, + ApplicationName = "Hutopy" + }); + + return await service.Userinfo.Get().ExecuteAsync(); + } +} diff --git a/src/Infrastructure/Services/UserService.cs b/src/Infrastructure/Services/UserService.cs index 98c0c7c..169c5f3 100644 --- a/src/Infrastructure/Services/UserService.cs +++ b/src/Infrastructure/Services/UserService.cs @@ -1,15 +1,13 @@ -using Hutopy.Domain.Interfaces; +using Google.Apis.Oauth2.v2.Data; +using Hutopy.Domain.Interfaces; using Hutopy.Domain.Models; using Hutopy.Infrastructure.Identity; -using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Identity; namespace Hutopy.Infrastructure.Services; public class UserService(UserManager userManager) : IUserService { - private readonly UserManager _userManager = userManager; - public async Task CreateUserAsync(string email, string userName, string firstName, string lastName, string password) { var applicationUser = new ApplicationUser @@ -19,19 +17,36 @@ public class UserService(UserManager userManager) : IUserServic FirstName = firstName, LastName = lastName }; - - //todo: Need to handle errors better for the user. - var response = await _userManager.CreateAsync(applicationUser, password); - - if (response.Errors.Any()) + + var response = await userManager.CreateAsync(applicationUser, password); + + if (!response.Succeeded) { - throw new InvalidOperationException(response.Errors.First().Description); + throw new Exception("Failed to create user"); + } + } + + public async Task CreateUserAsync(Userinfo userInfo) + { + var applicationUser = new ApplicationUser + { + UserName = userInfo.Name, + Email = userInfo.Email, + FirstName = userInfo.GivenName, + LastName = userInfo.FamilyName + }; + + var response = await userManager.CreateAsync(applicationUser, Guid.NewGuid().ToString("N")[..32]); + + if (!response.Succeeded) + { + throw new Exception("Failed to create user"); } } public async Task FindUserByIdAsync(string id) { - var response = await _userManager.FindByIdAsync(id); + var response = await userManager.FindByIdAsync(id); if (response == null) return null; @@ -49,7 +64,7 @@ public class UserService(UserManager userManager) : IUserServic public async Task FindUserByEmailAsync(string email) { - var response = await _userManager.FindByEmailAsync(email); + var response = await userManager.FindByEmailAsync(email); if (response == null) return null; diff --git a/src/Web/Endpoints/Google.cs b/src/Web/Endpoints/Google.cs new file mode 100644 index 0000000..129e71f --- /dev/null +++ b/src/Web/Endpoints/Google.cs @@ -0,0 +1,22 @@ +using Hutopy.Application.Common.Interfaces; +using Hutopy.Application.Google.Commands; +using Hutopy.Domain.Interfaces; + +namespace Hutopy.Web.Endpoints; + +public class Google : EndpointGroupBase +{ + public override void Map(WebApplication app) + { + app.MapGroup(this) + .MapPost(CreateGoogleUser); + } + + public static async Task CreateGoogleUser(ISender sender, CreateGoogleUserCommand command, IUserService userService, IGoogleService googleService) + { + var user = await googleService.GetUserInfoAsync(command.AccessToken) ?? throw new Exception("Failed to get user info from Google"); + Console.WriteLine(user); + await userService.CreateUserAsync(user); + return await sender.Send(command); + } +} From e05bd894b341e29e108b1e3038528eed878692dd Mon Sep 17 00:00:00 2001 From: Kamigen <46357922+Edouard127@users.noreply.github.com> Date: Tue, 16 Apr 2024 17:00:21 -0400 Subject: [PATCH 02/15] Removed test console --- src/Web/Endpoints/Google.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Web/Endpoints/Google.cs b/src/Web/Endpoints/Google.cs index 129e71f..8f3e323 100644 --- a/src/Web/Endpoints/Google.cs +++ b/src/Web/Endpoints/Google.cs @@ -15,8 +15,9 @@ public class Google : EndpointGroupBase public static async Task CreateGoogleUser(ISender sender, CreateGoogleUserCommand command, IUserService userService, IGoogleService googleService) { var user = await googleService.GetUserInfoAsync(command.AccessToken) ?? throw new Exception("Failed to get user info from Google"); - Console.WriteLine(user); + await userService.CreateUserAsync(user); + return await sender.Send(command); } } From 5f92998663b6cb325758160c2647d345ec8b837b Mon Sep 17 00:00:00 2001 From: Kamigen <46357922+Edouard127@users.noreply.github.com> Date: Mon, 22 Apr 2024 16:43:20 -0400 Subject: [PATCH 03/15] Feature: Google OAuth2 --- src/Application/Users/Commands/CreateUser.cs | 2 - src/Infrastructure/Services/UserService.cs | 45 ++++++++++++-------- src/Web/Program.cs | 2 + src/Web/wwwroot/api/specification.json | 42 ++++++++++++++++++ 4 files changed, 71 insertions(+), 20 deletions(-) diff --git a/src/Application/Users/Commands/CreateUser.cs b/src/Application/Users/Commands/CreateUser.cs index 8992f01..e15cc1b 100644 --- a/src/Application/Users/Commands/CreateUser.cs +++ b/src/Application/Users/Commands/CreateUser.cs @@ -1,8 +1,6 @@ using System.Dynamic; using Hutopy.Application.Common.Interfaces; -using Hutopy.Domain.Entities; using Hutopy.Domain.Interfaces; -using Microsoft.EntityFrameworkCore; namespace Hutopy.Application.Users.Commands; public record CreateUserCommand : IRequest diff --git a/src/Infrastructure/Services/UserService.cs b/src/Infrastructure/Services/UserService.cs index 169c5f3..767a1c4 100644 --- a/src/Infrastructure/Services/UserService.cs +++ b/src/Infrastructure/Services/UserService.cs @@ -1,4 +1,6 @@ -using Google.Apis.Oauth2.v2.Data; +using System.Security.Cryptography; +using System.Text; +using Google.Apis.Oauth2.v2.Data; using Hutopy.Domain.Interfaces; using Hutopy.Domain.Models; using Hutopy.Infrastructure.Identity; @@ -8,6 +10,8 @@ namespace Hutopy.Infrastructure.Services; public class UserService(UserManager userManager) : IUserService { + private readonly Random _random = new(DateTime.Now.Millisecond); + public async Task CreateUserAsync(string email, string userName, string firstName, string lastName, string password) { var applicationUser = new ApplicationUser @@ -22,26 +26,13 @@ public class UserService(UserManager userManager) : IUserServic if (!response.Succeeded) { - throw new Exception("Failed to create user"); + throw new Exception("Failed to create user", new AggregateException(response.Errors.Select(e => new Exception(e.Description)))); } } public async Task CreateUserAsync(Userinfo userInfo) { - var applicationUser = new ApplicationUser - { - UserName = userInfo.Name, - Email = userInfo.Email, - FirstName = userInfo.GivenName, - LastName = userInfo.FamilyName - }; - - var response = await userManager.CreateAsync(applicationUser, Guid.NewGuid().ToString("N")[..32]); - - if (!response.Succeeded) - { - throw new Exception("Failed to create user"); - } + await CreateUserAsync(userInfo.Email, userInfo.GivenName, userInfo.GivenName, userInfo.FamilyName, GeneratePassword(24)); } public async Task FindUserByIdAsync(string id) @@ -50,7 +41,7 @@ public class UserService(UserManager userManager) : IUserServic if (response == null) return null; - var userModel = new UserModel() + var userModel = new UserModel { Id = response.Id, UserName = response.UserName, @@ -68,7 +59,7 @@ public class UserService(UserManager userManager) : IUserServic if (response == null) return null; - var userModel = new UserModel() + var userModel = new UserModel { Id = response.Id, UserName = response.UserName, @@ -79,5 +70,23 @@ public class UserService(UserManager userManager) : IUserServic return userModel; } + + private const string Characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + private const string SpecialCharacters = "!@#$%^&*()_+"; + + private String GeneratePassword(int length) + { + // Using a string builder has additional overhead, maybe we can find something else + var password = new StringBuilder(); + + for (var i = 0; i < length; i++) + { + password.Append(Characters[_random.Next(Characters.Length)]); + } + + password.Append(SpecialCharacters[_random.Next(SpecialCharacters.Length)]); + + return password.ToString(); + } } diff --git a/src/Web/Program.cs b/src/Web/Program.cs index b2b34db..76fb1d8 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -1,4 +1,5 @@ using Hutopy.Application; +using Hutopy.Application.Common.Interfaces; using Hutopy.Domain.Interfaces; using Hutopy.Infrastructure; using Hutopy.Infrastructure.Data; @@ -48,6 +49,7 @@ builder.Services.AddInfrastructureServices(builder.Configuration); builder.Services.AddWebServices(); builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); diff --git a/src/Web/wwwroot/api/specification.json b/src/Web/wwwroot/api/specification.json index 944608b..fc53d77 100644 --- a/src/Web/wwwroot/api/specification.json +++ b/src/Web/wwwroot/api/specification.json @@ -6,6 +6,39 @@ "version": "1.0.0" }, "paths": { + "/api/Google": { + "post": { + "tags": [ + "Google" + ], + "operationId": "CreateGoogleUser", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateGoogleUserCommand" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "string", + "format": "guid" + } + } + } + } + } + } + }, "/api/JoinUs": { "get": { "tags": [ @@ -559,6 +592,15 @@ }, "components": { "schemas": { + "CreateGoogleUserCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "accessToken": { + "type": "string" + } + } + }, "PaginatedListOfFutureCreatorListDto": { "type": "object", "additionalProperties": false, From 3a9dbf42a133c3dca7106b398677ae9fcf371a5a Mon Sep 17 00:00:00 2001 From: Kamigen <46357922+Edouard127@users.noreply.github.com> Date: Sun, 28 Apr 2024 19:27:16 -0400 Subject: [PATCH 04/15] Update specification.json --- src/Web/wwwroot/api/specification.json | 42 ++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/Web/wwwroot/api/specification.json b/src/Web/wwwroot/api/specification.json index 5a1838c..f5515cb 100644 --- a/src/Web/wwwroot/api/specification.json +++ b/src/Web/wwwroot/api/specification.json @@ -26,6 +26,39 @@ } } }, + "/api/Google": { + "post": { + "tags": [ + "Google" + ], + "operationId": "CreateGoogleUser", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateGoogleUserCommand" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "string", + "format": "guid" + } + } + } + } + } + } + }, "/api/JoinUs": { "get": { "tags": [ @@ -649,6 +682,15 @@ } } }, + "CreateGoogleUserCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "accessToken": { + "type": "string" + } + } + }, "PaginatedListOfFutureCreatorListDto": { "type": "object", "additionalProperties": false, From be1d4cb3b61a1921775a19568b2cb734bd31b1d9 Mon Sep 17 00:00:00 2001 From: Kamigen <46357922+Edouard127@users.noreply.github.com> Date: Sun, 28 Apr 2024 19:52:31 -0400 Subject: [PATCH 05/15] TODO: Login user when account already exists --- src/Web/Endpoints/Google.cs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Web/Endpoints/Google.cs b/src/Web/Endpoints/Google.cs index 8f3e323..cd75695 100644 --- a/src/Web/Endpoints/Google.cs +++ b/src/Web/Endpoints/Google.cs @@ -14,9 +14,18 @@ public class Google : EndpointGroupBase public static async Task CreateGoogleUser(ISender sender, CreateGoogleUserCommand command, IUserService userService, IGoogleService googleService) { - var user = await googleService.GetUserInfoAsync(command.AccessToken) ?? throw new Exception("Failed to get user info from Google"); + var googleUser = await googleService.GetUserInfoAsync(command.AccessToken) ?? throw new Exception("Failed to get user info from Google"); - await userService.CreateUserAsync(user); + + + var user = await userService.FindUserByEmailAsync(googleUser.Email); + if (user != null) + { + // TODO: Return login information + return await sender.Send(command); + } + + await userService.CreateUserAsync(googleUser); return await sender.Send(command); } From 025195627c0faf30dbde05b0dcde616663186c3b Mon Sep 17 00:00:00 2001 From: Kamigen <46357922+Edouard127@users.noreply.github.com> Date: Mon, 29 Apr 2024 18:30:04 -0400 Subject: [PATCH 06/15] Test: Integrated google auth --- Directory.Packages.props | 2 + .../Common/Interfaces/IGoogleService.cs | 8 ---- .../Google/Commands/CreateGoogleUser.cs | 20 --------- src/Infrastructure/Infrastructure.csproj | 1 + src/Infrastructure/Services/GoogleService.cs | 24 ----------- src/Web/Endpoints/Google.cs | 32 -------------- src/Web/Program.cs | 18 +++++++- src/Web/Web.csproj | 2 + src/Web/wwwroot/api/specification.json | 42 ------------------- 9 files changed, 22 insertions(+), 127 deletions(-) delete mode 100644 src/Application/Common/Interfaces/IGoogleService.cs delete mode 100644 src/Application/Google/Commands/CreateGoogleUser.cs delete mode 100644 src/Infrastructure/Services/GoogleService.cs delete mode 100644 src/Web/Endpoints/Google.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 758ff65..d3ae35d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,6 +14,8 @@ + + diff --git a/src/Application/Common/Interfaces/IGoogleService.cs b/src/Application/Common/Interfaces/IGoogleService.cs deleted file mode 100644 index aba62f7..0000000 --- a/src/Application/Common/Interfaces/IGoogleService.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Google.Apis.Oauth2.v2.Data; - -namespace Hutopy.Application.Common.Interfaces; - -public interface IGoogleService -{ - Task GetUserInfoAsync(string accessToken); -} diff --git a/src/Application/Google/Commands/CreateGoogleUser.cs b/src/Application/Google/Commands/CreateGoogleUser.cs deleted file mode 100644 index a5f2bca..0000000 --- a/src/Application/Google/Commands/CreateGoogleUser.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Hutopy.Application.Common.Interfaces; - -namespace Hutopy.Application.Google.Commands; - -public record CreateGoogleUserCommand : IRequest -{ - public required string AccessToken { get; init; } -} - -public class CreateGoogleUser( - IApplicationDbContext context - ) : IRequestHandler -{ - public async Task Handle(CreateGoogleUserCommand request, CancellationToken cancellationToken) - { - await context.SaveChangesAsync(cancellationToken); - - return Guid.NewGuid(); - } -} diff --git a/src/Infrastructure/Infrastructure.csproj b/src/Infrastructure/Infrastructure.csproj index 2958bd6..108c958 100644 --- a/src/Infrastructure/Infrastructure.csproj +++ b/src/Infrastructure/Infrastructure.csproj @@ -4,6 +4,7 @@ Hutopy.Infrastructure + diff --git a/src/Infrastructure/Services/GoogleService.cs b/src/Infrastructure/Services/GoogleService.cs deleted file mode 100644 index b8abdf2..0000000 --- a/src/Infrastructure/Services/GoogleService.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Google.Apis.Auth.OAuth2; -using Google.Apis.Services; -using Google.Apis.Oauth2.v2; -using Google.Apis.Oauth2.v2.Data; -using Hutopy.Application.Common.Interfaces; - -namespace Hutopy.Infrastructure.Services; - -public class GoogleService : IGoogleService -{ - public async Task GetUserInfoAsync(string accessToken) - { - var user = GoogleCredential.FromAccessToken(accessToken); - - var service = new Oauth2Service( - new BaseClientService.Initializer - { - HttpClientInitializer = user, - ApplicationName = "Hutopy" - }); - - return await service.Userinfo.Get().ExecuteAsync(); - } -} diff --git a/src/Web/Endpoints/Google.cs b/src/Web/Endpoints/Google.cs deleted file mode 100644 index cd75695..0000000 --- a/src/Web/Endpoints/Google.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Hutopy.Application.Common.Interfaces; -using Hutopy.Application.Google.Commands; -using Hutopy.Domain.Interfaces; - -namespace Hutopy.Web.Endpoints; - -public class Google : EndpointGroupBase -{ - public override void Map(WebApplication app) - { - app.MapGroup(this) - .MapPost(CreateGoogleUser); - } - - public static async Task CreateGoogleUser(ISender sender, CreateGoogleUserCommand command, IUserService userService, IGoogleService googleService) - { - var googleUser = await googleService.GetUserInfoAsync(command.AccessToken) ?? throw new Exception("Failed to get user info from Google"); - - - - var user = await userService.FindUserByEmailAsync(googleUser.Email); - if (user != null) - { - // TODO: Return login information - return await sender.Send(command); - } - - await userService.CreateUserAsync(googleUser); - - return await sender.Send(command); - } -} diff --git a/src/Web/Program.cs b/src/Web/Program.cs index 76fb1d8..0e3863c 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -6,6 +6,9 @@ using Hutopy.Infrastructure.Data; using Hutopy.Infrastructure.Services; using Hutopy.Web; using Azure.Identity; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.Google; +using Microsoft.AspNetCore.Identity; var builder = WebApplication.CreateBuilder(args); @@ -48,8 +51,21 @@ builder.Services.AddApplicationServices(); builder.Services.AddInfrastructureServices(builder.Configuration); builder.Services.AddWebServices(); +// OAuth +builder.Services.AddAuthentication() + .AddGoogle(options => + { + options.ClientId = builder.Configuration["Google:ClientId"] ?? throw new ArgumentNullException("The Google ClientId is missing."); + options.ClientSecret = builder.Configuration["Google:ClientSecret"] ?? throw new ArgumentNullException("The Google ClientSecret is missing."); + options.CallbackPath = "/api/google/o/signin-callback"; + }); + /*.AddFacebook(options => + { + options.AppId = ""; // TODO + options.AppSecret = ""; // TODO + });*/ // We can add a lot more if needed, microsoft, twitter, etc. + builder.Services.AddScoped(); -builder.Services.AddScoped(); var app = builder.Build(); diff --git a/src/Web/Web.csproj b/src/Web/Web.csproj index a5444e7..d5e0c62 100644 --- a/src/Web/Web.csproj +++ b/src/Web/Web.csproj @@ -14,6 +14,8 @@ + + diff --git a/src/Web/wwwroot/api/specification.json b/src/Web/wwwroot/api/specification.json index f5515cb..5a1838c 100644 --- a/src/Web/wwwroot/api/specification.json +++ b/src/Web/wwwroot/api/specification.json @@ -26,39 +26,6 @@ } } }, - "/api/Google": { - "post": { - "tags": [ - "Google" - ], - "operationId": "CreateGoogleUser", - "requestBody": { - "x-name": "command", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateGoogleUserCommand" - } - } - }, - "required": true, - "x-position": 1 - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "string", - "format": "guid" - } - } - } - } - } - } - }, "/api/JoinUs": { "get": { "tags": [ @@ -682,15 +649,6 @@ } } }, - "CreateGoogleUserCommand": { - "type": "object", - "additionalProperties": false, - "properties": { - "accessToken": { - "type": "string" - } - } - }, "PaginatedListOfFutureCreatorListDto": { "type": "object", "additionalProperties": false, From f9a661c8d2be3cd33a5200a77ca25606318cc0ee Mon Sep 17 00:00:00 2001 From: Kamigen <46357922+Edouard127@users.noreply.github.com> Date: Wed, 1 May 2024 17:54:21 -0400 Subject: [PATCH 07/15] Test: ASP Google login --- src/Web/Endpoints/Google.cs | 23 +++++++++++++++++++++++ src/Web/Program.cs | 24 +++++++++++++++++++++--- src/Web/wwwroot/api/specification.json | 13 +++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 src/Web/Endpoints/Google.cs diff --git a/src/Web/Endpoints/Google.cs b/src/Web/Endpoints/Google.cs new file mode 100644 index 0000000..274426c --- /dev/null +++ b/src/Web/Endpoints/Google.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Google; + +namespace Hutopy.Web.Endpoints; + +public class Google : EndpointGroupBase +{ + public override void Map(WebApplication app) + { + app.MapGroup(this) + .MapGet("/o/sign-in", Callback); + } + + private static async Task Callback(ISender sender, HttpContext context) + { + await context.ChallengeAsync(GoogleDefaults.AuthenticationScheme, + new AuthenticationProperties + { + RedirectUri = "/o/google/callback", + ExpiresUtc = DateTimeOffset.UtcNow.AddDays(30), + }); + } +} diff --git a/src/Web/Program.cs b/src/Web/Program.cs index 0e3863c..1b712f9 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -1,11 +1,11 @@ using Hutopy.Application; -using Hutopy.Application.Common.Interfaces; using Hutopy.Domain.Interfaces; using Hutopy.Infrastructure; using Hutopy.Infrastructure.Data; using Hutopy.Infrastructure.Services; using Hutopy.Web; using Azure.Identity; +using Hutopy.Infrastructure.Identity; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Google; using Microsoft.AspNetCore.Identity; @@ -52,12 +52,30 @@ builder.Services.AddInfrastructureServices(builder.Configuration); builder.Services.AddWebServices(); // OAuth -builder.Services.AddAuthentication() +builder.Services.AddAuthentication(options => + { + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme; + }) + .AddCookie(options => + { + options.Cookie.Name = "Hutopy"; + options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.None : CookieSecurePolicy.Always; + options.Cookie.SameSite = SameSiteMode.Strict; + options.Cookie.HttpOnly = true; + options.Cookie.IsEssential = true; + options.Cookie.MaxAge = TimeSpan.FromDays(30); + }) .AddGoogle(options => { options.ClientId = builder.Configuration["Google:ClientId"] ?? throw new ArgumentNullException("The Google ClientId is missing."); options.ClientSecret = builder.Configuration["Google:ClientSecret"] ?? throw new ArgumentNullException("The Google ClientSecret is missing."); - options.CallbackPath = "/api/google/o/signin-callback"; + options.CallbackPath = "/o/google/callback"; + options.Events.OnRedirectToAuthorizationEndpoint = context => + { + context.Response.Redirect(context.RedirectUri + "&prompt=consent"); + return Task.CompletedTask; + }; }); /*.AddFacebook(options => { diff --git a/src/Web/wwwroot/api/specification.json b/src/Web/wwwroot/api/specification.json index 5a1838c..c080bf3 100644 --- a/src/Web/wwwroot/api/specification.json +++ b/src/Web/wwwroot/api/specification.json @@ -26,6 +26,19 @@ } } }, + "/api/Google/o/sign-in": { + "get": { + "tags": [ + "Google" + ], + "operationId": "GetApiGoogleOSignIn", + "responses": { + "200": { + "description": "" + } + } + } + }, "/api/JoinUs": { "get": { "tags": [ From b966e28d9a278565042d372e7277b401442836ce Mon Sep 17 00:00:00 2001 From: Kamigen <46357922+Edouard127@users.noreply.github.com> Date: Wed, 1 May 2024 18:15:52 -0400 Subject: [PATCH 08/15] Moved back to default redirect uri --- src/Web/Endpoints/Google.cs | 2 +- src/Web/Program.cs | 10 +++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/Web/Endpoints/Google.cs b/src/Web/Endpoints/Google.cs index 274426c..640ac44 100644 --- a/src/Web/Endpoints/Google.cs +++ b/src/Web/Endpoints/Google.cs @@ -16,7 +16,7 @@ public class Google : EndpointGroupBase await context.ChallengeAsync(GoogleDefaults.AuthenticationScheme, new AuthenticationProperties { - RedirectUri = "/o/google/callback", + RedirectUri = "/signin-google", ExpiresUtc = DateTimeOffset.UtcNow.AddDays(30), }); } diff --git a/src/Web/Program.cs b/src/Web/Program.cs index 1b712f9..0c0b449 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -52,12 +52,8 @@ builder.Services.AddInfrastructureServices(builder.Configuration); builder.Services.AddWebServices(); // OAuth -builder.Services.AddAuthentication(options => - { - options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme; - }) - .AddCookie(options => +builder.Services.AddAuthentication() + .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme,options => { options.Cookie.Name = "Hutopy"; options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.None : CookieSecurePolicy.Always; @@ -70,7 +66,7 @@ builder.Services.AddAuthentication(options => { options.ClientId = builder.Configuration["Google:ClientId"] ?? throw new ArgumentNullException("The Google ClientId is missing."); options.ClientSecret = builder.Configuration["Google:ClientSecret"] ?? throw new ArgumentNullException("The Google ClientSecret is missing."); - options.CallbackPath = "/o/google/callback"; + options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.Events.OnRedirectToAuthorizationEndpoint = context => { context.Response.Redirect(context.RedirectUri + "&prompt=consent"); From cd2bf64af59b4591cfb78b1d2d3f522322821045 Mon Sep 17 00:00:00 2001 From: Kamigen <46357922+Edouard127@users.noreply.github.com> Date: Wed, 1 May 2024 18:43:20 -0400 Subject: [PATCH 09/15] Added challenge result --- src/Web/Endpoints/Google.cs | 17 ++++++++------ src/Web/Program.cs | 31 +++++++++++++------------- src/Web/wwwroot/api/specification.json | 10 ++++++++- 3 files changed, 35 insertions(+), 23 deletions(-) diff --git a/src/Web/Endpoints/Google.cs b/src/Web/Endpoints/Google.cs index 640ac44..0a9a92d 100644 --- a/src/Web/Endpoints/Google.cs +++ b/src/Web/Endpoints/Google.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Google; +using Microsoft.AspNetCore.Mvc; namespace Hutopy.Web.Endpoints; @@ -11,13 +12,15 @@ public class Google : EndpointGroupBase .MapGet("/o/sign-in", Callback); } - private static async Task Callback(ISender sender, HttpContext context) + private static async Task Callback(ISender sender, HttpContext context) { - await context.ChallengeAsync(GoogleDefaults.AuthenticationScheme, - new AuthenticationProperties - { - RedirectUri = "/signin-google", - ExpiresUtc = DateTimeOffset.UtcNow.AddDays(30), - }); + var properties = new AuthenticationProperties + { + RedirectUri = "/signin-google", ExpiresUtc = DateTimeOffset.UtcNow.AddDays(30), + }; + + await context.ChallengeAsync(GoogleDefaults.AuthenticationScheme, properties); + + return new ChallengeResult(GoogleDefaults.AuthenticationScheme, properties); } } diff --git a/src/Web/Program.cs b/src/Web/Program.cs index 0c0b449..b6c6745 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -5,10 +5,7 @@ using Hutopy.Infrastructure.Data; using Hutopy.Infrastructure.Services; using Hutopy.Web; using Azure.Identity; -using Hutopy.Infrastructure.Identity; using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Authentication.Google; -using Microsoft.AspNetCore.Identity; var builder = WebApplication.CreateBuilder(args); @@ -52,11 +49,16 @@ builder.Services.AddInfrastructureServices(builder.Configuration); builder.Services.AddWebServices(); // OAuth -builder.Services.AddAuthentication() - .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme,options => +builder.Services.AddAuthorization(); +builder.Services.AddAuthentication(options => + { + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + }) + .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options => { options.Cookie.Name = "Hutopy"; - options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.None : CookieSecurePolicy.Always; + options.Cookie.SecurePolicy = + builder.Environment.IsDevelopment() ? CookieSecurePolicy.None : CookieSecurePolicy.Always; options.Cookie.SameSite = SameSiteMode.Strict; options.Cookie.HttpOnly = true; options.Cookie.IsEssential = true; @@ -64,8 +66,10 @@ builder.Services.AddAuthentication() }) .AddGoogle(options => { - options.ClientId = builder.Configuration["Google:ClientId"] ?? throw new ArgumentNullException("The Google ClientId is missing."); - options.ClientSecret = builder.Configuration["Google:ClientSecret"] ?? throw new ArgumentNullException("The Google ClientSecret is missing."); + options.ClientId = builder.Configuration["Google:ClientId"] ?? + throw new ArgumentNullException("The Google ClientId is missing."); + options.ClientSecret = builder.Configuration["Google:ClientSecret"] ?? + throw new ArgumentNullException("The Google ClientSecret is missing."); options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.Events.OnRedirectToAuthorizationEndpoint = context => { @@ -73,16 +77,15 @@ builder.Services.AddAuthentication() return Task.CompletedTask; }; }); - /*.AddFacebook(options => - { - options.AppId = ""; // TODO - options.AppSecret = ""; // TODO - });*/ // We can add a lot more if needed, microsoft, twitter, etc. +builder.Services.AddControllers(); builder.Services.AddScoped(); var app = builder.Build(); +app.UseAuthentication(); +app.UseAuthorization(); + app.UseCors("AllowAll"); app.UseCors("AllowHutopyUi"); app.UseCors("AllowHutopyUiPreview"); @@ -111,8 +114,6 @@ app.MapControllerRoute( name: "default", pattern: "{controller}/{action=Index}/{id?}"); -app.MapRazorPages(); - app.MapFallbackToFile("index.html"); app.UseExceptionHandler(options => { }); diff --git a/src/Web/wwwroot/api/specification.json b/src/Web/wwwroot/api/specification.json index c080bf3..79be20e 100644 --- a/src/Web/wwwroot/api/specification.json +++ b/src/Web/wwwroot/api/specification.json @@ -34,7 +34,15 @@ "operationId": "GetApiGoogleOSignIn", "responses": { "200": { - "description": "" + "description": "", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } } } } From bbbfddd6cbd09616aa3336480f0601744832f698 Mon Sep 17 00:00:00 2001 From: Kamigen <46357922+Edouard127@users.noreply.github.com> Date: Wed, 8 May 2024 19:04:25 -0400 Subject: [PATCH 10/15] Feature: Google oauth --- src/Infrastructure/Services/UserService.cs | 70 ++++++++++++++++------ src/Web/Controllers/GoogleController.cs | 54 +++++++++++++++++ src/Web/Endpoints/Google.cs | 26 -------- src/Web/Program.cs | 43 +++++++------ src/Web/wwwroot/api/specification.json | 34 ++++------- 5 files changed, 143 insertions(+), 84 deletions(-) create mode 100644 src/Web/Controllers/GoogleController.cs delete mode 100644 src/Web/Endpoints/Google.cs diff --git a/src/Infrastructure/Services/UserService.cs b/src/Infrastructure/Services/UserService.cs index b45e3b3..12910a4 100644 --- a/src/Infrastructure/Services/UserService.cs +++ b/src/Infrastructure/Services/UserService.cs @@ -32,7 +32,7 @@ public class UserService(UserManager userManager) : IUserServic public async Task CreateUserAsync(Userinfo userInfo) { - await CreateUserAsync(userInfo.Email, userInfo.GivenName, userInfo.GivenName, userInfo.FamilyName, GeneratePassword(24)); + await CreateUserAsync(userInfo.Email, userInfo.GivenName, userInfo.GivenName, userInfo.FamilyName, RandomGenerator.RandomString(24)); } public async Task FindUserByIdAsync(string id) @@ -82,23 +82,55 @@ public class UserService(UserManager userManager) : IUserServic return userModel; } - - private const string Characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - private const string SpecialCharacters = "!@#$%^&*()_+"; - - private String GeneratePassword(int length) - { - // Using a string builder has additional overhead, maybe we can find something else - var password = new StringBuilder(); - - for (var i = 0; i < length; i++) - { - password.Append(Characters[_random.Next(Characters.Length)]); - } - - password.Append(SpecialCharacters[_random.Next(SpecialCharacters.Length)]); - - return password.ToString(); - } } +public class RandomGenerator +{ + 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/GoogleController.cs b/src/Web/Controllers/GoogleController.cs new file mode 100644 index 0000000..173413a --- /dev/null +++ b/src/Web/Controllers/GoogleController.cs @@ -0,0 +1,54 @@ +using System.Security.Claims; +using Google.Apis.Oauth2.v2.Data; +using Hutopy.Domain.Interfaces; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.Google; +using Microsoft.AspNetCore.Mvc; + +namespace Hutopy.Web.Controllers; + +public class GoogleController( + IUserService userService) : Controller +{ + [HttpGet("/api/google/sign-in")] + public async Task SignIn() + { + await HttpContext.ChallengeAsync(GoogleDefaults.AuthenticationScheme, new AuthenticationProperties + { + RedirectUri = Url.Action("Callback"), + }); + } + + public async Task Callback() + { + var authenticateResult = await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme); + + if (!authenticateResult.Succeeded) + { + return BadRequest(); + } + + var claims = authenticateResult.Principal.Claims.ToList(); + + var userInfo = new Userinfo + { + Name = claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value, + Email = claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value, + GivenName = claims.FirstOrDefault(c => c.Type == ClaimTypes.GivenName)?.Value, + FamilyName = claims.FirstOrDefault(c => c.Type == ClaimTypes.Surname)?.Value + }; + + await userService.CreateUserAsync(userInfo); // TODO: Don't create user if already exists + + await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(new ClaimsIdentity(new List + { + new(ClaimTypes.Name, userInfo.Name), + new(ClaimTypes.Email, userInfo.Email), + new(ClaimTypes.GivenName, userInfo.GivenName), + new(ClaimTypes.Surname, userInfo.FamilyName) + }, CookieAuthenticationDefaults.AuthenticationScheme))); + + return Redirect("/"); + } +} diff --git a/src/Web/Endpoints/Google.cs b/src/Web/Endpoints/Google.cs deleted file mode 100644 index 0a9a92d..0000000 --- a/src/Web/Endpoints/Google.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Google; -using Microsoft.AspNetCore.Mvc; - -namespace Hutopy.Web.Endpoints; - -public class Google : EndpointGroupBase -{ - public override void Map(WebApplication app) - { - app.MapGroup(this) - .MapGet("/o/sign-in", Callback); - } - - private static async Task Callback(ISender sender, HttpContext context) - { - var properties = new AuthenticationProperties - { - RedirectUri = "/signin-google", ExpiresUtc = DateTimeOffset.UtcNow.AddDays(30), - }; - - await context.ChallengeAsync(GoogleDefaults.AuthenticationScheme, properties); - - return new ChallengeResult(GoogleDefaults.AuthenticationScheme, properties); - } -} diff --git a/src/Web/Program.cs b/src/Web/Program.cs index b6c6745..5407839 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -5,7 +5,11 @@ using Hutopy.Infrastructure.Data; using Hutopy.Infrastructure.Services; using Hutopy.Web; using Azure.Identity; +using Hutopy.Infrastructure.Identity; using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.Google; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.Identity; var builder = WebApplication.CreateBuilder(args); @@ -49,40 +53,43 @@ builder.Services.AddInfrastructureServices(builder.Configuration); builder.Services.AddWebServices(); // OAuth -builder.Services.AddAuthorization(); builder.Services.AddAuthentication(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme; }) - .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options => - { - options.Cookie.Name = "Hutopy"; - options.Cookie.SecurePolicy = - builder.Environment.IsDevelopment() ? CookieSecurePolicy.None : CookieSecurePolicy.Always; - options.Cookie.SameSite = SameSiteMode.Strict; - options.Cookie.HttpOnly = true; - options.Cookie.IsEssential = true; - options.Cookie.MaxAge = TimeSpan.FromDays(30); - }) - .AddGoogle(options => + .AddCookie() + .AddGoogle( + GoogleDefaults.AuthenticationScheme, + options => { options.ClientId = builder.Configuration["Google:ClientId"] ?? throw new ArgumentNullException("The Google ClientId is missing."); options.ClientSecret = builder.Configuration["Google:ClientSecret"] ?? throw new ArgumentNullException("The Google ClientSecret is missing."); - options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; - options.Events.OnRedirectToAuthorizationEndpoint = context => - { - context.Response.Redirect(context.RedirectUri + "&prompt=consent"); - return Task.CompletedTask; - }; }); +// Password hashing +builder.Services.AddIdentity(options => + { + options.Password.RequireDigit = true; + options.Password.RequireLowercase = false; + options.Password.RequireUppercase = true; + options.Password.RequireNonAlphanumeric = true; + options.Password.RequiredLength = 8; + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + builder.Services.AddControllers(); builder.Services.AddScoped(); var app = builder.Build(); +app.UseForwardedHeaders( + new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedProto } +); + app.UseAuthentication(); app.UseAuthorization(); diff --git a/src/Web/wwwroot/api/specification.json b/src/Web/wwwroot/api/specification.json index 79be20e..fcb4d9f 100644 --- a/src/Web/wwwroot/api/specification.json +++ b/src/Web/wwwroot/api/specification.json @@ -26,27 +26,6 @@ } } }, - "/api/Google/o/sign-in": { - "get": { - "tags": [ - "Google" - ], - "operationId": "GetApiGoogleOSignIn", - "responses": { - "200": { - "description": "", - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - } - } - } - }, "/api/JoinUs": { "get": { "tags": [ @@ -628,6 +607,19 @@ } ] } + }, + "/api/google/sign-in": { + "get": { + "tags": [ + "Google" + ], + "operationId": "Google_SignIn", + "responses": { + "200": { + "description": "" + } + } + } } }, "components": { From fc0c94306b2a4bb591125865688e6fc301697a28 Mon Sep 17 00:00:00 2001 From: Kamigen <46357922+Edouard127@users.noreply.github.com> Date: Fri, 24 May 2024 17:00:25 -0400 Subject: [PATCH 11/15] Fix: Sign-in user if exists --- src/Infrastructure/Services/UserService.cs | 4 ++++ src/Web/Controllers/GoogleController.cs | 14 ++++++++++---- src/Web/Program.cs | 8 ++++---- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/Infrastructure/Services/UserService.cs b/src/Infrastructure/Services/UserService.cs index ebb5695..8610844 100644 --- a/src/Infrastructure/Services/UserService.cs +++ b/src/Infrastructure/Services/UserService.cs @@ -84,8 +84,12 @@ public class UserService(UserManager userManager, IHttpContextA } } +// 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" + "!@#$%^&*()_+" diff --git a/src/Web/Controllers/GoogleController.cs b/src/Web/Controllers/GoogleController.cs index 173413a..236f130 100644 --- a/src/Web/Controllers/GoogleController.cs +++ b/src/Web/Controllers/GoogleController.cs @@ -39,16 +39,22 @@ public class GoogleController( FamilyName = claims.FirstOrDefault(c => c.Type == ClaimTypes.Surname)?.Value }; - await userService.CreateUserAsync(userInfo); // TODO: Don't create user if already exists - - await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(new ClaimsIdentity(new List + var claimsIdentity = new ClaimsIdentity(new List { new(ClaimTypes.Name, userInfo.Name), new(ClaimTypes.Email, userInfo.Email), new(ClaimTypes.GivenName, userInfo.GivenName), new(ClaimTypes.Surname, userInfo.FamilyName) - }, CookieAuthenticationDefaults.AuthenticationScheme))); + }, CookieAuthenticationDefaults.AuthenticationScheme); + if (await userService.FindUserByEmailAsync(userInfo.Email) != null) // TODO: Do we need to check for null ? + { + await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(claimsIdentity)); + return Redirect("/"); + } + + await userService.CreateUserAsync(userInfo); + await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(claimsIdentity)); return Redirect("/"); } } diff --git a/src/Web/Program.cs b/src/Web/Program.cs index 5407839..c49790c 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -72,11 +72,11 @@ builder.Services.AddAuthentication(options => // Password hashing builder.Services.AddIdentity(options => { - options.Password.RequireDigit = true; + options.Password.RequireDigit = false; options.Password.RequireLowercase = false; - options.Password.RequireUppercase = true; - options.Password.RequireNonAlphanumeric = true; - options.Password.RequiredLength = 8; + options.Password.RequireUppercase = false; + options.Password.RequireNonAlphanumeric = false; + options.Password.RequiredLength = 16; }) .AddEntityFrameworkStores() .AddDefaultTokenProviders(); From aa8a5ad93d52295b3a318ae40f1fe1c4d117100a Mon Sep 17 00:00:00 2001 From: Kamigen <46357922+Edouard127@users.noreply.github.com> Date: Wed, 5 Jun 2024 16:01:44 -0400 Subject: [PATCH 12/15] Feature: Google and Facebook sign-in --- src/Domain/Interfaces/IUserService.cs | 5 +- src/Infrastructure/Services/UserService.cs | 6 --- src/Web/Controllers/FacebookController.cs | 53 ++++++++++++++++++++++ src/Web/Controllers/GoogleController.cs | 39 +++++++--------- src/Web/Program.cs | 14 +++++- src/Web/wwwroot/api/specification.json | 13 ++++++ 6 files changed, 96 insertions(+), 34 deletions(-) create mode 100644 src/Web/Controllers/FacebookController.cs diff --git a/src/Domain/Interfaces/IUserService.cs b/src/Domain/Interfaces/IUserService.cs index 2af0f97..efe46bb 100644 --- a/src/Domain/Interfaces/IUserService.cs +++ b/src/Domain/Interfaces/IUserService.cs @@ -1,5 +1,4 @@ -using Google.Apis.Oauth2.v2.Data; -using Hutopy.Domain.Models; +using Hutopy.Domain.Models; namespace Hutopy.Domain.Interfaces; @@ -7,8 +6,6 @@ public interface IUserService { Task CreateUserAsync(string email, string userName, string firstName, string lastName, string password); - Task CreateUserAsync(Userinfo userInfo); - Task FindUserByIdAsync(string id); Task GetCurrentUserAsync(); diff --git a/src/Infrastructure/Services/UserService.cs b/src/Infrastructure/Services/UserService.cs index 8610844..4e52033 100644 --- a/src/Infrastructure/Services/UserService.cs +++ b/src/Infrastructure/Services/UserService.cs @@ -1,5 +1,4 @@ using System.Text; -using Google.Apis.Oauth2.v2.Data; using System.Security.Claims; using Hutopy.Domain.Interfaces; using Hutopy.Domain.Models; @@ -30,11 +29,6 @@ public class UserService(UserManager userManager, IHttpContextA } } - public async Task CreateUserAsync(Userinfo userInfo) - { - await CreateUserAsync(userInfo.Email, userInfo.GivenName, userInfo.GivenName, userInfo.FamilyName, RandomGenerator.RandomString(24)); - } - public async Task FindUserByIdAsync(string id) { var response = await userManager.FindByIdAsync(id); diff --git a/src/Web/Controllers/FacebookController.cs b/src/Web/Controllers/FacebookController.cs new file mode 100644 index 0000000..394eea1 --- /dev/null +++ b/src/Web/Controllers/FacebookController.cs @@ -0,0 +1,53 @@ +using System.Security.Claims; +using Hutopy.Domain.Interfaces; +using Hutopy.Infrastructure.Services; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.Facebook; +using Microsoft.AspNetCore.Mvc; + +namespace Hutopy.Web.Controllers; + +public class FacebookController(IUserService userService) : Controller +{ + [HttpGet("/api/facebook/sign-in")] + public async Task SignIn() + { + await HttpContext.ChallengeAsync(FacebookDefaults.AuthenticationScheme, new AuthenticationProperties + { + RedirectUri = Url.Action("Authorize") + }); + } + + public async Task Authorize() + { + var authenticateResult = await HttpContext.AuthenticateAsync(FacebookDefaults.AuthenticationScheme); + + if (!authenticateResult.Succeeded) return BadRequest(); + + var claims = authenticateResult.Principal.Claims.ToList(); + + var name = claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value; + var email = claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value; + var givenName = claims.FirstOrDefault(c => c.Type == ClaimTypes.GivenName)?.Value; + var familyName = claims.FirstOrDefault(c => c.Type == ClaimTypes.Surname)?.Value; + + var claimsIdentity = new ClaimsIdentity(new List + { + new(ClaimTypes.Name, name), + new(ClaimTypes.Email, email), + new(ClaimTypes.GivenName, givenName), + new(ClaimTypes.Surname, familyName) + }, CookieAuthenticationDefaults.AuthenticationScheme); + + if (await userService.FindUserByEmailAsync(email) != null) + { + await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(claimsIdentity)); + return Redirect("/"); + } + + await userService.CreateUserAsync(email, givenName, givenName, familyName, RandomGenerator.RandomString(24)); + await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(claimsIdentity)); + return Redirect("/"); + } +} diff --git a/src/Web/Controllers/GoogleController.cs b/src/Web/Controllers/GoogleController.cs index 236f130..0780277 100644 --- a/src/Web/Controllers/GoogleController.cs +++ b/src/Web/Controllers/GoogleController.cs @@ -1,6 +1,6 @@ using System.Security.Claims; -using Google.Apis.Oauth2.v2.Data; using Hutopy.Domain.Interfaces; +using Hutopy.Infrastructure.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Google; @@ -8,52 +8,45 @@ using Microsoft.AspNetCore.Mvc; namespace Hutopy.Web.Controllers; -public class GoogleController( - IUserService userService) : Controller +public class GoogleController(IUserService userService) : Controller { [HttpGet("/api/google/sign-in")] public async Task SignIn() { await HttpContext.ChallengeAsync(GoogleDefaults.AuthenticationScheme, new AuthenticationProperties { - RedirectUri = Url.Action("Callback"), + RedirectUri = Url.Action("Authorize") }); } - public async Task Callback() + public async Task Authorize() { - var authenticateResult = await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme); + var authenticateResult = await HttpContext.AuthenticateAsync(GoogleDefaults.AuthenticationScheme); - if (!authenticateResult.Succeeded) - { - return BadRequest(); - } + if (!authenticateResult.Succeeded) return BadRequest(); var claims = authenticateResult.Principal.Claims.ToList(); - var userInfo = new Userinfo - { - Name = claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value, - Email = claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value, - GivenName = claims.FirstOrDefault(c => c.Type == ClaimTypes.GivenName)?.Value, - FamilyName = claims.FirstOrDefault(c => c.Type == ClaimTypes.Surname)?.Value - }; + var name = claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value; + var email = claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value; + var givenName = claims.FirstOrDefault(c => c.Type == ClaimTypes.GivenName)?.Value; + var familyName = claims.FirstOrDefault(c => c.Type == ClaimTypes.Surname)?.Value; var claimsIdentity = new ClaimsIdentity(new List { - new(ClaimTypes.Name, userInfo.Name), - new(ClaimTypes.Email, userInfo.Email), - new(ClaimTypes.GivenName, userInfo.GivenName), - new(ClaimTypes.Surname, userInfo.FamilyName) + new(ClaimTypes.Name, name), + new(ClaimTypes.Email, email), + new(ClaimTypes.GivenName, givenName), + new(ClaimTypes.Surname, familyName) }, CookieAuthenticationDefaults.AuthenticationScheme); - if (await userService.FindUserByEmailAsync(userInfo.Email) != null) // TODO: Do we need to check for null ? + if (await userService.FindUserByEmailAsync(email) != null) { await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(claimsIdentity)); return Redirect("/"); } - await userService.CreateUserAsync(userInfo); + await userService.CreateUserAsync(email, givenName, givenName, familyName, RandomGenerator.RandomString(24)); await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(claimsIdentity)); return Redirect("/"); } diff --git a/src/Web/Program.cs b/src/Web/Program.cs index c49790c..45dfcf2 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -7,6 +7,7 @@ using Hutopy.Web; using Azure.Identity; using Hutopy.Infrastructure.Identity; using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.Facebook; using Microsoft.AspNetCore.Authentication.Google; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; @@ -56,7 +57,7 @@ builder.Services.AddWebServices(); builder.Services.AddAuthentication(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme; }) .AddCookie() .AddGoogle( @@ -67,6 +68,17 @@ builder.Services.AddAuthentication(options => throw new ArgumentNullException("The Google ClientId is missing."); options.ClientSecret = builder.Configuration["Google:ClientSecret"] ?? throw new ArgumentNullException("The Google ClientSecret is missing."); + //options.AccessDeniedPath = "/AccessDeniedPathInfo"; + }) + .AddFacebook( + FacebookDefaults.AuthenticationScheme, + options => + { + options.ClientId = builder.Configuration["Facebook:ClientId"] ?? + throw new ArgumentNullException("The Facebook ClientId is missing."); + options.ClientSecret = builder.Configuration["Facebook:ClientSecret"] ?? + throw new ArgumentNullException("The Facebook ClientSecret is missing."); + //options.AccessDeniedPath = "/AccessDeniedPathInfo"; }); // Password hashing diff --git a/src/Web/wwwroot/api/specification.json b/src/Web/wwwroot/api/specification.json index b5a4c09..a6129af 100644 --- a/src/Web/wwwroot/api/specification.json +++ b/src/Web/wwwroot/api/specification.json @@ -685,6 +685,19 @@ ] } }, + "/api/facebook/sign-in": { + "get": { + "tags": [ + "Facebook" + ], + "operationId": "Facebook_SignIn", + "responses": { + "200": { + "description": "" + } + } + } + }, "/api/google/sign-in": { "get": { "tags": [ From 6f76cb208474b8a6deebf4698e19d17a9f5daac3 Mon Sep 17 00:00:00 2001 From: Dominic Villemure Date: Sun, 9 Jun 2024 23:44:37 -0400 Subject: [PATCH 13/15] #oauth changed GoogleController for the jwt flow ( using a common token if we connect from our app or from google ) --- Directory.Packages.props | 1 + .../Common/Interfaces/IIdentityService.cs | 5 +- src/Application/Users/Commands/Login.cs | 27 + src/Infrastructure/DependencyInjection.cs | 17 +- .../Identity/IdentityService.cs | 25 +- src/Infrastructure/Services/UserService.cs | 134 ---- src/Infrastructure/Utils/GenerateJwtToken.cs | 31 + src/Infrastructure/Utils/RandomGenerator.cs | 58 ++ src/Web/Controllers/FacebookController.cs | 18 +- src/Web/Controllers/GoogleController.cs | 85 ++- src/Web/DependencyInjection.cs | 51 +- src/Web/Endpoints/Users.cs | 9 +- src/Web/Program.cs | 56 +- src/Web/Web.csproj | 1 + src/Web/appsettings.Development.dist | 30 +- src/Web/wwwroot/api/specification.json | 632 ++---------------- 16 files changed, 338 insertions(+), 842 deletions(-) create mode 100644 src/Application/Users/Commands/Login.cs delete mode 100644 src/Infrastructure/Services/UserService.cs create mode 100644 src/Infrastructure/Utils/GenerateJwtToken.cs create mode 100644 src/Infrastructure/Utils/RandomGenerator.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index b8593ab..f4732ee 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,6 +16,7 @@ + diff --git a/src/Application/Common/Interfaces/IIdentityService.cs b/src/Application/Common/Interfaces/IIdentityService.cs index 5451d37..cd97117 100644 --- a/src/Application/Common/Interfaces/IIdentityService.cs +++ b/src/Application/Common/Interfaces/IIdentityService.cs @@ -10,15 +10,12 @@ public interface IIdentityService Task FindUserByIdAsync(string id); Task GetCurrentUserAsync(); Task FindUserByEmailAsync(string id); + Task LoginAsync(string email, string password); Task GetUserByUserNameAsync(string userName); Task IsInRoleAsync(string userId, string role); Task AuthorizeAsync(string userId, string policyName); Task AddRoleAsync(string userId, string role); Task> GetCurrentUserRolesAsync(); - - Task<(Result Result, string UserId)> CreateUserAsync(string userName, string password); - Task<(Result Result, string UserId)> CreateUserAsync(Userinfo userInfo); - Task DeleteUserAsync(string userId); } diff --git a/src/Application/Users/Commands/Login.cs b/src/Application/Users/Commands/Login.cs new file mode 100644 index 0000000..f61f810 --- /dev/null +++ b/src/Application/Users/Commands/Login.cs @@ -0,0 +1,27 @@ +using Hutopy.Application.Common.Interfaces; + +namespace Hutopy.Application.Users.Commands; +public record LoginCommand : IRequest +{ + public required string EmailAddress { get; init; } + public required string Password { get; init; } +} + +public class LoginCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + private readonly IIdentityService _identityService; + + public LoginCommandHandler(IApplicationDbContext context, IIdentityService identityService) + { + _context = context; + _identityService = identityService; + } + + public async Task Handle(LoginCommand request, CancellationToken cancellationToken) + { + var jwt = await _identityService.LoginAsync(request.EmailAddress, request.Password); + + return jwt ?? "Invalid login credentials"; + } +} diff --git a/src/Infrastructure/DependencyInjection.cs b/src/Infrastructure/DependencyInjection.cs index 31b0b30..42ca0c3 100644 --- a/src/Infrastructure/DependencyInjection.cs +++ b/src/Infrastructure/DependencyInjection.cs @@ -48,13 +48,20 @@ public static class DependencyInjection .AddBearerToken(IdentityConstants.BearerScheme); services.AddAuthorizationBuilder(); - - // Might need to change and use AddIdentity() when we need to integrate connection via third party ( facebook, google ) - services - .AddIdentityCore() + + services.AddIdentityCore(options => + { + options.Password.RequireDigit = false; + options.Password.RequireLowercase = false; + options.Password.RequireUppercase = false; + options.Password.RequireNonAlphanumeric = false; + options.Password.RequiredLength = 8; + }) .AddRoles() .AddEntityFrameworkStores() - .AddApiEndpoints(); + .AddApiEndpoints() + .AddSignInManager>() + .AddDefaultTokenProviders(); services.AddSingleton(TimeProvider.System); services.AddScoped(); diff --git a/src/Infrastructure/Identity/IdentityService.cs b/src/Infrastructure/Identity/IdentityService.cs index b7f7d77..0a25594 100644 --- a/src/Infrastructure/Identity/IdentityService.cs +++ b/src/Infrastructure/Identity/IdentityService.cs @@ -2,17 +2,21 @@ using Google.Apis.Oauth2.v2.Data; using System.Security.Claims; using Hutopy.Application.Common.Interfaces; using Hutopy.Application.Common.Models; +using Hutopy.Infrastructure.Utils; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Configuration; namespace Hutopy.Infrastructure.Identity; public class IdentityService( UserManager userManager, + SignInManager signInManager, IUserClaimsPrincipalFactory userClaimsPrincipalFactory, IAuthorizationService authorizationService, - IHttpContextAccessor contextAccessor + IHttpContextAccessor contextAccessor, + IConfiguration configuration ) : IIdentityService { @@ -205,4 +209,23 @@ public class IdentityService( return userRoles; } + + public async Task LoginAsync(string userName, string password) + { + var result = await signInManager.PasswordSignInAsync(userName, password, isPersistent: false, lockoutOnFailure: false); + + if (!result.Succeeded) + { + return null; + } + + var user = await GetUserByUserNameAsync(userName); + var token = JwtTokenHelper.GenerateJwtToken( + issuer: configuration["Jwt:Issuer"] ?? "", + audience: configuration["Jwt:Audience"] ?? "", + key: configuration["Jwt:Key"] ?? "", + userId: user?.Id ?? ""); + + return token; + } } diff --git a/src/Infrastructure/Services/UserService.cs b/src/Infrastructure/Services/UserService.cs deleted file mode 100644 index 4e52033..0000000 --- a/src/Infrastructure/Services/UserService.cs +++ /dev/null @@ -1,134 +0,0 @@ -using System.Text; -using System.Security.Claims; -using Hutopy.Domain.Interfaces; -using Hutopy.Domain.Models; -using Hutopy.Infrastructure.Identity; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Identity; - -namespace Hutopy.Infrastructure.Services; - -public class UserService(UserManager userManager, IHttpContextAccessor contextAccessor) : IUserService -{ - public async Task CreateUserAsync(string email, string userName, string firstName, string lastName, string password) - { - var applicationUser = new ApplicationUser - { - UserName = userName, - Email = email, - FirstName = firstName, - LastName = lastName - }; - - //todo: Need to handle errors better for the user. - var response = await userManager.CreateAsync(applicationUser, password); - - if (response.Errors.Any()) - { - throw new Exception("Failed to create user", new AggregateException(response.Errors.Select(e => new Exception(e.Description)))); - } - } - - public async Task FindUserByIdAsync(string id) - { - var response = await userManager.FindByIdAsync(id); - - if (response == null) return null; - - var userModel = new UserModel - { - Id = response.Id, - UserName = response.UserName, - FirstName = response.FirstName, - LastName = response.LastName, - Email = response.Email, - }; - - return userModel; - } - - public async Task GetCurrentUserAsync() - { - // todo: Get the id of the user doing the request. - var currentUserId = contextAccessor.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (string.IsNullOrEmpty(currentUserId)) - { - return null; - } - - return await FindUserByIdAsync(currentUserId); - } - - public async Task FindUserByEmailAsync(string email) - { - var response = await userManager.FindByEmailAsync(email); - - if (response == null) return null; - - var userModel = new UserModel - { - Id = response.Id, - UserName = response.UserName, - FirstName = response.FirstName, - LastName = response.LastName, - Email = response.Email - }; - - return userModel; - } -} - -// 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/Infrastructure/Utils/GenerateJwtToken.cs b/src/Infrastructure/Utils/GenerateJwtToken.cs new file mode 100644 index 0000000..9f01d6a --- /dev/null +++ b/src/Infrastructure/Utils/GenerateJwtToken.cs @@ -0,0 +1,31 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.IdentityModel.Tokens; + +namespace Hutopy.Infrastructure.Utils; + +public static class JwtTokenHelper +{ + public static string GenerateJwtToken(string issuer, string audience, string key, string userId) + { + var claims = new[] + { + new Claim(JwtRegisteredClaimNames.Sub, userId), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new Claim(ClaimTypes.NameIdentifier, userId) + }; + + var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)); + var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: issuer, + audience: audience, + claims: claims, + expires: DateTime.Now.AddMinutes(30), + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} diff --git a/src/Infrastructure/Utils/RandomGenerator.cs b/src/Infrastructure/Utils/RandomGenerator.cs new file mode 100644 index 0000000..1e5a9a2 --- /dev/null +++ b/src/Infrastructure/Utils/RandomGenerator.cs @@ -0,0 +1,58 @@ +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 394eea1..679502e 100644 --- a/src/Web/Controllers/FacebookController.cs +++ b/src/Web/Controllers/FacebookController.cs @@ -1,6 +1,6 @@ using System.Security.Claims; -using Hutopy.Domain.Interfaces; -using Hutopy.Infrastructure.Services; +using Hutopy.Application.Common.Interfaces; +using Hutopy.Infrastructure.Utils; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Facebook; @@ -8,7 +8,7 @@ using Microsoft.AspNetCore.Mvc; namespace Hutopy.Web.Controllers; -public class FacebookController(IUserService userService) : Controller +public class FacebookController(IIdentityService identityService) : Controller { [HttpGet("/api/facebook/sign-in")] public async Task SignIn() @@ -27,10 +27,10 @@ public class FacebookController(IUserService userService) : Controller var claims = authenticateResult.Principal.Claims.ToList(); - var name = claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value; - var email = claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value; - var givenName = claims.FirstOrDefault(c => c.Type == ClaimTypes.GivenName)?.Value; - var familyName = claims.FirstOrDefault(c => c.Type == ClaimTypes.Surname)?.Value; + var name = claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value ?? ""; + var email = claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value ?? ""; + var givenName = claims.FirstOrDefault(c => c.Type == ClaimTypes.GivenName)?.Value ?? ""; + var familyName = claims.FirstOrDefault(c => c.Type == ClaimTypes.Surname)?.Value ?? ""; var claimsIdentity = new ClaimsIdentity(new List { @@ -40,13 +40,13 @@ public class FacebookController(IUserService userService) : Controller new(ClaimTypes.Surname, familyName) }, CookieAuthenticationDefaults.AuthenticationScheme); - if (await userService.FindUserByEmailAsync(email) != null) + if (await identityService.FindUserByEmailAsync(email) != null) { await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(claimsIdentity)); return Redirect("/"); } - await userService.CreateUserAsync(email, givenName, givenName, familyName, RandomGenerator.RandomString(24)); + await identityService.CreateUserAsync(email, givenName, givenName, familyName, RandomGenerator.RandomString(24)); await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(claimsIdentity)); return Redirect("/"); } diff --git a/src/Web/Controllers/GoogleController.cs b/src/Web/Controllers/GoogleController.cs index 0780277..1f5228e 100644 --- a/src/Web/Controllers/GoogleController.cs +++ b/src/Web/Controllers/GoogleController.cs @@ -1,53 +1,70 @@ using System.Security.Claims; -using Hutopy.Domain.Interfaces; -using Hutopy.Infrastructure.Services; +using Hutopy.Application.Common.Interfaces; +using Hutopy.Infrastructure.Utils; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Authentication.Google; using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json.Linq; namespace Hutopy.Web.Controllers; -public class GoogleController(IUserService userService) : Controller +public class GoogleController(IIdentityService identityService, IHttpClientFactory httpClientFactory) : Controller { - [HttpGet("/api/google/sign-in")] - public async Task SignIn() + [HttpPost("/api/google/sign-in")] + public async Task SignIn([FromBody] GoogleSignInRequest request) { - await HttpContext.ChallengeAsync(GoogleDefaults.AuthenticationScheme, new AuthenticationProperties + 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) { - RedirectUri = Url.Action("Authorize") - }); - } - - public async Task Authorize() - { - var authenticateResult = await HttpContext.AuthenticateAsync(GoogleDefaults.AuthenticationScheme); + return BadRequest("Invalid Google token."); + } + + var payload = JObject.Parse(await response.Content.ReadAsStringAsync()); + + var email = payload["email"]?.ToString() ?? ""; + var name = payload["name"]?.ToString() ?? ""; + var givenName = payload["given_name"]?.ToString() ?? ""; + var familyName = payload["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 (!authenticateResult.Succeeded) return BadRequest(); - - var claims = authenticateResult.Principal.Claims.ToList(); - - var name = claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value; - var email = claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value; - var givenName = claims.FirstOrDefault(c => c.Type == ClaimTypes.GivenName)?.Value; - var familyName = claims.FirstOrDefault(c => c.Type == ClaimTypes.Surname)?.Value; - - var claimsIdentity = new ClaimsIdentity(new List + if (user is null) + { + return BadRequest("Unable to find or create the user."); + } + + // Sign in the user + var claims = new List { new(ClaimTypes.Name, name), new(ClaimTypes.Email, email), new(ClaimTypes.GivenName, givenName), new(ClaimTypes.Surname, familyName) - }, CookieAuthenticationDefaults.AuthenticationScheme); - - if (await userService.FindUserByEmailAsync(email) != null) - { - await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(claimsIdentity)); - return Redirect("/"); - } - - await userService.CreateUserAsync(email, givenName, givenName, familyName, RandomGenerator.RandomString(24)); + }; + + var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(claimsIdentity)); - return Redirect("/"); + + var jwtToken = JwtTokenHelper.GenerateJwtToken("https://hutopy.com", "Hutopy", "V3J3bWFuUml3ZVpQbmxlWmZhWEo3ZkJSZ01YbHBwS24=", user.Id!); + + return Ok(new { accessToken = jwtToken, email }); + } + + public class GoogleSignInRequest + { + public required string AccessToken { get; set; } } } diff --git a/src/Web/DependencyInjection.cs b/src/Web/DependencyInjection.cs index f8ede38..a921805 100644 --- a/src/Web/DependencyInjection.cs +++ b/src/Web/DependencyInjection.cs @@ -1,8 +1,14 @@ -using Azure.Identity; +using System.Text; +using Azure.Identity; using Hutopy.Application.Common.Interfaces; using Hutopy.Infrastructure.Data; using Hutopy.Web.Services; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.Facebook; +using Microsoft.AspNetCore.Authentication.Google; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; using NSwag; using NSwag.Generation.Processors.Security; @@ -24,6 +30,8 @@ public static class DependencyInjection services.AddExceptionHandler(); services.AddRazorPages(); + + services.AddHttpClient(); // Customise default API behaviour services.Configure(options => @@ -62,4 +70,45 @@ public static class DependencyInjection return services; } + + public static IServiceCollection AddAuthorizationAndAuthentication(this IServiceCollection services, ConfigurationManager configuration) + { + services.AddAuthentication(options => + { + options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme; + }) + .AddCookie("Identity.Application", options => + { + options.LoginPath = "/api/Users/login"; + }) + .AddCookie() + .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, jwtBearerOptions => + { + jwtBearerOptions.Authority = "https://hutopy.com"; + jwtBearerOptions.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = configuration["Jwt:Issuer"], + ValidateAudience = true, + ValidAudience = configuration["Jwt:Audience"], + ValidateLifetime = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["Jwt:Key"] ?? "")) + }; + }) + .AddGoogle(GoogleDefaults.AuthenticationScheme, options => + { + options.ClientId = configuration["Google:ClientId"] ?? ""; + options.ClientSecret = configuration["Google:ClientSecret"] ?? ""; + }) + .AddFacebook(FacebookDefaults.AuthenticationScheme, options => + { + options.ClientId = configuration["Facebook:ClientId"] ?? + throw new ArgumentNullException("The Facebook ClientId is missing."); + options.ClientSecret = configuration["Facebook:ClientSecret"] ?? + throw new ArgumentNullException("The Facebook ClientSecret is missing."); + }); + + return services; + } } diff --git a/src/Web/Endpoints/Users.cs b/src/Web/Endpoints/Users.cs index 32b8acc..41b558d 100644 --- a/src/Web/Endpoints/Users.cs +++ b/src/Web/Endpoints/Users.cs @@ -10,8 +10,8 @@ public class Users : EndpointGroupBase { app.MapGroup(this) .MapPost(CreateUser) - .MapGet(GetMinimalUser) - .MapIdentityApi(); + .MapPost(Login, "/login") + .MapGet(GetMinimalUser); } private static async Task CreateUser(ISender sender, CreateUserCommand command) @@ -23,4 +23,9 @@ public class Users : EndpointGroupBase { return await sender.Send(query); } + + private static async Task Login(ISender sender, LoginCommand command) + { + return await sender.Send(command); + } } diff --git a/src/Web/Program.cs b/src/Web/Program.cs index 14bda9f..1246d0d 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -3,12 +3,7 @@ using Hutopy.Infrastructure; using Hutopy.Infrastructure.Data; using Hutopy.Web; using Azure.Identity; -using Hutopy.Infrastructure.Identity; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Authentication.Facebook; -using Microsoft.AspNetCore.Authentication.Google; using Microsoft.AspNetCore.HttpOverrides; -using Microsoft.AspNetCore.Identity; var builder = WebApplication.CreateBuilder(args); @@ -50,49 +45,8 @@ builder.Services.AddKeyVaultIfConfigured(builder.Configuration); builder.Services.AddApplicationServices(); builder.Services.AddInfrastructureServices(builder.Configuration); builder.Services.AddWebServices(); - -// OAuth -builder.Services.AddAuthentication(options => - { - options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme; - }) - .AddCookie() - .AddGoogle( - GoogleDefaults.AuthenticationScheme, - options => - { - options.ClientId = builder.Configuration["Google:ClientId"] ?? - throw new ArgumentNullException("The Google ClientId is missing."); - options.ClientSecret = builder.Configuration["Google:ClientSecret"] ?? - throw new ArgumentNullException("The Google ClientSecret is missing."); - //options.AccessDeniedPath = "/AccessDeniedPathInfo"; - }) - .AddFacebook( - FacebookDefaults.AuthenticationScheme, - options => - { - options.ClientId = builder.Configuration["Facebook:ClientId"] ?? - throw new ArgumentNullException("The Facebook ClientId is missing."); - options.ClientSecret = builder.Configuration["Facebook:ClientSecret"] ?? - throw new ArgumentNullException("The Facebook ClientSecret is missing."); - //options.AccessDeniedPath = "/AccessDeniedPathInfo"; - }); - -// Password hashing -builder.Services.AddIdentity(options => - { - options.Password.RequireDigit = false; - options.Password.RequireLowercase = false; - options.Password.RequireUppercase = false; - options.Password.RequireNonAlphanumeric = false; - options.Password.RequiredLength = 16; - }) - .AddEntityFrameworkStores() - .AddDefaultTokenProviders(); - +builder.Services.AddAuthorizationAndAuthentication(builder.Configuration); builder.Services.AddControllers(); -builder.Services.AddScoped(); var app = builder.Build(); @@ -100,13 +54,15 @@ app.UseForwardedHeaders( new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedProto } ); -app.UseAuthentication(); -app.UseAuthorization(); - app.UseCors("AllowAll"); app.UseCors("AllowHutopyUi"); app.UseCors("AllowHutopyUiPreview"); +app.UseAuthentication(); +app.UseAuthorization(); + + + // Initialize and seed the db. await app.InitialiseDatabaseAsync(); diff --git a/src/Web/Web.csproj b/src/Web/Web.csproj index d5e0c62..9193242 100644 --- a/src/Web/Web.csproj +++ b/src/Web/Web.csproj @@ -16,6 +16,7 @@ + diff --git a/src/Web/appsettings.Development.dist b/src/Web/appsettings.Development.dist index 58d6716..d25bfda 100644 --- a/src/Web/appsettings.Development.dist +++ b/src/Web/appsettings.Development.dist @@ -9,21 +9,15 @@ }, "Google": { "ClientId": "", - "ClientSecret": "", - "ProjectId": "", - "AuthUri": "", - "TokenUri": "", - "AuthProviderX509CertUrl": "", - "RedirectUris": [ - "https://hutopy.ca", - "https://hutopy.com", - "http://localhost" - ], - "JavascriptOrigins": [ - "https://hutopy.ca", - "https://hutopy.com", - "http://localhost" - ] - } -} - + "ClientSecret": "" + }, + "Facebook": { + "ClientId": "", + "ClientSecret": "" + }, + "Jwt": { + "Issuer": "", + "Audience": "", + "Key": "" + } +} \ No newline at end of file diff --git a/src/Web/wwwroot/api/specification.json b/src/Web/wwwroot/api/specification.json index 2a98d65..5b73a58 100644 --- a/src/Web/wwwroot/api/specification.json +++ b/src/Web/wwwroot/api/specification.json @@ -273,75 +273,22 @@ } } }, - "/api/Users/register": { - "post": { - "tags": [ - "Users" - ], - "operationId": "PostApiUsersRegister", - "requestBody": { - "x-name": "registration", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RegisterRequest" - } - } - }, - "x-position": 1 - }, - "responses": { - "200": { - "description": "" - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HttpValidationProblemDetails" - } - } - } - } - } - } - }, "/api/Users/login": { "post": { "tags": [ "Users" ], - "operationId": "PostApiUsersLogin", - "parameters": [ - { - "name": "useCookies", - "in": "query", - "schema": { - "type": "boolean", - "nullable": true - }, - "x-position": 2 - }, - { - "name": "useSessionCookies", - "in": "query", - "schema": { - "type": "boolean", - "nullable": true - }, - "x-position": 3 - } - ], + "operationId": "Login", "requestBody": { - "x-name": "login", + "x-name": "command", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LoginRequest" + "$ref": "#/components/schemas/LoginCommand" } } }, + "required": true, "x-position": 1 }, "responses": { @@ -350,7 +297,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccessTokenResponse" + "type": "string" } } } @@ -358,305 +305,6 @@ } } }, - "/api/Users/refresh": { - "post": { - "tags": [ - "Users" - ], - "operationId": "PostApiUsersRefresh", - "requestBody": { - "x-name": "refreshRequest", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RefreshRequest" - } - } - }, - "x-position": 1 - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AccessTokenResponse" - } - } - } - } - } - } - }, - "/api/Users/confirmEmail": { - "get": { - "tags": [ - "Users" - ], - "operationId": "GetApiUsersConfirmEmail", - "parameters": [ - { - "name": "userId", - "in": "query", - "schema": { - "type": "string", - "nullable": true - }, - "x-position": 1 - }, - { - "name": "code", - "in": "query", - "schema": { - "type": "string", - "nullable": true - }, - "x-position": 2 - }, - { - "name": "changedEmail", - "in": "query", - "schema": { - "type": "string", - "nullable": true - }, - "x-position": 3 - } - ], - "responses": { - "200": { - "description": "" - } - } - } - }, - "/api/Users/resendConfirmationEmail": { - "post": { - "tags": [ - "Users" - ], - "operationId": "PostApiUsersResendConfirmationEmail", - "requestBody": { - "x-name": "resendRequest", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ResendConfirmationEmailRequest" - } - } - }, - "x-position": 1 - }, - "responses": { - "200": { - "description": "" - } - } - } - }, - "/api/Users/forgotPassword": { - "post": { - "tags": [ - "Users" - ], - "operationId": "PostApiUsersForgotPassword", - "requestBody": { - "x-name": "resetRequest", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ForgotPasswordRequest" - } - } - }, - "x-position": 1 - }, - "responses": { - "200": { - "description": "" - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HttpValidationProblemDetails" - } - } - } - } - } - } - }, - "/api/Users/resetPassword": { - "post": { - "tags": [ - "Users" - ], - "operationId": "PostApiUsersResetPassword", - "requestBody": { - "x-name": "resetRequest", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ResetPasswordRequest" - } - } - }, - "x-position": 1 - }, - "responses": { - "200": { - "description": "" - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HttpValidationProblemDetails" - } - } - } - } - } - } - }, - "/api/Users/manage/2fa": { - "post": { - "tags": [ - "Users" - ], - "operationId": "PostApiUsersManage2fa", - "requestBody": { - "x-name": "tfaRequest", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TwoFactorRequest" - } - } - }, - "x-position": 1 - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TwoFactorResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HttpValidationProblemDetails" - } - } - } - }, - "404": { - "description": "" - } - }, - "security": [ - { - "JWT": [] - } - ] - } - }, - "/api/Users/manage/info": { - "get": { - "tags": [ - "Users" - ], - "operationId": "GetApiUsersManageInfo", - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InfoResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HttpValidationProblemDetails" - } - } - } - }, - "404": { - "description": "" - } - }, - "security": [ - { - "JWT": [] - } - ] - }, - "post": { - "tags": [ - "Users" - ], - "operationId": "PostApiUsersManageInfo", - "requestBody": { - "x-name": "infoRequest", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InfoRequest" - } - } - }, - "x-position": 1 - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InfoResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HttpValidationProblemDetails" - } - } - } - }, - "404": { - "description": "" - } - }, - "security": [ - { - "JWT": [] - } - ] - } - }, "/api/WeatherForecasts": { "get": { "tags": [ @@ -699,14 +347,34 @@ } }, "/api/google/sign-in": { - "get": { + "post": { "tags": [ "Google" ], "operationId": "Google_SignIn", + "requestBody": { + "x-name": "request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GoogleSignInRequest" + } + } + }, + "required": true, + "x-position": 1 + }, "responses": { "200": { - "description": "" + "description": "", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } } } } @@ -986,6 +654,18 @@ } } }, + "LoginCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "emailAddress": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, "MinimalUserDto": { "type": "object", "additionalProperties": false, @@ -1001,231 +681,6 @@ } } }, - "HttpValidationProblemDetails": { - "allOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - }, - { - "type": "object", - "additionalProperties": { - "nullable": true - }, - "properties": { - "errors": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - ] - }, - "ProblemDetails": { - "type": "object", - "additionalProperties": { - "nullable": true - }, - "properties": { - "type": { - "type": "string", - "nullable": true - }, - "title": { - "type": "string", - "nullable": true - }, - "status": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "detail": { - "type": "string", - "nullable": true - }, - "instance": { - "type": "string", - "nullable": true - } - } - }, - "RegisterRequest": { - "type": "object", - "additionalProperties": false, - "properties": { - "email": { - "type": "string" - }, - "password": { - "type": "string" - } - } - }, - "AccessTokenResponse": { - "type": "object", - "additionalProperties": false, - "properties": { - "tokenType": { - "type": "string" - }, - "accessToken": { - "type": "string" - }, - "expiresIn": { - "type": "integer", - "format": "int64" - }, - "refreshToken": { - "type": "string" - } - } - }, - "LoginRequest": { - "type": "object", - "additionalProperties": false, - "properties": { - "email": { - "type": "string" - }, - "password": { - "type": "string" - }, - "twoFactorCode": { - "type": "string", - "nullable": true - }, - "twoFactorRecoveryCode": { - "type": "string", - "nullable": true - } - } - }, - "RefreshRequest": { - "type": "object", - "additionalProperties": false, - "properties": { - "refreshToken": { - "type": "string" - } - } - }, - "ResendConfirmationEmailRequest": { - "type": "object", - "additionalProperties": false, - "properties": { - "email": { - "type": "string" - } - } - }, - "ForgotPasswordRequest": { - "type": "object", - "additionalProperties": false, - "properties": { - "email": { - "type": "string" - } - } - }, - "ResetPasswordRequest": { - "type": "object", - "additionalProperties": false, - "properties": { - "email": { - "type": "string" - }, - "resetCode": { - "type": "string" - }, - "newPassword": { - "type": "string" - } - } - }, - "TwoFactorResponse": { - "type": "object", - "additionalProperties": false, - "properties": { - "sharedKey": { - "type": "string" - }, - "recoveryCodesLeft": { - "type": "integer", - "format": "int32" - }, - "recoveryCodes": { - "type": "array", - "nullable": true, - "items": { - "type": "string" - } - }, - "isTwoFactorEnabled": { - "type": "boolean" - }, - "isMachineRemembered": { - "type": "boolean" - } - } - }, - "TwoFactorRequest": { - "type": "object", - "additionalProperties": false, - "properties": { - "enable": { - "type": "boolean", - "nullable": true - }, - "twoFactorCode": { - "type": "string", - "nullable": true - }, - "resetSharedKey": { - "type": "boolean" - }, - "resetRecoveryCodes": { - "type": "boolean" - }, - "forgetMachine": { - "type": "boolean" - } - } - }, - "InfoResponse": { - "type": "object", - "additionalProperties": false, - "properties": { - "email": { - "type": "string" - }, - "isEmailConfirmed": { - "type": "boolean" - } - } - }, - "InfoRequest": { - "type": "object", - "additionalProperties": false, - "properties": { - "newEmail": { - "type": "string", - "nullable": true - }, - "newPassword": { - "type": "string", - "nullable": true - }, - "oldPassword": { - "type": "string", - "nullable": true - } - } - }, "WeatherForecast": { "type": "object", "additionalProperties": false, @@ -1247,6 +702,15 @@ "nullable": true } } + }, + "GoogleSignInRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "accessToken": { + "type": "string" + } + } } }, "securitySchemes": { From 66bfeea3eec2677e6ab39c9abfb0bd4f79b7ec8f Mon Sep 17 00:00:00 2001 From: Dominic Villemure Date: Sun, 9 Jun 2024 23:53:43 -0400 Subject: [PATCH 14/15] #oauth cleanup --- src/Infrastructure/Identity/ApplicationUser.cs | 1 - src/Web/Controllers/GoogleController.cs | 13 ++++++++++--- src/Web/DependencyInjection.cs | 9 ++++++--- src/Web/Program.cs | 2 -- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/Infrastructure/Identity/ApplicationUser.cs b/src/Infrastructure/Identity/ApplicationUser.cs index 39cb433..0462aff 100644 --- a/src/Infrastructure/Identity/ApplicationUser.cs +++ b/src/Infrastructure/Identity/ApplicationUser.cs @@ -6,5 +6,4 @@ public class ApplicationUser : IdentityUser { public string FirstName { get; set; } = string.Empty; public string LastName { get; set; } = string.Empty; - //public string Gender { get; set; } = string.Empty; } diff --git a/src/Web/Controllers/GoogleController.cs b/src/Web/Controllers/GoogleController.cs index 1f5228e..49c57e2 100644 --- a/src/Web/Controllers/GoogleController.cs +++ b/src/Web/Controllers/GoogleController.cs @@ -8,7 +8,7 @@ using Newtonsoft.Json.Linq; namespace Hutopy.Web.Controllers; -public class GoogleController(IIdentityService identityService, IHttpClientFactory httpClientFactory) : Controller +public class GoogleController(IIdentityService identityService, IHttpClientFactory httpClientFactory, IConfiguration configuration) : Controller { [HttpPost("/api/google/sign-in")] public async Task SignIn([FromBody] GoogleSignInRequest request) @@ -41,7 +41,7 @@ public class GoogleController(IIdentityService identityService, IHttpClientFacto user = await identityService.FindUserByEmailAsync(email); } - if (user is null) + if (user?.Id is null) { return BadRequest("Unable to find or create the user."); } @@ -58,7 +58,14 @@ public class GoogleController(IIdentityService identityService, IHttpClientFacto var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(claimsIdentity)); - var jwtToken = JwtTokenHelper.GenerateJwtToken("https://hutopy.com", "Hutopy", "V3J3bWFuUml3ZVpQbmxlWmZhWEo3ZkJSZ01YbHBwS24=", user.Id!); + var issuer = configuration["Jwt:Issuer"] ?? + throw new ArgumentNullException("The Jwt issuer is missing."); + var audience = configuration["Jwt:Audience"] ?? + throw new ArgumentNullException("The Jwt audience is missing."); + var key = configuration["Jwt:Key"] ?? + throw new ArgumentNullException("The Jwt key is missing."); + + var jwtToken = JwtTokenHelper.GenerateJwtToken(issuer, audience, key, user.Id); return Ok(new { accessToken = jwtToken, email }); } diff --git a/src/Web/DependencyInjection.cs b/src/Web/DependencyInjection.cs index a921805..402b870 100644 --- a/src/Web/DependencyInjection.cs +++ b/src/Web/DependencyInjection.cs @@ -93,13 +93,16 @@ public static class DependencyInjection ValidateAudience = true, ValidAudience = configuration["Jwt:Audience"], ValidateLifetime = true, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["Jwt:Key"] ?? "")) + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["Jwt:Key"] ?? + throw new ArgumentNullException("The Jwt Key is missing."))) }; }) .AddGoogle(GoogleDefaults.AuthenticationScheme, options => { - options.ClientId = configuration["Google:ClientId"] ?? ""; - options.ClientSecret = configuration["Google:ClientSecret"] ?? ""; + options.ClientId = configuration["Google:ClientId"] ?? + throw new ArgumentNullException("The Google ClientId is missing.");; + options.ClientSecret = configuration["Google:ClientSecret"] ?? + throw new ArgumentNullException("The Google ClientSecret is missing.");; }) .AddFacebook(FacebookDefaults.AuthenticationScheme, options => { diff --git a/src/Web/Program.cs b/src/Web/Program.cs index 1246d0d..781bdc2 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -61,8 +61,6 @@ app.UseCors("AllowHutopyUiPreview"); app.UseAuthentication(); app.UseAuthorization(); - - // Initialize and seed the db. await app.InitialiseDatabaseAsync(); From 568a5c99ca314b82b67bbb7e6ca7c375271101de Mon Sep 17 00:00:00 2001 From: Dominic Villemure Date: Sun, 16 Jun 2024 11:00:40 -0400 Subject: [PATCH 15/15] oauth fixes to be able to use vault for env vars --- .../Identity/IdentityService.cs | 6 +++--- src/Web/Controllers/GoogleController.cs | 6 +++--- src/Web/DependencyInjection.cs | 14 ++++++------- src/Web/Endpoints/Users.cs | 1 - src/Web/appsettings.Development.dist | 20 +++++++------------ 5 files changed, 20 insertions(+), 27 deletions(-) diff --git a/src/Infrastructure/Identity/IdentityService.cs b/src/Infrastructure/Identity/IdentityService.cs index 0a25594..02b5ce0 100644 --- a/src/Infrastructure/Identity/IdentityService.cs +++ b/src/Infrastructure/Identity/IdentityService.cs @@ -221,9 +221,9 @@ public class IdentityService( var user = await GetUserByUserNameAsync(userName); var token = JwtTokenHelper.GenerateJwtToken( - issuer: configuration["Jwt:Issuer"] ?? "", - audience: configuration["Jwt:Audience"] ?? "", - key: configuration["Jwt:Key"] ?? "", + issuer: configuration["Jwt-Issuer"] ?? "", + audience: configuration["Jwt-Audience"] ?? "", + key: configuration["Jwt-Key"] ?? "", userId: user?.Id ?? ""); return token; diff --git a/src/Web/Controllers/GoogleController.cs b/src/Web/Controllers/GoogleController.cs index 49c57e2..6df233a 100644 --- a/src/Web/Controllers/GoogleController.cs +++ b/src/Web/Controllers/GoogleController.cs @@ -58,11 +58,11 @@ public class GoogleController(IIdentityService identityService, IHttpClientFacto var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(claimsIdentity)); - var issuer = configuration["Jwt:Issuer"] ?? + var issuer = configuration["Jwt-Issuer"] ?? throw new ArgumentNullException("The Jwt issuer is missing."); - var audience = configuration["Jwt:Audience"] ?? + var audience = configuration["Jwt-Audience"] ?? throw new ArgumentNullException("The Jwt audience is missing."); - var key = configuration["Jwt:Key"] ?? + var key = configuration["Jwt-Key"] ?? throw new ArgumentNullException("The Jwt key is missing."); var jwtToken = JwtTokenHelper.GenerateJwtToken(issuer, audience, key, user.Id); diff --git a/src/Web/DependencyInjection.cs b/src/Web/DependencyInjection.cs index 402b870..3a7eb61 100644 --- a/src/Web/DependencyInjection.cs +++ b/src/Web/DependencyInjection.cs @@ -89,26 +89,26 @@ public static class DependencyInjection jwtBearerOptions.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, - ValidIssuer = configuration["Jwt:Issuer"], + ValidIssuer = configuration["Jwt-Issuer"], ValidateAudience = true, - ValidAudience = configuration["Jwt:Audience"], + ValidAudience = configuration["Jwt-Audience"], ValidateLifetime = true, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["Jwt:Key"] ?? + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["Jwt-Key"] ?? throw new ArgumentNullException("The Jwt Key is missing."))) }; }) .AddGoogle(GoogleDefaults.AuthenticationScheme, options => { - options.ClientId = configuration["Google:ClientId"] ?? + options.ClientId = configuration["Google-ClientId"] ?? throw new ArgumentNullException("The Google ClientId is missing.");; - options.ClientSecret = configuration["Google:ClientSecret"] ?? + options.ClientSecret = configuration["Google-ClientSecret"] ?? throw new ArgumentNullException("The Google ClientSecret is missing.");; }) .AddFacebook(FacebookDefaults.AuthenticationScheme, options => { - options.ClientId = configuration["Facebook:ClientId"] ?? + options.ClientId = configuration["Facebook-ClientId"] ?? throw new ArgumentNullException("The Facebook ClientId is missing."); - options.ClientSecret = configuration["Facebook:ClientSecret"] ?? + options.ClientSecret = configuration["Facebook-ClientSecret"] ?? throw new ArgumentNullException("The Facebook ClientSecret is missing."); }); diff --git a/src/Web/Endpoints/Users.cs b/src/Web/Endpoints/Users.cs index 2c4f403..39f784a 100644 --- a/src/Web/Endpoints/Users.cs +++ b/src/Web/Endpoints/Users.cs @@ -1,6 +1,5 @@ using Hutopy.Application.Users.Commands; using Hutopy.Application.Users.Queries.GetMinimalUser; -using Hutopy.Infrastructure.Identity; namespace Hutopy.Web.Endpoints; diff --git a/src/Web/appsettings.Development.dist b/src/Web/appsettings.Development.dist index d25bfda..25369fa 100644 --- a/src/Web/appsettings.Development.dist +++ b/src/Web/appsettings.Development.dist @@ -7,17 +7,11 @@ "Microsoft.Hosting.Lifetime": "Information" } }, - "Google": { - "ClientId": "", - "ClientSecret": "" - }, - "Facebook": { - "ClientId": "", - "ClientSecret": "" - }, - "Jwt": { - "Issuer": "", - "Audience": "", - "Key": "" - } + "Google-ClientId": "", + "Google-ClientSecret": "", + "Facebook-ClientId": "", + "Facebook-ClientSecret": "", + "Jwt-Audience": "", + "Jwt-Issuer": "", + "Jwt-Key": "", } \ No newline at end of file