From 63d195ddf7f89c9ff4d69b0f3c2cffcb921cfdbb Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sat, 22 Aug 2026 01:31:49 -0400 Subject: [PATCH] Migrate handlers to UserManager<> --- .../Commands/ConfirmUserHandlerTests.cs | 34 ++-- .../Commands/RegisterUserHandlerTests.cs | 161 ++++++------------ .../ResendConfirmationEmailHandlerTests.cs | 31 ++-- .../Queries/LoginHandlerTests.cs | 84 +++------ .../TestSupport/UserManagerMockFactory.cs | 24 +++ .../ConfirmUser/ConfirmUserHandler.cs | 21 ++- .../Commands/Login/LoginHandler.cs | 36 ++-- .../RegisterUser/RegisterUserHandler.cs | 98 ++++------- .../ResendConfirmationEmailHandler.cs | 15 +- 9 files changed, 205 insertions(+), 299 deletions(-) create mode 100644 web/backend/Features/Features.Auth.Tests/TestSupport/UserManagerMockFactory.cs diff --git a/web/backend/Features/Features.Auth.Tests/Commands/ConfirmUserHandlerTests.cs b/web/backend/Features/Features.Auth.Tests/Commands/ConfirmUserHandlerTests.cs index 9ddbe72a..3e2f63e5 100644 --- a/web/backend/Features/Features.Auth.Tests/Commands/ConfirmUserHandlerTests.cs +++ b/web/backend/Features/Features.Auth.Tests/Commands/ConfirmUserHandlerTests.cs @@ -1,27 +1,34 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; -using Domain.Entities; using Domain.Exceptions; using Features.Auth.Commands.ConfirmUser; using Features.Auth.Dtos; -using Features.Auth.Repository; +using Features.Auth.Identity; using Features.Auth.Services; +using Features.Auth.Tests.TestSupport; using FluentAssertions; +using Microsoft.AspNetCore.Identity; using Moq; namespace Features.Auth.Tests.Commands; public class ConfirmUserHandlerTests { - private readonly Mock _authRepositoryMock; + private readonly Mock> _emailStoreMock; private readonly ConfirmUserHandler _handler; private readonly Mock _tokenServiceMock; + private readonly Mock> _userManagerMock; public ConfirmUserHandlerTests() { - _authRepositoryMock = new Mock(); + _userManagerMock = UserManagerMockFactory.Create(); + _emailStoreMock = new Mock>(); _tokenServiceMock = new Mock(); - _handler = new ConfirmUserHandler(_authRepositoryMock.Object, _tokenServiceMock.Object); + _handler = new ConfirmUserHandler( + _userManagerMock.Object, + _emailStoreMock.Object, + _tokenServiceMock.Object + ); } private static ValidatedToken MakeValidatedToken(Guid userId, string username) @@ -44,12 +51,13 @@ public class ConfirmUserHandlerTests const string confirmationToken = "valid-confirmation-token"; ValidatedToken validatedToken = MakeValidatedToken(userId, username); - UserAccount userAccount = new() { UserAccountId = userId, Username = username }; + ApplicationUser user = new() { Id = userId, UserName = username }; _tokenServiceMock .Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)) .ReturnsAsync(validatedToken); - _authRepositoryMock.Setup(x => x.ConfirmUserAccountAsync(userId)).ReturnsAsync(userAccount); + _userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user); + _userManagerMock.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); ConfirmationPayload result = await _handler.Handle( new ConfirmUserCommand(confirmationToken), @@ -59,6 +67,12 @@ public class ConfirmUserHandlerTests result.Should().NotBeNull(); result.UserAccountId.Should().Be(userId); result.ConfirmedDate.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + + _emailStoreMock.Verify( + x => x.SetEmailConfirmedAsync(user, true, It.IsAny()), + Times.Once + ); + _userManagerMock.Verify(x => x.UpdateAsync(user), Times.Once); } [Fact] @@ -85,9 +99,9 @@ public class ConfirmUserHandlerTests _tokenServiceMock .Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)) .ReturnsAsync(MakeValidatedToken(userId, username)); - _authRepositoryMock - .Setup(x => x.ConfirmUserAccountAsync(userId)) - .ReturnsAsync((UserAccount?)null); + _userManagerMock + .Setup(x => x.FindByIdAsync(userId.ToString())) + .ReturnsAsync((ApplicationUser?)null); Func> act = async () => await _handler.Handle( diff --git a/web/backend/Features/Features.Auth.Tests/Commands/RegisterUserHandlerTests.cs b/web/backend/Features/Features.Auth.Tests/Commands/RegisterUserHandlerTests.cs index abd331be..d51279c2 100644 --- a/web/backend/Features/Features.Auth.Tests/Commands/RegisterUserHandlerTests.cs +++ b/web/backend/Features/Features.Auth.Tests/Commands/RegisterUserHandlerTests.cs @@ -1,12 +1,12 @@ -using Domain.Entities; using Domain.Exceptions; using Features.Auth.Commands.RegisterUser; using Features.Auth.Dtos; -using Features.Auth.Repository; +using Features.Auth.Identity; using Features.Auth.Services; +using Features.Auth.Tests.TestSupport; using FluentAssertions; -using Infrastructure.PasswordHashing; using MediatR; +using Microsoft.AspNetCore.Identity; using Moq; using Shared.Application.Emails; @@ -14,22 +14,19 @@ namespace Features.Auth.Tests.Commands; public class RegisterUserHandlerTests { - private readonly Mock _authRepoMock; private readonly RegisterUserHandler _handler; private readonly Mock _mediatorMock; - private readonly Mock _passwordInfraMock; private readonly Mock _tokenServiceMock; + private readonly Mock> _userManagerMock; public RegisterUserHandlerTests() { - _authRepoMock = new Mock(); - _passwordInfraMock = new Mock(); + _userManagerMock = UserManagerMockFactory.Create(); _tokenServiceMock = new Mock(); _mediatorMock = new Mock(); _handler = new RegisterUserHandler( - _authRepoMock.Object, - _passwordInfraMock.Object, + _userManagerMock.Object, _tokenServiceMock.Object, _mediatorMock.Object ); @@ -54,42 +51,23 @@ public class RegisterUserHandlerTests public async Task Handle_WithValidData_CreatesUserAndReturnsPayload() { RegisterUserCommand command = ValidCommand(); - const string hashedPassword = "hashed_password_value"; Guid expectedUserId = Guid.NewGuid(); - _authRepoMock - .Setup(x => x.GetUserByUsernameAsync(command.Username)) - .ReturnsAsync((UserAccount?)null); - _authRepoMock - .Setup(x => x.GetUserByEmailAsync(command.Email)) - .ReturnsAsync((UserAccount?)null); - _passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword); - - _authRepoMock + _userManagerMock .Setup(x => - x.RegisterUserAsync( - It.Is(ua => - ua.FirstName == command.FirstName - && ua.LastName == command.LastName - && ua.Username == command.Username - && ua.Email == command.Email - && ua.DateOfBirth == command.DateOfBirth - && ua.UserCredential!.Hash == hashedPassword - ) + x.CreateAsync( + It.Is(u => + u.FirstName == command.FirstName + && u.LastName == command.LastName + && u.UserName == command.Username + && u.Email == command.Email + && u.DateOfBirth == command.DateOfBirth + ), + command.Password ) ) - .ReturnsAsync( - new UserAccount - { - UserAccountId = expectedUserId, - Username = command.Username, - FirstName = command.FirstName, - LastName = command.LastName, - Email = command.Email, - DateOfBirth = command.DateOfBirth, - CreatedAt = DateTime.UtcNow, - } - ); + .Callback((u, _) => u.Id = expectedUserId) + .ReturnsAsync(IdentityResult.Success); _tokenServiceMock .Setup(x => x.GenerateAccessToken(It.IsAny(), It.IsAny())) @@ -120,18 +98,14 @@ public class RegisterUserHandlerTests public async Task Handle_WithExistingUsername_ThrowsConflictException() { RegisterUserCommand command = ValidCommand("existinguser"); - UserAccount existingUser = new() - { - UserAccountId = Guid.NewGuid(), - Username = "existinguser", - }; - _authRepoMock - .Setup(x => x.GetUserByUsernameAsync(command.Username)) - .ReturnsAsync(existingUser); - _authRepoMock - .Setup(x => x.GetUserByEmailAsync(command.Email)) - .ReturnsAsync((UserAccount?)null); + _userManagerMock + .Setup(x => x.CreateAsync(It.IsAny(), command.Password)) + .ReturnsAsync( + IdentityResult.Failed( + new IdentityError { Code = "DuplicateUserName", Description = "Username taken." } + ) + ); Func> act = async () => await _handler.Handle(command, CancellationToken.None); @@ -139,27 +113,20 @@ public class RegisterUserHandlerTests await act.Should() .ThrowAsync() .WithMessage("Username or email already exists"); - - _authRepoMock.Verify( - x => x.RegisterUserAsync(It.IsAny()), - Times.Never - ); } [Fact] public async Task Handle_WithExistingEmail_ThrowsConflictException() { RegisterUserCommand command = ValidCommand(email: "existing@example.com"); - UserAccount existingUser = new() - { - UserAccountId = Guid.NewGuid(), - Email = "existing@example.com", - }; - _authRepoMock - .Setup(x => x.GetUserByUsernameAsync(command.Username)) - .ReturnsAsync((UserAccount?)null); - _authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync(existingUser); + _userManagerMock + .Setup(x => x.CreateAsync(It.IsAny(), command.Password)) + .ReturnsAsync( + IdentityResult.Failed( + new IdentityError { Code = "DuplicateEmail", Description = "Email taken." } + ) + ); Func> act = async () => await _handler.Handle(command, CancellationToken.None); @@ -173,23 +140,11 @@ public class RegisterUserHandlerTests public async Task Handle_PasswordIsHashed_BeforeStoringInDatabase() { RegisterUserCommand command = ValidCommand(); - const string hashedPassword = "hashed_secure_password"; - _authRepoMock - .Setup(x => x.GetUserByUsernameAsync(It.IsAny())) - .ReturnsAsync((UserAccount?)null); - _authRepoMock - .Setup(x => x.GetUserByEmailAsync(It.IsAny())) - .ReturnsAsync((UserAccount?)null); - _passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword); - - _authRepoMock - .Setup(x => - x.RegisterUserAsync( - It.Is(ua => ua.UserCredential!.Hash == hashedPassword) - ) - ) - .ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() }); + _userManagerMock + .Setup(x => x.CreateAsync(It.IsAny(), command.Password)) + .Callback((u, _) => u.Id = Guid.NewGuid()) + .ReturnsAsync(IdentityResult.Success); _tokenServiceMock .Setup(x => x.GenerateAccessToken(It.IsAny(), It.IsAny())) @@ -200,18 +155,19 @@ public class RegisterUserHandlerTests await _handler.Handle(command, CancellationToken.None); - _passwordInfraMock.Verify(x => x.Hash(command.Password), Times.Once); - _authRepoMock.Verify( + // UserManager.CreateAsync receives the plaintext password and hashes it internally via + // IPasswordHasher -- the handler never sees or stores a hash directly. + _userManagerMock.Verify( x => - x.RegisterUserAsync( - It.Is(ua => - ua.FirstName == command.FirstName - && ua.LastName == command.LastName - && ua.Username == command.Username - && ua.DateOfBirth == command.DateOfBirth - && ua.Email == command.Email - && ua.UserCredential!.Hash == hashedPassword - ) + x.CreateAsync( + It.Is(u => + u.FirstName == command.FirstName + && u.LastName == command.LastName + && u.UserName == command.Username + && u.DateOfBirth == command.DateOfBirth + && u.Email == command.Email + ), + command.Password ), Times.Once ); @@ -222,23 +178,10 @@ public class RegisterUserHandlerTests { RegisterUserCommand command = ValidCommand(); - _authRepoMock - .Setup(x => x.GetUserByUsernameAsync(It.IsAny())) - .ReturnsAsync((UserAccount?)null); - _authRepoMock - .Setup(x => x.GetUserByEmailAsync(It.IsAny())) - .ReturnsAsync((UserAccount?)null); - _passwordInfraMock.Setup(x => x.Hash(It.IsAny())).Returns("hashed"); - _authRepoMock - .Setup(x => x.RegisterUserAsync(It.IsAny())) - .ReturnsAsync( - new UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = command.Username, - Email = command.Email, - } - ); + _userManagerMock + .Setup(x => x.CreateAsync(It.IsAny(), command.Password)) + .Callback((u, _) => u.Id = Guid.NewGuid()) + .ReturnsAsync(IdentityResult.Success); _tokenServiceMock .Setup(x => x.GenerateAccessToken(It.IsAny(), It.IsAny())) diff --git a/web/backend/Features/Features.Auth.Tests/Commands/ResendConfirmationEmailHandlerTests.cs b/web/backend/Features/Features.Auth.Tests/Commands/ResendConfirmationEmailHandlerTests.cs index 5905cafc..c275e09d 100644 --- a/web/backend/Features/Features.Auth.Tests/Commands/ResendConfirmationEmailHandlerTests.cs +++ b/web/backend/Features/Features.Auth.Tests/Commands/ResendConfirmationEmailHandlerTests.cs @@ -1,8 +1,9 @@ -using Domain.Entities; using Features.Auth.Commands.ResendConfirmationEmail; -using Features.Auth.Repository; +using Features.Auth.Identity; using Features.Auth.Services; +using Features.Auth.Tests.TestSupport; using MediatR; +using Microsoft.AspNetCore.Identity; using Moq; using Shared.Application.Emails; @@ -10,15 +11,15 @@ namespace Features.Auth.Tests.Commands; public class ResendConfirmationEmailHandlerTests { - private readonly Mock _authRepositoryMock = new(); private readonly ResendConfirmationEmailHandler _handler; private readonly Mock _mediatorMock = new(); private readonly Mock _tokenServiceMock = new(); + private readonly Mock> _userManagerMock = UserManagerMockFactory.Create(); public ResendConfirmationEmailHandlerTests() { _handler = new ResendConfirmationEmailHandler( - _authRepositoryMock.Object, + _userManagerMock.Object, _tokenServiceMock.Object, _mediatorMock.Object ); @@ -28,16 +29,18 @@ public class ResendConfirmationEmailHandlerTests public async Task Handle_SendsFreshConfirmationEmail_WhenUserExistsAndUnverified() { Guid userId = Guid.NewGuid(); - UserAccount user = new() + ApplicationUser user = new() { - UserAccountId = userId, + Id = userId, FirstName = "Aaron", Email = "aaron@example.com", }; - _authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user); - _authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(false); - _tokenServiceMock.Setup(x => x.GenerateConfirmationToken(user.UserAccountId, user.Username)).Returns("fresh-token"); + _userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user); + _userManagerMock.Setup(x => x.IsEmailConfirmedAsync(user)).ReturnsAsync(false); + _tokenServiceMock + .Setup(x => x.GenerateConfirmationToken(user.Id, user.UserName)) + .Returns("fresh-token"); await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None); @@ -59,7 +62,9 @@ public class ResendConfirmationEmailHandlerTests public async Task Handle_DoesNothing_WhenUserDoesNotExist() { Guid userId = Guid.NewGuid(); - _authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync((UserAccount?)null); + _userManagerMock + .Setup(x => x.FindByIdAsync(userId.ToString())) + .ReturnsAsync((ApplicationUser?)null); await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None); @@ -77,10 +82,10 @@ public class ResendConfirmationEmailHandlerTests public async Task Handle_DoesNothing_WhenUserAlreadyVerified() { Guid userId = Guid.NewGuid(); - UserAccount user = new() { UserAccountId = userId }; + ApplicationUser user = new() { Id = userId }; - _authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user); - _authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(true); + _userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user); + _userManagerMock.Setup(x => x.IsEmailConfirmedAsync(user)).ReturnsAsync(true); await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None); diff --git a/web/backend/Features/Features.Auth.Tests/Queries/LoginHandlerTests.cs b/web/backend/Features/Features.Auth.Tests/Queries/LoginHandlerTests.cs index 86104a7b..484b7414 100644 --- a/web/backend/Features/Features.Auth.Tests/Queries/LoginHandlerTests.cs +++ b/web/backend/Features/Features.Auth.Tests/Queries/LoginHandlerTests.cs @@ -1,32 +1,26 @@ -using Domain.Entities; using Domain.Exceptions; using Features.Auth.Commands.Login; using Features.Auth.Dtos; -using Features.Auth.Repository; +using Features.Auth.Identity; using Features.Auth.Services; +using Features.Auth.Tests.TestSupport; using FluentAssertions; -using Infrastructure.PasswordHashing; +using Microsoft.AspNetCore.Identity; using Moq; namespace Features.Auth.Tests.Queries; public class LoginHandlerTests { - private readonly Mock _authRepoMock; private readonly LoginHandler _handler; - private readonly Mock _passwordInfraMock; private readonly Mock _tokenServiceMock; + private readonly Mock> _userManagerMock; public LoginHandlerTests() { - _authRepoMock = new Mock(); - _passwordInfraMock = new Mock(); + _userManagerMock = UserManagerMockFactory.Create(); _tokenServiceMock = new Mock(); - _handler = new LoginHandler( - _authRepoMock.Object, - _passwordInfraMock.Object, - _tokenServiceMock.Object - ); + _handler = new LoginHandler(_userManagerMock.Object, _tokenServiceMock.Object); } [Fact] @@ -35,31 +29,20 @@ public class LoginHandlerTests const string username = "CogitoErgoSum"; Guid userAccountId = Guid.NewGuid(); - UserAccount userAccount = new() + ApplicationUser user = new() { - UserAccountId = userAccountId, - Username = username, + Id = userAccountId, + UserName = username, FirstName = "René", LastName = "Descartes", Email = "r.descartes@example.com", DateOfBirth = new DateTime(1596, 03, 31), }; - UserCredential userCredential = new() - { - UserCredentialId = Guid.NewGuid(), - UserAccountId = userAccountId, - Hash = "some-hash", - Expiry = DateTime.MaxValue, - }; - - _authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount); - _authRepoMock - .Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId)) - .ReturnsAsync(userCredential); - _passwordInfraMock - .Setup(x => x.Verify(It.IsAny(), It.IsAny())) - .Returns(true); + _userManagerMock.Setup(x => x.FindByNameAsync(username)).ReturnsAsync(user); + _userManagerMock + .Setup(x => x.CheckPasswordAsync(user, It.IsAny())) + .ReturnsAsync(true); _tokenServiceMock .Setup(x => x.GenerateAccessToken(It.IsAny(), It.IsAny())) .Returns("access-token"); @@ -83,16 +66,16 @@ public class LoginHandlerTests public async Task Handle_WithUnregisteredUsername_ThrowsUnauthorizedException() { const string username = "de_beauvoir"; - _authRepoMock - .Setup(x => x.GetUserByUsernameAsync(username)) - .ReturnsAsync((UserAccount?)null); + _userManagerMock + .Setup(x => x.FindByNameAsync(username)) + .ReturnsAsync((ApplicationUser?)null); Func> act = async () => await _handler.Handle(new LoginCommand(username, "password"), CancellationToken.None); await act.Should().ThrowAsync(); - _authRepoMock.Verify( - x => x.GetActiveCredentialByUserAccountIdAsync(It.IsAny()), + _userManagerMock.Verify( + x => x.CheckPasswordAsync(It.IsAny(), It.IsAny()), Times.Never ); } @@ -101,13 +84,10 @@ public class LoginHandlerTests public async Task Handle_WithNoActiveCredential_ThrowsUnauthorizedException() { const string username = "BRussell"; - Guid userAccountId = Guid.NewGuid(); - UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username }; + ApplicationUser user = new() { Id = Guid.NewGuid(), UserName = username }; - _authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount); - _authRepoMock - .Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId)) - .ReturnsAsync((UserCredential?)null); + _userManagerMock.Setup(x => x.FindByNameAsync(username)).ReturnsAsync(user); + _userManagerMock.Setup(x => x.CheckPasswordAsync(user, It.IsAny())).ReturnsAsync(false); Func> act = async () => await _handler.Handle(new LoginCommand(username, "password"), CancellationToken.None); @@ -115,32 +95,16 @@ public class LoginHandlerTests await act.Should() .ThrowAsync() .WithMessage("Invalid username or password."); - _passwordInfraMock.Verify( - x => x.Verify(It.IsAny(), It.IsAny()), - Times.Never - ); } [Fact] public async Task Handle_WithIncorrectPassword_ThrowsUnauthorizedException() { const string username = "RCarnap"; - Guid userAccountId = Guid.NewGuid(); - UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username }; - UserCredential userCredential = new() - { - UserCredentialId = Guid.NewGuid(), - UserAccountId = userAccountId, - Hash = "hashed-password", - }; + ApplicationUser user = new() { Id = Guid.NewGuid(), UserName = username }; - _authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount); - _authRepoMock - .Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId)) - .ReturnsAsync(userCredential); - _passwordInfraMock - .Setup(x => x.Verify(It.IsAny(), It.IsAny())) - .Returns(false); + _userManagerMock.Setup(x => x.FindByNameAsync(username)).ReturnsAsync(user); + _userManagerMock.Setup(x => x.CheckPasswordAsync(user, It.IsAny())).ReturnsAsync(false); Func> act = async () => await _handler.Handle( diff --git a/web/backend/Features/Features.Auth.Tests/TestSupport/UserManagerMockFactory.cs b/web/backend/Features/Features.Auth.Tests/TestSupport/UserManagerMockFactory.cs new file mode 100644 index 00000000..2e956558 --- /dev/null +++ b/web/backend/Features/Features.Auth.Tests/TestSupport/UserManagerMockFactory.cs @@ -0,0 +1,24 @@ +using Features.Auth.Identity; +using Microsoft.AspNetCore.Identity; +using Moq; + +namespace Features.Auth.Tests.TestSupport; + +public static class UserManagerMockFactory +{ + public static Mock> Create() + { + Mock> storeMock = new(); + return new Mock>( + storeMock.Object, + null!, + null!, + null!, + null!, + null!, + null!, + null!, + null! + ); + } +} diff --git a/web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserHandler.cs b/web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserHandler.cs index cbf4f91c..90387e45 100644 --- a/web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserHandler.cs +++ b/web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserHandler.cs @@ -1,9 +1,9 @@ -using Domain.Entities; using Domain.Exceptions; using Features.Auth.Dtos; -using Features.Auth.Repository; +using Features.Auth.Identity; using Features.Auth.Services; using MediatR; +using Microsoft.AspNetCore.Identity; namespace Features.Auth.Commands.ConfirmUser; @@ -11,8 +11,11 @@ namespace Features.Auth.Commands.ConfirmUser; /// Handles by validating the confirmation token and marking the /// corresponding user account as confirmed. /// -public class ConfirmUserHandler(IAuthRepository authRepository, ITokenService tokenService) - : IRequestHandler +public class ConfirmUserHandler( + UserManager userManager, + IUserEmailStore emailStore, + ITokenService tokenService +) : IRequestHandler { /// /// Thrown when the confirmation token is invalid or expired, or when the associated user account cannot be found. @@ -26,11 +29,15 @@ public class ConfirmUserHandler(IAuthRepository authRepository, ITokenService to request.Token ); - UserAccount? user = await authRepository.ConfirmUserAccountAsync(validatedToken.UserId); - + ApplicationUser? user = await userManager.FindByIdAsync( + validatedToken.UserId.ToString() + ); if (user == null) throw new UnauthorizedException("User account not found"); - return new ConfirmationPayload(user.UserAccountId, DateTime.UtcNow); + await emailStore.SetEmailConfirmedAsync(user, true, cancellationToken); + await userManager.UpdateAsync(user); + + return new ConfirmationPayload(user.Id, DateTime.UtcNow); } } diff --git a/web/backend/Features/Features.Auth/Commands/Login/LoginHandler.cs b/web/backend/Features/Features.Auth/Commands/Login/LoginHandler.cs index 32db7718..f8bb07dd 100644 --- a/web/backend/Features/Features.Auth/Commands/Login/LoginHandler.cs +++ b/web/backend/Features/Features.Auth/Commands/Login/LoginHandler.cs @@ -1,43 +1,35 @@ -using Domain.Entities; using Domain.Exceptions; using Features.Auth.Dtos; -using Features.Auth.Repository; +using Features.Auth.Identity; using Features.Auth.Services; -using Infrastructure.PasswordHashing; using MediatR; +using Microsoft.AspNetCore.Identity; namespace Features.Auth.Commands.Login; /// -/// Handles by verifying credentials and issuing access/refresh tokens. +/// Handles by verifying credentials via +/// and issuing access/refresh tokens. /// -public class LoginHandler( - IAuthRepository authRepo, - IPasswordInfrastructure passwordInfrastructure, - ITokenService tokenService -) : IRequestHandler +public class LoginHandler(UserManager userManager, ITokenService tokenService) + : IRequestHandler { /// - /// Thrown when the username does not match any account, the account has no active credential, - /// or the supplied password does not match the stored hash. + /// Thrown when the username does not match any account or the supplied password does not match + /// the stored hash. /// public async Task Handle(LoginCommand request, CancellationToken cancellationToken) { - UserAccount user = - await authRepo.GetUserByUsernameAsync(request.Username) + ApplicationUser user = + await userManager.FindByNameAsync(request.Username) ?? throw new UnauthorizedException("Invalid username or password."); - // @todo handle expired passwords - UserCredential activeCred = - await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId) - ?? throw new UnauthorizedException("Invalid username or password."); - - if (!passwordInfrastructure.Verify(request.Password, activeCred.Hash)) + if (!await userManager.CheckPasswordAsync(user, request.Password)) throw new UnauthorizedException("Invalid username or password."); - string accessToken = tokenService.GenerateAccessToken(user.UserAccountId, user.Username); - string refreshToken = tokenService.GenerateRefreshToken(user.UserAccountId, user.Username); + string accessToken = tokenService.GenerateAccessToken(user.Id, user.UserName); + string refreshToken = tokenService.GenerateRefreshToken(user.Id, user.UserName); - return new LoginPayload(user.UserAccountId, user.Username, refreshToken, accessToken); + return new LoginPayload(user.Id, user.UserName, refreshToken, accessToken); } } diff --git a/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs b/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs index f6202652..f1c29092 100644 --- a/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs +++ b/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs @@ -1,84 +1,62 @@ -using Domain.Entities; using Domain.Exceptions; using Features.Auth.Dtos; -using Features.Auth.Repository; +using Features.Auth.Identity; using Features.Auth.Services; -using Infrastructure.PasswordHashing; using MediatR; +using Microsoft.AspNetCore.Identity; using Shared.Application.Emails; namespace Features.Auth.Commands.RegisterUser; /// -/// Handles : validates uniqueness, hashes the password, persists the -/// account, issues access/refresh/confirmation tokens, and attempts to send the registration -/// confirmation email via Features.Emails. +/// Handles : creates the user via +/// (which validates uniqueness and hashes the password), issues access/refresh/confirmation tokens, and +/// attempts to send the registration confirmation email via Features.Emails. /// public class RegisterUserHandler( - IAuthRepository authRepo, - IPasswordInfrastructure passwordInfrastructure, + UserManager userManager, ITokenService tokenService, IMediator mediator ) : IRequestHandler { /// - /// Thrown when an existing account already has the same username or email address. + /// Thrown when an existing account already has the same username or email address, or when user + /// creation otherwise fails. /// public async Task Handle( RegisterUserCommand request, CancellationToken cancellationToken ) { - await ValidateUserDoesNotExist(request.Username, request.Email); + ApplicationUser user = new() + { + UserName = request.Username, + Email = request.Email, + FirstName = request.FirstName, + LastName = request.LastName, + DateOfBirth = request.DateOfBirth, + }; - string hashed = passwordInfrastructure.Hash(request.Password); + IdentityResult result = await userManager.CreateAsync(user, request.Password); + if (!result.Succeeded) + { + if (result.Errors.Any(e => e.Code is "DuplicateUserName" or "DuplicateEmail")) + throw new ConflictException("Username or email already exists"); + throw new ConflictException(string.Join("; ", result.Errors.Select(e => e.Description))); + } - UserAccount createdUser = await authRepo.RegisterUserAsync( - new UserAccount - { - Username = request.Username, - FirstName = request.FirstName, - LastName = request.LastName, - DateOfBirth = request.DateOfBirth, - Email = request.Email, - UserCredential = new UserCredential - { - Hash = hashed, - } - } - ); - - string accessToken = tokenService.GenerateAccessToken( - createdUser.UserAccountId, - createdUser.Username - ); - string refreshToken = tokenService.GenerateRefreshToken( - createdUser.UserAccountId, - createdUser.Username - ); - string confirmationToken = tokenService.GenerateConfirmationToken( - createdUser.UserAccountId, - createdUser.Username - ); + string accessToken = tokenService.GenerateAccessToken(user.Id, user.UserName); + string refreshToken = tokenService.GenerateRefreshToken(user.Id, user.UserName); + string confirmationToken = tokenService.GenerateConfirmationToken(user.Id, user.UserName); if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken)) - return new RegistrationPayload( - createdUser.UserAccountId, - createdUser.Username, - string.Empty, - string.Empty, - false - ); + return new RegistrationPayload(user.Id, user.UserName, string.Empty, string.Empty, false); bool emailSent = false; try { await mediator.Send( - new SendRegistrationEmailCommand( - createdUser.FirstName, - createdUser.Email, - confirmationToken - ), + new SendRegistrationEmailCommand(user.FirstName, user.Email, confirmationToken), cancellationToken ); emailSent = true; @@ -90,24 +68,6 @@ public class RegisterUserHandler( // ignored } - return new RegistrationPayload( - createdUser.UserAccountId, - createdUser.Username, - refreshToken, - accessToken, - emailSent - ); - } - - /// - /// Thrown when an existing account already has the same username or email address. - /// - private async Task ValidateUserDoesNotExist(string username, string email) - { - UserAccount? existingUsername = await authRepo.GetUserByUsernameAsync(username); - UserAccount? existingEmail = await authRepo.GetUserByEmailAsync(email); - - if (existingUsername != null || existingEmail != null) - throw new ConflictException("Username or email already exists"); + return new RegistrationPayload(user.Id, user.UserName, refreshToken, accessToken, emailSent); } } diff --git a/web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs b/web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs index f8227d65..9b8ef647 100644 --- a/web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs +++ b/web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs @@ -1,7 +1,7 @@ -using Domain.Entities; -using Features.Auth.Repository; +using Features.Auth.Identity; using Features.Auth.Services; using MediatR; +using Microsoft.AspNetCore.Identity; using Shared.Application.Emails; namespace Features.Auth.Commands.ResendConfirmationEmail; @@ -15,7 +15,7 @@ namespace Features.Auth.Commands.ResendConfirmationEmail; /// or if the user's account is already verified. /// public class ResendConfirmationEmailHandler( - IAuthRepository authRepository, + UserManager userManager, ITokenService tokenService, IMediator mediator ) : IRequestHandler @@ -25,17 +25,14 @@ public class ResendConfirmationEmailHandler( CancellationToken cancellationToken ) { - UserAccount? user = await authRepository.GetUserByIdAsync(request.UserId); + ApplicationUser? user = await userManager.FindByIdAsync(request.UserId.ToString()); if (user == null) return; // Silent return to prevent user enumeration - if (await authRepository.IsUserVerifiedAsync(request.UserId)) + if (await userManager.IsEmailConfirmedAsync(user)) return; // Already confirmed, no-op - string confirmationToken = tokenService.GenerateConfirmationToken( - user.UserAccountId, - user.Username - ); + string confirmationToken = tokenService.GenerateConfirmationToken(user.Id, user.UserName); await mediator.Send( new SendResendConfirmationEmailCommand(user.FirstName, user.Email, confirmationToken), cancellationToken