Migrate handlers to UserManager<>

This commit is contained in:
Aaron Po
2026-08-22 01:31:49 -04:00
parent f06f4bf3a9
commit 63d195ddf7
9 changed files with 205 additions and 299 deletions

View File

@@ -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<IAuthRepository> _authRepositoryMock;
private readonly Mock<IUserEmailStore<ApplicationUser>> _emailStoreMock;
private readonly ConfirmUserHandler _handler;
private readonly Mock<ITokenService> _tokenServiceMock;
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
public ConfirmUserHandlerTests()
{
_authRepositoryMock = new Mock<IAuthRepository>();
_userManagerMock = UserManagerMockFactory.Create();
_emailStoreMock = new Mock<IUserEmailStore<ApplicationUser>>();
_tokenServiceMock = new Mock<ITokenService>();
_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<CancellationToken>()),
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<Task<ConfirmationPayload>> act = async () =>
await _handler.Handle(

View File

@@ -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<IAuthRepository> _authRepoMock;
private readonly RegisterUserHandler _handler;
private readonly Mock<IMediator> _mediatorMock;
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
private readonly Mock<ITokenService> _tokenServiceMock;
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
public RegisterUserHandlerTests()
{
_authRepoMock = new Mock<IAuthRepository>();
_passwordInfraMock = new Mock<IPasswordInfrastructure>();
_userManagerMock = UserManagerMockFactory.Create();
_tokenServiceMock = new Mock<ITokenService>();
_mediatorMock = new Mock<IMediator>();
_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<UserAccount>(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<ApplicationUser>(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<ApplicationUser, string>((u, _) => u.Id = expectedUserId)
.ReturnsAsync(IdentityResult.Success);
_tokenServiceMock
.Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>()))
@@ -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<ApplicationUser>(), command.Password))
.ReturnsAsync(
IdentityResult.Failed(
new IdentityError { Code = "DuplicateUserName", Description = "Username taken." }
)
);
Func<Task<RegistrationPayload>> act = async () =>
await _handler.Handle(command, CancellationToken.None);
@@ -139,27 +113,20 @@ public class RegisterUserHandlerTests
await act.Should()
.ThrowAsync<ConflictException>()
.WithMessage("Username or email already exists");
_authRepoMock.Verify(
x => x.RegisterUserAsync(It.IsAny<UserAccount>()),
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<ApplicationUser>(), command.Password))
.ReturnsAsync(
IdentityResult.Failed(
new IdentityError { Code = "DuplicateEmail", Description = "Email taken." }
)
);
Func<Task<RegistrationPayload>> 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<string>()))
.ReturnsAsync((UserAccount?)null);
_authRepoMock
.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>()))
.ReturnsAsync((UserAccount?)null);
_passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword);
_authRepoMock
.Setup(x =>
x.RegisterUserAsync(
It.Is<UserAccount>(ua => ua.UserCredential!.Hash == hashedPassword)
)
)
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() });
_userManagerMock
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), command.Password))
.Callback<ApplicationUser, string>((u, _) => u.Id = Guid.NewGuid())
.ReturnsAsync(IdentityResult.Success);
_tokenServiceMock
.Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>()))
@@ -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<ApplicationUser> -- the handler never sees or stores a hash directly.
_userManagerMock.Verify(
x =>
x.RegisterUserAsync(
It.Is<UserAccount>(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<ApplicationUser>(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<string>()))
.ReturnsAsync((UserAccount?)null);
_authRepoMock
.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>()))
.ReturnsAsync((UserAccount?)null);
_passwordInfraMock.Setup(x => x.Hash(It.IsAny<string>())).Returns("hashed");
_authRepoMock
.Setup(x => x.RegisterUserAsync(It.IsAny<UserAccount>()))
.ReturnsAsync(
new UserAccount
{
UserAccountId = Guid.NewGuid(),
Username = command.Username,
Email = command.Email,
}
);
_userManagerMock
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), command.Password))
.Callback<ApplicationUser, string>((u, _) => u.Id = Guid.NewGuid())
.ReturnsAsync(IdentityResult.Success);
_tokenServiceMock
.Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>()))

View File

@@ -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<IAuthRepository> _authRepositoryMock = new();
private readonly ResendConfirmationEmailHandler _handler;
private readonly Mock<IMediator> _mediatorMock = new();
private readonly Mock<ITokenService> _tokenServiceMock = new();
private readonly Mock<UserManager<ApplicationUser>> _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);

View File

