mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-07-16 17:47:22 +00:00
Migrate Auth and Emails to vertical slices (Features.Auth, Features.Emails)
Auth and Emails land together since Auth's registration/resend flows need Emails to exist first. Service.Auth's four services (Register, Login, Confirmation, Token) collapse into MediatR commands/queries in Features.Auth; TokenService stays as a slice-internal service since multiple handlers call it. Auth no longer references Emails directly — it sends SendRegistrationEmailCommand/SendResendConfirmationEmailCommand (defined in Shared.Application) which Features.Emails handles, so neither slice has a project reference on the other. Service.Emails' IEmailService becomes Features.Emails' IEmailDispatcher, simplified to take (firstName, email, token) instead of a full UserAccount since that's all it ever used. API.Specs' TestApiFactory/MockEmailService are updated to swap in the relocated interface. Also deletes Infrastructure.Repository now that Breweries, UserManagement, and Auth have all moved their repos out of it, and replaces the two now-dead docker-compose test services (repository.tests, service.auth.tests, both pointing at deleted Dockerfiles) with a single unit.tests service that runs every Features.*.Tests project via Dockerfile.tests.
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.ConfirmUser;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a confirmation token and confirms the corresponding user account.
|
||||
/// </summary>
|
||||
/// <param name="Token">The confirmation token issued to the user, typically delivered via email.</param>
|
||||
public record ConfirmUserCommand(string Token) : IRequest<ConfirmationPayload>;
|
||||
@@ -0,0 +1,36 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Repository;
|
||||
using Features.Auth.Services;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.ConfirmUser;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="ConfirmUserCommand"/> by validating the confirmation token and marking the
|
||||
/// corresponding user account as confirmed.
|
||||
/// </summary>
|
||||
/// <param name="authRepository">Repository used to look up and confirm user accounts.</param>
|
||||
/// <param name="tokenService">Service used to validate the confirmation token.</param>
|
||||
public class ConfirmUserHandler(
|
||||
IAuthRepository authRepository,
|
||||
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.
|
||||
/// </exception>
|
||||
public async Task<ConfirmationPayload> Handle(ConfirmUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var validatedToken = await tokenService.ValidateConfirmationTokenAsync(request.Token);
|
||||
|
||||
var user = await authRepository.ConfirmUserAccountAsync(validatedToken.UserId);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
throw new UnauthorizedException("User account not found");
|
||||
}
|
||||
|
||||
return new ConfirmationPayload(user.UserAccountId, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.RefreshToken;
|
||||
|
||||
/// <summary>
|
||||
/// Exchanges a valid refresh token for a new access/refresh token pair. Bound directly from the
|
||||
/// request body of <c>POST /api/auth/refresh</c>.
|
||||
/// </summary>
|
||||
public record RefreshTokenCommand(string RefreshToken) : IRequest<LoginPayload>;
|
||||
@@ -0,0 +1,20 @@
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Services;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.RefreshToken;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="RefreshTokenCommand"/> by validating the refresh token and issuing a new
|
||||
/// access/refresh token pair.
|
||||
/// </summary>
|
||||
/// <param name="tokenService">Service used to validate and exchange the refresh token.</param>
|
||||
public class RefreshTokenHandler(ITokenService tokenService)
|
||||
: IRequestHandler<RefreshTokenCommand, LoginPayload>
|
||||
{
|
||||
public async Task<LoginPayload> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await tokenService.RefreshTokenAsync(request.RefreshToken);
|
||||
return new LoginPayload(result.UserAccount.UserAccountId, result.UserAccount.Username, result.RefreshToken, result.AccessToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.RefreshToken;
|
||||
|
||||
/// <summary>
|
||||
/// Validates <see cref="RefreshTokenCommand"/> instances before they are processed.
|
||||
/// </summary>
|
||||
public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures a validation rule requiring <see cref="RefreshTokenCommand.RefreshToken"/> to be non-empty.
|
||||
/// </summary>
|
||||
public RefreshTokenValidator()
|
||||
{
|
||||
RuleFor(x => x.RefreshToken)
|
||||
.NotEmpty()
|
||||
.WithMessage("Refresh token is required");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.RegisterUser;
|
||||
|
||||
/// <summary>
|
||||
/// Registers a new user account. Bound directly from the request body of <c>POST /api/auth/register</c>.
|
||||
/// </summary>
|
||||
/// <param name="Username">The desired username; must be 3-64 characters and contain only letters, numbers, dots, underscores, and hyphens.</param>
|
||||
/// <param name="FirstName">The user's first name; up to 128 characters.</param>
|
||||
/// <param name="LastName">The user's last name; up to 128 characters.</param>
|
||||
/// <param name="Email">The user's email address; up to 128 characters and must be a valid email format.</param>
|
||||
/// <param name="DateOfBirth">The user's date of birth; the user must be at least 19 years old.</param>
|
||||
/// <param name="Password">The desired plaintext password; must be at least 8 characters and contain an uppercase letter, a lowercase letter, a number, and a special character.</param>
|
||||
public record RegisterUserCommand(
|
||||
string Username,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
string Email,
|
||||
DateTime DateOfBirth,
|
||||
string Password
|
||||
) : IRequest<RegistrationPayload>;
|
||||
@@ -0,0 +1,86 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Repository;
|
||||
using Features.Auth.Services;
|
||||
using Infrastructure.PasswordHashing;
|
||||
using MediatR;
|
||||
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.
|
||||
/// </summary>
|
||||
/// <param name="authRepo">Repository used to check for existing users and persist the new account.</param>
|
||||
/// <param name="passwordInfrastructure">Infrastructure component used to hash the user's plain-text password.</param>
|
||||
/// <param name="tokenService">Service used to generate access, refresh, and confirmation tokens.</param>
|
||||
/// <param name="mediator">Used to send the cross-slice command that triggers the confirmation email.</param>
|
||||
public class RegisterUserHandler(
|
||||
IAuthRepository authRepo,
|
||||
IPasswordInfrastructure passwordInfrastructure,
|
||||
ITokenService tokenService,
|
||||
IMediator mediator
|
||||
) : IRequestHandler<RegisterUserCommand, RegistrationPayload>
|
||||
{
|
||||
/// <exception cref="ConflictException">
|
||||
/// Thrown when an existing account already has the same username or email address.
|
||||
/// </exception>
|
||||
public async Task<RegistrationPayload> Handle(RegisterUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
await ValidateUserDoesNotExist(request.Username, request.Email);
|
||||
|
||||
var hashed = passwordInfrastructure.Hash(request.Password);
|
||||
|
||||
var createdUser = await authRepo.RegisterUserAsync(
|
||||
request.Username,
|
||||
request.FirstName,
|
||||
request.LastName,
|
||||
request.Email,
|
||||
request.DateOfBirth,
|
||||
hashed
|
||||
);
|
||||
|
||||
var accessToken = tokenService.GenerateAccessToken(createdUser);
|
||||
var refreshToken = tokenService.GenerateRefreshToken(createdUser);
|
||||
var confirmationToken = tokenService.GenerateConfirmationToken(createdUser);
|
||||
|
||||
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
|
||||
{
|
||||
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, string.Empty, string.Empty, false);
|
||||
}
|
||||
|
||||
var emailSent = false;
|
||||
try
|
||||
{
|
||||
await mediator.Send(
|
||||
new SendRegistrationEmailCommand(createdUser.FirstName, createdUser.Email, confirmationToken),
|
||||
cancellationToken
|
||||
);
|
||||
emailSent = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Console.Error.WriteLineAsync(ex.Message);
|
||||
Console.WriteLine("Could not send email.");
|
||||
// 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)
|
||||
{
|
||||
var existingUsername = await authRepo.GetUserByUsernameAsync(username);
|
||||
var existingEmail = await authRepo.GetUserByEmailAsync(email);
|
||||
|
||||
if (existingUsername != null || existingEmail != null)
|
||||
{
|
||||
throw new ConflictException("Username or email already exists");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.RegisterUser;
|
||||
|
||||
/// <summary>
|
||||
/// Validates <see cref="RegisterUserCommand"/> instances before they are processed.
|
||||
/// </summary>
|
||||
public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures validation rules for username format and length, first/last name length, email format and
|
||||
/// length, minimum age based on date of birth, and password strength requirements.
|
||||
/// </summary>
|
||||
public RegisterUserValidator()
|
||||
{
|
||||
RuleFor(x => x.Username)
|
||||
.NotEmpty()
|
||||
.WithMessage("Username is required")
|
||||
.Length(3, 64)
|
||||
.WithMessage("Username must be between 3 and 64 characters")
|
||||
.Matches("^[a-zA-Z0-9._-]+$")
|
||||
.WithMessage(
|
||||
"Username can only contain letters, numbers, dots, underscores, and hyphens"
|
||||
);
|
||||
|
||||
RuleFor(x => x.FirstName)
|
||||
.NotEmpty()
|
||||
.WithMessage("First name is required")
|
||||
.MaximumLength(128)
|
||||
.WithMessage("First name cannot exceed 128 characters");
|
||||
|
||||
RuleFor(x => x.LastName)
|
||||
.NotEmpty()
|
||||
.WithMessage("Last name is required")
|
||||
.MaximumLength(128)
|
||||
.WithMessage("Last name cannot exceed 128 characters");
|
||||
|
||||
RuleFor(x => x.Email)
|
||||
.NotEmpty()
|
||||
.WithMessage("Email is required")
|
||||
.EmailAddress()
|
||||
.WithMessage("Invalid email format")
|
||||
.MaximumLength(128)
|
||||
.WithMessage("Email cannot exceed 128 characters");
|
||||
|
||||
RuleFor(x => x.DateOfBirth)
|
||||
.NotEmpty()
|
||||
.WithMessage("Date of birth is required")
|
||||
.LessThan(DateTime.Today.AddYears(-19))
|
||||
.WithMessage("You must be at least 19 years old to register");
|
||||
|
||||
RuleFor(x => x.Password)
|
||||
.NotEmpty()
|
||||
.WithMessage("Password is required")
|
||||
.MinimumLength(8)
|
||||
.WithMessage("Password must be at least 8 characters")
|
||||
.Matches("[A-Z]")
|
||||
.WithMessage("Password must contain at least one uppercase letter")
|
||||
.Matches("[a-z]")
|
||||
.WithMessage("Password must contain at least one lowercase letter")
|
||||
.Matches("[0-9]")
|
||||
.WithMessage("Password must contain at least one number")
|
||||
.Matches("[^a-zA-Z0-9]")
|
||||
.WithMessage(
|
||||
"Password must contain at least one special character"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.ResendConfirmationEmail;
|
||||
|
||||
/// <summary>
|
||||
/// Resends the account confirmation email to a user, generating a fresh confirmation token.
|
||||
/// </summary>
|
||||
/// <param name="UserId">The unique identifier of the user requesting the resend.</param>
|
||||
public record ResendConfirmationEmailCommand(Guid UserId) : IRequest;
|
||||
@@ -0,0 +1,44 @@
|
||||
using Features.Auth.Repository;
|
||||
using Features.Auth.Services;
|
||||
using MediatR;
|
||||
using Shared.Application.Emails;
|
||||
|
||||
namespace Features.Auth.Commands.ResendConfirmationEmail;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="ResendConfirmationEmailCommand"/> by generating a fresh confirmation token and
|
||||
/// sending it via Features.Emails.
|
||||
/// </summary>
|
||||
/// <param name="authRepository">Repository used to look up the user and check verification status.</param>
|
||||
/// <param name="tokenService">Service used to generate the confirmation token.</param>
|
||||
/// <param name="mediator">Used to send the cross-slice command that triggers the email.</param>
|
||||
/// <remarks>
|
||||
/// Returns silently without sending an email if the user does not exist (to prevent user enumeration)
|
||||
/// or if the user's account is already verified.
|
||||
/// </remarks>
|
||||
public class ResendConfirmationEmailHandler(
|
||||
IAuthRepository authRepository,
|
||||
ITokenService tokenService,
|
||||
IMediator mediator
|
||||
) : IRequestHandler<ResendConfirmationEmailCommand>
|
||||
{
|
||||
public async Task Handle(ResendConfirmationEmailCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await authRepository.GetUserByIdAsync(request.UserId);
|
||||
if (user == null)
|
||||
{
|
||||
return; // Silent return to prevent user enumeration
|
||||
}
|
||||
|
||||
if (await authRepository.IsUserVerifiedAsync(request.UserId))
|
||||
{
|
||||
return; // Already confirmed, no-op
|
||||
}
|
||||
|
||||
var confirmationToken = tokenService.GenerateConfirmationToken(user);
|
||||
await mediator.Send(
|
||||
new SendResendConfirmationEmailCommand(user.FirstName, user.Email, confirmationToken),
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user