mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 10:04:44 +00:00
Merge Features.Auth and Features.UserManagement into Features.Users
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.ConfirmUser;
|
||||
|
||||
/// <summary>Validates a confirmation token and confirms the corresponding user account.</summary>
|
||||
public record ConfirmUserCommand(string Token) : IRequest<ConfirmationPayload>;
|
||||
@@ -0,0 +1,41 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Services;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Commands.ConfirmUser;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="ConfirmUserCommand" /> by validating the confirmation token and marking the
|
||||
/// corresponding user account as confirmed.
|
||||
/// </summary>
|
||||
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.
|
||||
/// </exception>
|
||||
public async Task<ConfirmationPayload> Handle(
|
||||
ConfirmUserCommand request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
ValidatedToken validatedToken = await tokenService.ValidateConfirmationTokenAsync(
|
||||
request.Token
|
||||
);
|
||||
|
||||
ApplicationUser? user = await userManager.FindByIdAsync(validatedToken.UserId.ToString());
|
||||
if (user == null)
|
||||
throw new UnauthorizedException("User account not found");
|
||||
|
||||
await emailStore.SetEmailConfirmedAsync(user, true, cancellationToken);
|
||||
await userManager.UpdateAsync(user);
|
||||
|
||||
return new ConfirmationPayload(user.Id, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.DeleteAccount;
|
||||
|
||||
/// <summary>Permanently deletes the given user account.</summary>
|
||||
/// <param name="UserAccountId">
|
||||
/// The authenticated caller's own ID, extracted from the access token -- never bind this from
|
||||
/// client-supplied input.
|
||||
/// </param>
|
||||
public record DeleteAccountCommand(Guid UserAccountId) : IRequest;
|
||||
@@ -0,0 +1,35 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Identity;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Commands.DeleteAccount;
|
||||
|
||||
/// <summary>Handles <see cref="DeleteAccountCommand" /> by deleting the user via <see cref="UserManager{TUser}" />.</summary>
|
||||
public class DeleteAccountHandler(UserManager<ApplicationUser> userManager)
|
||||
: IRequestHandler<DeleteAccountCommand>
|
||||
{
|
||||
/// <exception cref="NotFoundException">Thrown when the account does not exist.</exception>
|
||||
/// <exception cref="ConflictException">
|
||||
/// Thrown when the account cannot be deleted because other records (posts, comments, photos,
|
||||
/// follows) still reference it.
|
||||
/// </exception>
|
||||
public async Task Handle(DeleteAccountCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
ApplicationUser user =
|
||||
await userManager.FindByIdAsync(request.UserAccountId.ToString())
|
||||
?? throw new NotFoundException("User account not found");
|
||||
|
||||
IdentityResult result = await userManager.DeleteAsync(user);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
if (result.Errors.Any(e => e.Code == "AccountHasDependentRecords"))
|
||||
throw new ConflictException(
|
||||
"Account cannot be deleted while it still has associated posts, comments, photos, or follows."
|
||||
);
|
||||
throw new ConflictException(
|
||||
string.Join("; ", result.Errors.Select(e => e.Description))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.Login;
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates a user using their username and password and issues new tokens. Bound directly
|
||||
/// from the request body of <c>POST /api/auth/login</c>.
|
||||
/// </summary>
|
||||
public record LoginCommand(string Username, string Password) : IRequest<LoginPayload>;
|
||||
@@ -0,0 +1,38 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Services;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Commands.Login;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="LoginCommand" /> by verifying credentials via
|
||||
/// <see cref="UserManager{TUser}" /> and issuing access/refresh tokens.
|
||||
/// </summary>
|
||||
public class LoginHandler(UserManager<ApplicationUser> userManager, ITokenService tokenService)
|
||||
: IRequestHandler<LoginCommand, LoginPayload>
|
||||
{
|
||||
/// <exception cref="UnauthorizedException">
|
||||
/// 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
|
||||
)
|
||||
{
|
||||
ApplicationUser user =
|
||||
await userManager.FindByNameAsync(request.Username)
|
||||
?? throw new UnauthorizedException("Invalid username or password.");
|
||||
|
||||
if (!await userManager.CheckPasswordAsync(user, request.Password))
|
||||
throw new UnauthorizedException("Invalid username or password.");
|
||||
|
||||
string accessToken = tokenService.GenerateAccessToken(user.Id, user.UserName);
|
||||
string refreshToken = tokenService.GenerateRefreshToken(user.Id, user.UserName);
|
||||
|
||||
return new LoginPayload(user.Id, user.UserName, refreshToken, accessToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.Login;
|
||||
|
||||
/// <summary>Validates that <see cref="LoginCommand.Username" /> and <see cref="LoginCommand.Password" /> are present.</summary>
|
||||
public class LoginValidator : AbstractValidator<LoginCommand>
|
||||
{
|
||||
public LoginValidator()
|
||||
{
|
||||
RuleFor(x => x.Username).NotEmpty().WithMessage("Username is required");
|
||||
|
||||
RuleFor(x => x.Password).NotEmpty().WithMessage("Password is required");
|
||||
}
|
||||
}
|
||||
@@ -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. Constructed from the
|
||||
/// <c>X-Refresh-Token</c> header of <c>POST /api/auth/refresh</c>.
|
||||
/// </summary>
|
||||
public record RefreshTokenCommand(string RefreshToken) : IRequest<LoginPayload>;
|
||||
@@ -0,0 +1,31 @@
|
||||
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>
|
||||
public class RefreshTokenHandler(ITokenService tokenService)
|
||||
: IRequestHandler<RefreshTokenCommand, LoginPayload>
|
||||
{
|
||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||
/// Thrown when the refresh token is invalid or expired, or when the user account it refers to no
|
||||
/// longer exists.
|
||||
/// </exception>
|
||||
public async Task<LoginPayload> Handle(
|
||||
RefreshTokenCommand request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
RefreshTokenResult result = await tokenService.RefreshTokenAsync(request.RefreshToken);
|
||||
return new LoginPayload(
|
||||
result.UserId,
|
||||
result.Username,
|
||||
result.RefreshToken,
|
||||
result.AccessToken
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.RefreshToken;
|
||||
|
||||
/// <summary>Validates that <see cref="RefreshTokenCommand.RefreshToken" /> is present.</summary>
|
||||
public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
|
||||
{
|
||||
public RefreshTokenValidator()
|
||||
{
|
||||
RuleFor(x => x.RefreshToken).NotEmpty().WithMessage("Refresh token is required");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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,87 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Services;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Shared.Application.Emails;
|
||||
|
||||
namespace Features.Auth.Commands.RegisterUser;
|
||||
|
||||
/// <summary>
|
||||
/// 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(
|
||||
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, or when user
|
||||
/// creation otherwise fails.
|
||||
/// </exception>
|
||||
public async Task<RegistrationPayload> Handle(
|
||||
RegisterUserCommand request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
ApplicationUser user = new UserAccount
|
||||
{
|
||||
Username = request.Username,
|
||||
Email = request.Email,
|
||||
FirstName = request.FirstName,
|
||||
LastName = request.LastName,
|
||||
DateOfBirth = request.DateOfBirth,
|
||||
}.ToApplicationUser();
|
||||
|
||||
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))
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
user.Id,
|
||||
user.UserName,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
false
|
||||
);
|
||||
|
||||
bool emailSent = false;
|
||||
try
|
||||
{
|
||||
await mediator.Send(
|
||||
new SendRegistrationEmailCommand(user.FirstName, user.Email, confirmationToken),
|
||||
cancellationToken
|
||||
);
|
||||
emailSent = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Console.Error.WriteLineAsync(ex.Message);
|
||||
Console.WriteLine("Could not send email.");
|
||||
}
|
||||
|
||||
return new RegistrationPayload(
|
||||
user.Id,
|
||||
user.UserName,
|
||||
refreshToken,
|
||||
accessToken,
|
||||
emailSent
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.RegisterUser;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the field-level constraints on <see cref="RegisterUserCommand" />: username format and
|
||||
/// length, name lengths, email format, minimum age of 19, and password complexity.
|
||||
/// </summary>
|
||||
public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
|
||||
{
|
||||
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,6 @@
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.ResendConfirmationEmail;
|
||||
|
||||
/// <summary>Resends the account confirmation email to a user, generating a fresh confirmation token.</summary>
|
||||
public record ResendConfirmationEmailCommand(Guid UserId) : IRequest;
|
||||
@@ -0,0 +1,41 @@
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Services;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
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>
|
||||
/// <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(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ITokenService tokenService,
|
||||
IMediator mediator
|
||||
) : IRequestHandler<ResendConfirmationEmailCommand>
|
||||
{
|
||||
public async Task Handle(
|
||||
ResendConfirmationEmailCommand request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
ApplicationUser? user = await userManager.FindByIdAsync(request.UserId.ToString());
|
||||
if (user == null)
|
||||
return; // Silent return to prevent user enumeration
|
||||
|
||||
if (await userManager.IsEmailConfirmedAsync(user))
|
||||
return; // Already confirmed, no-op
|
||||
|
||||
string confirmationToken = tokenService.GenerateConfirmationToken(user.Id, user.UserName);
|
||||
await mediator.Send(
|
||||
new SendResendConfirmationEmailCommand(user.FirstName, user.Email, confirmationToken),
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateEmail;
|
||||
|
||||
/// <summary>
|
||||
/// Changes the email address of the given user account. The account's <c>EmailConfirmed</c> status
|
||||
/// is reset to unconfirmed as a result.
|
||||
/// </summary>
|
||||
/// <param name="UserAccountId">
|
||||
/// The authenticated caller's own ID, extracted from the access token -- never bind this from
|
||||
/// client-supplied input.
|
||||
/// </param>
|
||||
public record UpdateEmailCommand(Guid UserAccountId, string NewEmail)
|
||||
: IRequest<UpdateEmailPayload>;
|
||||
@@ -0,0 +1,41 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateEmail;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="UpdateEmailCommand" /> via <see cref="UserManager{TUser}" />.
|
||||
/// <see cref="UserManager{TUser}.SetEmailAsync" /> also resets <c>EmailConfirmed</c> to
|
||||
/// <see langword="false" />, so the user must re-confirm the new address (see
|
||||
/// <c>Features.Users.Commands.ResendConfirmationEmail</c>).
|
||||
/// </summary>
|
||||
public class UpdateEmailHandler(UserManager<ApplicationUser> userManager)
|
||||
: IRequestHandler<UpdateEmailCommand, UpdateEmailPayload>
|
||||
{
|
||||
/// <exception cref="NotFoundException">Thrown when the account does not exist.</exception>
|
||||
/// <exception cref="ConflictException">Thrown when the requested email address is already in use.</exception>
|
||||
public async Task<UpdateEmailPayload> Handle(
|
||||
UpdateEmailCommand request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
ApplicationUser user =
|
||||
await userManager.FindByIdAsync(request.UserAccountId.ToString())
|
||||
?? throw new NotFoundException("User account not found");
|
||||
|
||||
IdentityResult result = await userManager.SetEmailAsync(user, request.NewEmail);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
if (result.Errors.Any(e => e.Code == "DuplicateEmail"))
|
||||
throw new ConflictException("Email address already exists");
|
||||
throw new ConflictException(
|
||||
string.Join("; ", result.Errors.Select(e => e.Description))
|
||||
);
|
||||
}
|
||||
|
||||
return new UpdateEmailPayload(user.Id, user.Email, user.EmailConfirmed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateEmail;
|
||||
|
||||
/// <summary>Validates <see cref="UpdateEmailCommand" />, mirroring the email rules in <c>RegisterUserValidator</c>.</summary>
|
||||
public class UpdateEmailValidator : AbstractValidator<UpdateEmailCommand>
|
||||
{
|
||||
public UpdateEmailValidator()
|
||||
{
|
||||
RuleFor(x => x.NewEmail)
|
||||
.NotEmpty()
|
||||
.WithMessage("Email is required")
|
||||
.EmailAddress()
|
||||
.WithMessage("Invalid email format")
|
||||
.MaximumLength(128)
|
||||
.WithMessage("Email cannot exceed 128 characters");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.UpdatePassword;
|
||||
|
||||
/// <summary>Changes the password of the given user account, verifying the current password first.</summary>
|
||||
/// <param name="UserAccountId">
|
||||
/// The authenticated caller's own ID, extracted from the access token -- never bind this from
|
||||
/// client-supplied input.
|
||||
/// </param>
|
||||
public record UpdatePasswordCommand(Guid UserAccountId, string CurrentPassword, string NewPassword)
|
||||
: IRequest<UpdatePasswordPayload>;
|
||||
@@ -0,0 +1,44 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Commands.UpdatePassword;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="UpdatePasswordCommand" /> via <see cref="UserManager{TUser}.ChangePasswordAsync" />,
|
||||
/// which verifies the current password before hashing and persisting the new one.
|
||||
/// </summary>
|
||||
public class UpdatePasswordHandler(UserManager<ApplicationUser> userManager)
|
||||
: IRequestHandler<UpdatePasswordCommand, UpdatePasswordPayload>
|
||||
{
|
||||
/// <exception cref="NotFoundException">Thrown when the account does not exist.</exception>
|
||||
/// <exception cref="UnauthorizedException">Thrown when the current password does not match.</exception>
|
||||
/// <exception cref="ConflictException">Thrown when the update otherwise fails.</exception>
|
||||
public async Task<UpdatePasswordPayload> Handle(
|
||||
UpdatePasswordCommand request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
ApplicationUser user =
|
||||
await userManager.FindByIdAsync(request.UserAccountId.ToString())
|
||||
?? throw new NotFoundException("User account not found");
|
||||
|
||||
IdentityResult result = await userManager.ChangePasswordAsync(
|
||||
user,
|
||||
request.CurrentPassword,
|
||||
request.NewPassword
|
||||
);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
if (result.Errors.Any(e => e.Code == "PasswordMismatch"))
|
||||
throw new UnauthorizedException("Current password is incorrect");
|
||||
throw new ConflictException(
|
||||
string.Join("; ", result.Errors.Select(e => e.Description))
|
||||
);
|
||||
}
|
||||
|
||||
return new UpdatePasswordPayload(user.Id, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.UpdatePassword;
|
||||
|
||||
/// <summary>Validates <see cref="UpdatePasswordCommand" />, mirroring the password rules in <c>RegisterUserValidator</c>.</summary>
|
||||
public class UpdatePasswordValidator : AbstractValidator<UpdatePasswordCommand>
|
||||
{
|
||||
public UpdatePasswordValidator()
|
||||
{
|
||||
RuleFor(x => x.CurrentPassword).NotEmpty().WithMessage("Current password is required");
|
||||
|
||||
RuleFor(x => x.NewPassword)
|
||||
.NotEmpty()
|
||||
.WithMessage("New 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")
|
||||
.NotEqual(x => x.CurrentPassword)
|
||||
.WithMessage("New password must be different from the current password");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateProfile;
|
||||
|
||||
/// <summary>
|
||||
/// Updates the non-credential profile fields (first name, last name, date of birth) of the given
|
||||
/// user account.
|
||||
/// </summary>
|
||||
/// <param name="UserAccountId">
|
||||
/// The authenticated caller's own ID, extracted from the access token -- never bind this from
|
||||
/// client-supplied input.
|
||||
/// </param>
|
||||
public record UpdateProfileCommand(
|
||||
Guid UserAccountId,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
DateTime DateOfBirth
|
||||
) : IRequest<UpdateProfilePayload>;
|
||||
@@ -0,0 +1,40 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateProfile;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="UpdateProfileCommand" /> via <see cref="UserManager{TUser}" />. First/last name
|
||||
/// and date of birth aren't tracked by any Identity-specific store interface, so the mutated
|
||||
/// <see cref="ApplicationUser" /> is saved directly through <see cref="UserManager{TUser}.UpdateAsync" />.
|
||||
/// </summary>
|
||||
public class UpdateProfileHandler(UserManager<ApplicationUser> userManager)
|
||||
: IRequestHandler<UpdateProfileCommand, UpdateProfilePayload>
|
||||
{
|
||||
/// <exception cref="NotFoundException">Thrown when the account does not exist.</exception>
|
||||
/// <exception cref="ConflictException">Thrown when the update otherwise fails.</exception>
|
||||
public async Task<UpdateProfilePayload> Handle(
|
||||
UpdateProfileCommand request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
ApplicationUser user =
|
||||
await userManager.FindByIdAsync(request.UserAccountId.ToString())
|
||||
?? throw new NotFoundException("User account not found");
|
||||
|
||||
user.FirstName = request.FirstName;
|
||||
user.LastName = request.LastName;
|
||||
user.DateOfBirth = request.DateOfBirth;
|
||||
|
||||
IdentityResult result = await userManager.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
throw new ConflictException(
|
||||
string.Join("; ", result.Errors.Select(e => e.Description))
|
||||
);
|
||||
|
||||
return new UpdateProfilePayload(user.Id, user.FirstName, user.LastName, user.DateOfBirth);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateProfile;
|
||||
|
||||
/// <summary>Validates <see cref="UpdateProfileCommand" />, mirroring the profile rules in <c>RegisterUserValidator</c>.</summary>
|
||||
public class UpdateProfileValidator : AbstractValidator<UpdateProfileCommand>
|
||||
{
|
||||
public UpdateProfileValidator()
|
||||
{
|
||||
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.DateOfBirth)
|
||||
.NotEmpty()
|
||||
.WithMessage("Date of birth is required")
|
||||
.LessThan(DateTime.Today.AddYears(-19))
|
||||
.WithMessage("You must be at least 19 years old");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateUsername;
|
||||
|
||||
/// <summary>Changes the username of the given user account.</summary>
|
||||
/// <param name="UserAccountId">
|
||||
/// The authenticated caller's own ID, extracted from the access token -- never bind this from
|
||||
/// client-supplied input.
|
||||
/// </param>
|
||||
public record UpdateUsernameCommand(Guid UserAccountId, string NewUsername)
|
||||
: IRequest<UpdateUsernamePayload>;
|
||||
@@ -0,0 +1,36 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateUsername;
|
||||
|
||||
/// <summary>Handles <see cref="UpdateUsernameCommand" /> via <see cref="UserManager{TUser}" />.</summary>
|
||||
public class UpdateUsernameHandler(UserManager<ApplicationUser> userManager)
|
||||
: IRequestHandler<UpdateUsernameCommand, UpdateUsernamePayload>
|
||||
{
|
||||
/// <exception cref="NotFoundException">Thrown when the account does not exist.</exception>
|
||||
/// <exception cref="ConflictException">Thrown when the requested username is already taken.</exception>
|
||||
public async Task<UpdateUsernamePayload> Handle(
|
||||
UpdateUsernameCommand request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
ApplicationUser user =
|
||||
await userManager.FindByIdAsync(request.UserAccountId.ToString())
|
||||
?? throw new NotFoundException("User account not found");
|
||||
|
||||
IdentityResult result = await userManager.SetUserNameAsync(user, request.NewUsername);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
if (result.Errors.Any(e => e.Code == "DuplicateUserName"))
|
||||
throw new ConflictException("Username already exists");
|
||||
throw new ConflictException(
|
||||
string.Join("; ", result.Errors.Select(e => e.Description))
|
||||
);
|
||||
}
|
||||
|
||||
return new UpdateUsernamePayload(user.Id, user.UserName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateUsername;
|
||||
|
||||
/// <summary>Validates <see cref="UpdateUsernameCommand" />, mirroring the username rules in <c>RegisterUserValidator</c>.</summary>
|
||||
public class UpdateUsernameValidator : AbstractValidator<UpdateUsernameCommand>
|
||||
{
|
||||
public UpdateUsernameValidator()
|
||||
{
|
||||
RuleFor(x => x.NewUsername)
|
||||
.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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user