@@ -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<IAuthRepository> _authRepoMock;
private readonly LoginHandler _handler;
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
private readonly Mock<ITokenService> _tokenServiceMock;
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
public LoginHandlerTests()
{
_authRepoMock = new Mock<IAuthRepository>();
_passwordInfraMock = new Mock<IPasswordInfrastructure>();
_userManagerMock = UserManagerMockFactory.Create();
_tokenServiceMock = new Mock<ITokenService>();
_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<string>(), It.IsAny<string>()))
.Returns(true);
_userManagerMock.Setup(x => x.FindByNameAsync(username)).ReturnsAsync(user);
_userManagerMock
.Setup(x => x.CheckPasswordAsync(user, It.IsAny<string>()))
.ReturnsAsync(true);
_tokenServiceMock
.Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>()))
.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<Task<LoginPayload>> act = async () =>
await _handler.Handle(new LoginCommand(username, "password"), CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedException>();
_authRepoMock.Verify(
x => x.GetActiveCredentialByUserAccountIdAsync(It.IsAny<Guid>()),
_userManagerMock.Verify(
x => x.CheckPasswordAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>()),
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<string>())).ReturnsAsync(false);
Func<Task<LoginPayload>> act = async () =>
await _handler.Handle(new LoginCommand(username, "password"), CancellationToken.None);
@@ -115,32 +95,16 @@ public class LoginHandlerTests
await act.Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("Invalid username or password.");
_passwordInfraMock.Verify(
x => x.Verify(It.IsAny<string>(), It.IsAny<string>()),
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<string>(), It.IsAny<string>()))
.Returns(false);
_userManagerMock.Setup(x => x.FindByNameAsync(username)).ReturnsAsync(user);
_userManagerMock.Setup(x => x.CheckPasswordAsync(user, It.IsAny<string>())).ReturnsAsync(false);
Func<Task<LoginPayload>> act = async () =>
await _handler.Handle(

View File

@@ -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<UserManager<ApplicationUser>> Create()
{
Mock<IUserStore<ApplicationUser>> storeMock = new();
return new Mock<UserManager<ApplicationUser>>(
storeMock.Object,
null!,
null!,
null!,
null!,
null!,
null!,
null!,
null!
);
}
}

View File

@@ -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 <see cref="ConfirmUserCommand" /> by validating the confirmation token and marking the
/// corresponding user account as confirmed.
/// </summary>
public class ConfirmUserHandler(IAuthRepository authRepository, ITokenService tokenService)
: IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
public class ConfirmUserHandler(
UserManager<ApplicationUser> userManager,
IUserEmailStore<ApplicationUser> emailStore,
ITokenService tokenService
) : IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
{
/// <exception cref="UnauthorizedException">
/// 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);
}
}

View File

@@ -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;
/// <summary>
/// Handles <see cref="LoginCommand" /> by verifying credentials and issuing access/refresh tokens.
/// Handles <see cref="LoginCommand" /> by verifying credentials via
/// <see cref="UserManager{TUser}" /> and issuing access/refresh tokens.
/// </summary>
public class LoginHandler(
IAuthRepository authRepo,
IPasswordInfrastructure passwordInfrastructure,
ITokenService tokenService
) : IRequestHandler<LoginCommand, LoginPayload>
public class LoginHandler(UserManager<ApplicationUser> userManager, ITokenService tokenService)
: IRequestHandler<LoginCommand, LoginPayload>
{
/// <exception cref="UnauthorizedException">
/// 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.
/// </exception>
public async Task<LoginPayload> 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);
}
}

View File

@@ -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;
/// <summary>
/// Handles <see cref="RegisterUserCommand" />: 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 <see cref="RegisterUserCommand" />: creates the user via <see cref="UserManager{TUser}" />
/// (which validates uniqueness and hashes the password), issues access/refresh/confirmation tokens, and
/// attempts to send the registration confirmation email via Features.Emails.
/// </summary>
public class RegisterUserHandler(
IAuthRepository authRepo,
IPasswordInfrastructure passwordInfrastructure,
UserManager<ApplicationUser> userManager,
ITokenService tokenService,
IMediator mediator
) : IRequestHandler<RegisterUserCommand, RegistrationPayload>
{
/// <exception cref="ConflictException">
/// 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.
/// </exception>
public async Task<RegistrationPayload> 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
);
}
/// <exception cref="ConflictException">
/// Thrown when an existing account already has the same username or email address.
/// </exception>
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);
}
}

View File

@@ -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.
/// </remarks>
public class ResendConfirmationEmailHandler(
IAuthRepository authRepository,
UserManager<ApplicationUser> userManager,
ITokenService tokenService,
IMediator mediator
) : IRequestHandler<ResendConfirmationEmailCommand>
@@ -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