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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.ConfirmUser;
|
||||
using Features.Auth.Commands.DeleteAccount;
|
||||
using Features.Auth.Commands.Login;
|
||||
using Features.Auth.Commands.RefreshToken;
|
||||
using Features.Auth.Commands.RegisterUser;
|
||||
using Features.Auth.Commands.ResendConfirmationEmail;
|
||||
using Features.Auth.Commands.UpdateEmail;
|
||||
using Features.Auth.Commands.UpdatePassword;
|
||||
using Features.Auth.Commands.UpdateProfile;
|
||||
using Features.Auth.Commands.UpdateUsername;
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Shared.Contracts;
|
||||
|
||||
namespace Features.Auth.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Handles user authentication concerns: registration, login, email confirmation, token refresh, and
|
||||
/// self-service account management.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default, but most
|
||||
/// actions opt out via <c>[AllowAnonymous]</c> since they are entry points used before a caller holds a token.
|
||||
/// </remarks>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(AuthenticationSchemes = "JWT")]
|
||||
public class AuthController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts the authenticated caller's user ID from the validated access token's <c>sub</c> claim.
|
||||
/// </summary>
|
||||
/// <exception cref="UnauthorizedException">Thrown when the claim is missing or malformed.</exception>
|
||||
private Guid GetAuthenticatedUserId()
|
||||
{
|
||||
string? userIdClaim = User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
|
||||
if (!Guid.TryParse(userIdClaim, out Guid userId))
|
||||
throw new UnauthorizedException("Access token is missing a valid user ID claim");
|
||||
return userId;
|
||||
}
|
||||
|
||||
/// <summary>Registers a new user account.</summary>
|
||||
/// <remarks>
|
||||
/// Anonymous access. Returns 201 Created with the new account's ID, username, and issued tokens.
|
||||
/// Returns 409 Conflict if the username or email is already taken.
|
||||
/// </remarks>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("register")]
|
||||
public async Task<ActionResult<ResponseBody<RegistrationPayload>>> Register(
|
||||
[FromBody] RegisterUserCommand command
|
||||
)
|
||||
{
|
||||
RegistrationPayload payload = await mediator.Send(command);
|
||||
return Created(
|
||||
"/",
|
||||
new ResponseBody<RegistrationPayload>
|
||||
{
|
||||
Message = "User registered successfully.",
|
||||
Payload = payload,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Authenticates a user by username and password and issues a new access/refresh token pair.</summary>
|
||||
/// <remarks>
|
||||
/// Anonymous access. Returns 200 OK with the tokens, or 401 Unauthorized if the credentials are invalid.
|
||||
/// </remarks>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("login")]
|
||||
public async Task<ActionResult<ResponseBody<LoginPayload>>> Login(
|
||||
[FromBody] LoginCommand command
|
||||
)
|
||||
{
|
||||
LoginPayload payload = await mediator.Send(command);
|
||||
return Ok(
|
||||
new ResponseBody<LoginPayload>
|
||||
{
|
||||
Message = "Logged in successfully.",
|
||||
Payload = payload,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Confirms a user account using a confirmation token.</summary>
|
||||
/// <remarks>
|
||||
/// Requires a valid JWT bearer token (no <c>[AllowAnonymous]</c> override). Returns 200 OK on success,
|
||||
/// or 401 Unauthorized if <paramref name="token" /> is invalid or expired.
|
||||
/// </remarks>
|
||||
[HttpPost("confirm")]
|
||||
public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm(
|
||||
[FromQuery] string token
|
||||
)
|
||||
{
|
||||
ConfirmationPayload payload = await mediator.Send(new ConfirmUserCommand(token));
|
||||
return Ok(
|
||||
new ResponseBody<ConfirmationPayload>
|
||||
{
|
||||
Message = "User with ID " + payload.UserAccountId + " is confirmed.",
|
||||
Payload = payload,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Resends the account confirmation email for the given user, generating a fresh confirmation token.</summary>
|
||||
/// <remarks>
|
||||
/// Requires a valid JWT bearer token (no <c>[AllowAnonymous]</c> override). Always returns 200 OK, even
|
||||
/// if <paramref name="userId" /> does not exist or the account is already confirmed, to avoid user
|
||||
/// enumeration.
|
||||
/// </remarks>
|
||||
[HttpPost("confirm/resend")]
|
||||
public async Task<ActionResult<ResponseBody>> ResendConfirmation([FromQuery] Guid userId)
|
||||
{
|
||||
await mediator.Send(new ResendConfirmationEmailCommand(userId));
|
||||
return Ok(new ResponseBody { Message = "confirmation email has been resent" });
|
||||
}
|
||||
|
||||
/// <summary>Exchanges a valid refresh token for a new access/refresh token pair.</summary>
|
||||
/// <remarks>
|
||||
/// Anonymous access. The refresh token is supplied via the <c>X-Refresh-Token</c> header, not the
|
||||
/// request body. Returns 200 OK with the new tokens, 400 Bad Request if the header is missing, or
|
||||
/// 401 Unauthorized if the refresh token is invalid or expired.
|
||||
/// </remarks>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("refresh")]
|
||||
public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh(
|
||||
[FromHeader(Name = "X-Refresh-Token")] string? refreshToken
|
||||
)
|
||||
{
|
||||
LoginPayload payload = await mediator.Send(
|
||||
new RefreshTokenCommand(refreshToken ?? string.Empty)
|
||||
);
|
||||
return Ok(
|
||||
new ResponseBody<LoginPayload>
|
||||
{
|
||||
Message = "Token refreshed successfully.",
|
||||
Payload = payload,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Changes the authenticated user's username.</summary>
|
||||
/// <remarks>Returns 200 OK with the updated username, or 409 Conflict if it is already taken.</remarks>
|
||||
[HttpPatch("username")]
|
||||
public async Task<ActionResult<ResponseBody<UpdateUsernamePayload>>> UpdateUsername(
|
||||
[FromBody] UpdateUsernameRequest request
|
||||
)
|
||||
{
|
||||
UpdateUsernamePayload payload = await mediator.Send(
|
||||
new UpdateUsernameCommand(GetAuthenticatedUserId(), request.NewUsername)
|
||||
);
|
||||
return Ok(
|
||||
new ResponseBody<UpdateUsernamePayload>
|
||||
{
|
||||
Message = "Username updated successfully.",
|
||||
Payload = payload,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Changes the authenticated user's email address.</summary>
|
||||
/// <remarks>
|
||||
/// Returns 200 OK with the updated (now unconfirmed) email address, or 409 Conflict if it is
|
||||
/// already in use. The caller should use <c>POST /api/auth/confirm/resend</c> to re-confirm.
|
||||
/// </remarks>
|
||||
[HttpPatch("email")]
|
||||
public async Task<ActionResult<ResponseBody<UpdateEmailPayload>>> UpdateEmail(
|
||||
[FromBody] UpdateEmailRequest request
|
||||
)
|
||||
{
|
||||
UpdateEmailPayload payload = await mediator.Send(
|
||||
new UpdateEmailCommand(GetAuthenticatedUserId(), request.NewEmail)
|
||||
);
|
||||
return Ok(
|
||||
new ResponseBody<UpdateEmailPayload>
|
||||
{
|
||||
Message = "Email updated successfully.",
|
||||
Payload = payload,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Changes the authenticated user's password.</summary>
|
||||
/// <remarks>
|
||||
/// Returns 200 OK on success, or 401 Unauthorized if <c>CurrentPassword</c> does not match the
|
||||
/// account's current password.
|
||||
/// </remarks>
|
||||
[HttpPatch("password")]
|
||||
public async Task<ActionResult<ResponseBody<UpdatePasswordPayload>>> UpdatePassword(
|
||||
[FromBody] UpdatePasswordRequest request
|
||||
)
|
||||
{
|
||||
UpdatePasswordPayload payload = await mediator.Send(
|
||||
new UpdatePasswordCommand(
|
||||
GetAuthenticatedUserId(),
|
||||
request.CurrentPassword,
|
||||
request.NewPassword
|
||||
)
|
||||
);
|
||||
return Ok(
|
||||
new ResponseBody<UpdatePasswordPayload>
|
||||
{
|
||||
Message = "Password updated successfully.",
|
||||
Payload = payload,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Updates the authenticated user's profile fields (first name, last name, date of birth).</summary>
|
||||
/// <remarks>Returns 200 OK with the updated profile.</remarks>
|
||||
[HttpPatch("profile")]
|
||||
public async Task<ActionResult<ResponseBody<UpdateProfilePayload>>> UpdateProfile(
|
||||
[FromBody] UpdateProfileRequest request
|
||||
)
|
||||
{
|
||||
UpdateProfilePayload payload = await mediator.Send(
|
||||
new UpdateProfileCommand(
|
||||
GetAuthenticatedUserId(),
|
||||
request.FirstName,
|
||||
request.LastName,
|
||||
request.DateOfBirth
|
||||
)
|
||||
);
|
||||
return Ok(
|
||||
new ResponseBody<UpdateProfilePayload>
|
||||
{
|
||||
Message = "Profile updated successfully.",
|
||||
Payload = payload,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Permanently deletes the authenticated user's account.</summary>
|
||||
/// <remarks>
|
||||
/// Returns 200 OK on success, or 409 Conflict if the account still has associated posts, comments,
|
||||
/// photos, or follows.
|
||||
/// </remarks>
|
||||
[HttpDelete("account")]
|
||||
public async Task<ActionResult<ResponseBody>> DeleteAccount()
|
||||
{
|
||||
await mediator.Send(new DeleteAccountCommand(GetAuthenticatedUserId()));
|
||||
return Ok(new ResponseBody { Message = "Account deleted successfully." });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Queries.GetAllUsers;
|
||||
using Features.Auth.Queries.GetUserById;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Features.Auth.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Exposes read endpoints for user accounts.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class UserController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
/// <summary>Gets a page of user accounts, ordered by creation date descending.</summary>
|
||||
/// <param name="limit"><c>null</c> for no limit.</param>
|
||||
/// <param name="offset"><c>null</c> for no offset.</param>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IEnumerable<UserAccount>>> GetAll(
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset
|
||||
)
|
||||
{
|
||||
IEnumerable<UserAccount> users = await mediator.Send(new GetAllUsersQuery(limit, offset));
|
||||
return Ok(users);
|
||||
}
|
||||
|
||||
/// <summary>Gets the user account with the given ID.</summary>
|
||||
/// <exception cref="NotFoundException">
|
||||
/// Thrown when no user account exists with the given ID, resulting in a 404 response.
|
||||
/// </exception>
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<UserAccount>> GetById(Guid id)
|
||||
{
|
||||
UserAccount user = await mediator.Send(new GetUserByIdQuery(id));
|
||||
return Ok(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Repository;
|
||||
using Features.Auth.Services;
|
||||
using Infrastructure.PasswordHashing;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Features.Auth.DependencyInjection;
|
||||
|
||||
/// <summary>Registers the services required by Features.Users with the dependency injection container.</summary>
|
||||
public static class FeaturesAuthServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds <see cref="ITokenService" />, <see cref="IUserListRepository" />, and ASP.NET Core
|
||||
/// Identity (<see cref="UserManager{TUser}" /> backed by <see cref="DapperUserStore" /> and
|
||||
/// <see cref="Argon2PasswordHasher" />) as scoped services.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddFeaturesAuth(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<ITokenService, TokenService>();
|
||||
services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
|
||||
services.AddScoped<IUserListRepository, UserListRepository>();
|
||||
|
||||
// AddDefaultTokenProviders() is deliberately not called: it registers
|
||||
// DataProtectorTokenProvider<TUser>, which UserManager's constructor resolves eagerly and which
|
||||
// requires IDataProtectionProvider -- not registered in the plain-console-app DI containers this
|
||||
// solution builds (e.g. Database.Seed). Identity's own token providers are unused here anyway;
|
||||
// account confirmation keeps using its own JWT-based tokens (see ITokenService).
|
||||
services.AddIdentityCore<ApplicationUser>(options =>
|
||||
{
|
||||
options.User.RequireUniqueEmail = true;
|
||||
|
||||
// Password/username complexity is already enforced by RegisterUserValidator
|
||||
// (FluentValidation); disable Identity's own checks to avoid duplicated/drifting rules.
|
||||
options.Password.RequireDigit = false;
|
||||
options.Password.RequireLowercase = false;
|
||||
options.Password.RequireUppercase = false;
|
||||
options.Password.RequireNonAlphanumeric = false;
|
||||
options.Password.RequiredLength = 1;
|
||||
});
|
||||
|
||||
services.AddScoped<DapperUserStore>();
|
||||
services.AddScoped<IUserStore<ApplicationUser>>(sp =>
|
||||
sp.GetRequiredService<DapperUserStore>()
|
||||
);
|
||||
services.AddScoped<IUserEmailStore<ApplicationUser>>(sp =>
|
||||
sp.GetRequiredService<DapperUserStore>()
|
||||
);
|
||||
services.AddScoped<IUserPasswordStore<ApplicationUser>>(sp =>
|
||||
sp.GetRequiredService<DapperUserStore>()
|
||||
);
|
||||
services.AddScoped<IPasswordHasher<ApplicationUser>, Argon2PasswordHasher>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
66
web/backend/Features/Features.Users/Dtos/AuthPayloads.cs
Normal file
66
web/backend/Features/Features.Users/Dtos/AuthPayloads.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
namespace Features.Auth.Dtos;
|
||||
|
||||
/// <summary>Payload returned to the client after a successful login or token refresh.</summary>
|
||||
public record LoginPayload(
|
||||
Guid UserAccountId,
|
||||
string Username,
|
||||
string RefreshToken,
|
||||
string AccessToken
|
||||
);
|
||||
|
||||
/// <summary>Payload returned to the client after a successful registration.</summary>
|
||||
public record RegistrationPayload(
|
||||
Guid UserAccountId,
|
||||
string Username,
|
||||
string RefreshToken,
|
||||
string AccessToken,
|
||||
bool ConfirmationEmailSent
|
||||
);
|
||||
|
||||
/// <summary>Payload returned to the client after a user account's email has been confirmed.</summary>
|
||||
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);
|
||||
|
||||
/// <summary>Request body for changing the authenticated user's username.</summary>
|
||||
/// <param name="NewUsername">
|
||||
/// The desired username; must be 3-64 characters and contain only letters, numbers, dots,
|
||||
/// underscores, and hyphens.
|
||||
/// </param>
|
||||
public record UpdateUsernameRequest(string NewUsername);
|
||||
|
||||
/// <summary>Payload returned to the client after a username change.</summary>
|
||||
public record UpdateUsernamePayload(Guid UserAccountId, string Username);
|
||||
|
||||
/// <summary>Request body for changing the authenticated user's email address.</summary>
|
||||
/// <param name="NewEmail">The new email address; up to 128 characters and must be a valid email format.</param>
|
||||
public record UpdateEmailRequest(string NewEmail);
|
||||
|
||||
/// <summary>
|
||||
/// Payload returned to the client after an email change. The new address is unconfirmed until the
|
||||
/// user completes the confirmation flow again.
|
||||
/// </summary>
|
||||
public record UpdateEmailPayload(Guid UserAccountId, string Email, bool EmailConfirmed);
|
||||
|
||||
/// <summary>Request body for updating the authenticated user's profile fields.</summary>
|
||||
/// <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="DateOfBirth">The user's date of birth; the user must be at least 19 years old.</param>
|
||||
public record UpdateProfileRequest(string FirstName, string LastName, DateTime DateOfBirth);
|
||||
|
||||
/// <summary>Payload returned to the client after a profile update.</summary>
|
||||
public record UpdateProfilePayload(
|
||||
Guid UserAccountId,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
DateTime DateOfBirth
|
||||
);
|
||||
|
||||
/// <summary>Request body for changing the authenticated user's password.</summary>
|
||||
/// <param name="CurrentPassword">The user's current plaintext password, verified before the change is applied.</param>
|
||||
/// <param name="NewPassword">
|
||||
/// The desired new 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 UpdatePasswordRequest(string CurrentPassword, string NewPassword);
|
||||
|
||||
/// <summary>Payload returned to the client after a password change.</summary>
|
||||
public record UpdatePasswordPayload(Guid UserAccountId, DateTime ChangedAt);
|
||||
25
web/backend/Features/Features.Users/Features.Users.csproj
Normal file
25
web/backend/Features/Features.Users/Features.Users.csproj
Normal file
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<RootNamespace>Features.Auth</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
|
||||
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
|
||||
<ProjectReference Include="..\Features.PhotoUpload\Features.PhotoUpload.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Features.Auth.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// The user type driving <see cref="Microsoft.AspNetCore.Identity.UserManager{TUser}" /> for
|
||||
/// Features.Users. A plain POCO (not <c>IdentityUser<TKey></c>) backed by <see cref="DapperUserStore" />
|
||||
/// rather than EF Core, matching the rest of the solution's Dapper-only persistence.
|
||||
/// </summary>
|
||||
public sealed class ApplicationUser
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
public string? NormalizedUserName { get; set; }
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string? NormalizedEmail { get; set; }
|
||||
public bool EmailConfirmed { get; set; }
|
||||
|
||||
/// <summary>The current Argon2 hash, or <see langword="null" /> if the account has no password set.</summary>
|
||||
public string? PasswordHash { get; set; }
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
public string LastName { get; set; } = string.Empty;
|
||||
public DateTime DateOfBirth { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Infrastructure.PasswordHashing;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Identity;
|
||||
|
||||
/// <summary>Adapts <see cref="IPasswordInfrastructure" />'s Argon2 hashing to <see cref="IPasswordHasher{TUser}" />.</summary>
|
||||
public sealed class Argon2PasswordHasher(IPasswordInfrastructure passwordInfrastructure)
|
||||
: IPasswordHasher<ApplicationUser>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string HashPassword(ApplicationUser user, string password) =>
|
||||
passwordInfrastructure.Hash(password);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Never returns <see cref="PasswordVerificationResult.SuccessRehashNeeded" />: Argon2 parameters are
|
||||
/// fixed by <see cref="IPasswordInfrastructure" />, so <see cref="UserManager{TUser}" />'s automatic
|
||||
/// rehash-on-verify path never triggers.
|
||||
/// </remarks>
|
||||
public PasswordVerificationResult VerifyHashedPassword(
|
||||
ApplicationUser user,
|
||||
string hashedPassword,
|
||||
string providedPassword
|
||||
) =>
|
||||
passwordInfrastructure.Verify(providedPassword, hashedPassword)
|
||||
? PasswordVerificationResult.Success
|
||||
: PasswordVerificationResult.Failed;
|
||||
}
|
||||
511
web/backend/Features/Features.Users/Identity/DapperUserStore.cs
Normal file
511
web/backend/Features/Features.Users/Identity/DapperUserStore.cs
Normal file
@@ -0,0 +1,511 @@
|
||||
using System.Data.Common;
|
||||
using Dapper;
|
||||
using Database.Connection;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace Features.Auth.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Dapper-based implementation of the ASP.NET Core Identity store interfaces consumed by
|
||||
/// <see cref="UserManager{TUser}" />, persisting against the existing <c>dbo.UserAccount</c>,
|
||||
/// <c>dbo.UserCredential</c>, and <c>dbo.UserVerification</c> tables. No EF Core is used, and no schema
|
||||
/// changes were required: normalized name/email are computed on the fly rather than persisted (the
|
||||
/// existing unique indexes are already case-insensitive under SQL Server's default collation).
|
||||
/// </summary>
|
||||
public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
|
||||
: DapperRepository(connectionFactory),
|
||||
IUserStore<ApplicationUser>,
|
||||
IUserPasswordStore<ApplicationUser>,
|
||||
IUserEmailStore<ApplicationUser>
|
||||
{
|
||||
private const string SelectUserSql = """
|
||||
SELECT UserAccountID AS Id, Username AS UserName, FirstName, LastName, Email, DateOfBirth
|
||||
FROM dbo.UserAccount
|
||||
""";
|
||||
|
||||
/// <remarks>No unmanaged resources to release; each method opens and disposes its own connection.</remarks>
|
||||
public void Dispose() { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string> GetUserIdAsync(ApplicationUser user, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(user.Id.ToString());
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetUserNameAsync(
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken
|
||||
) => Task.FromResult<string?>(user.UserName);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetUserNameAsync(
|
||||
ApplicationUser user,
|
||||
string? userName,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
user.UserName = userName ?? string.Empty;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetNormalizedUserNameAsync(
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken
|
||||
) => Task.FromResult(user.NormalizedUserName);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetNormalizedUserNameAsync(
|
||||
ApplicationUser user,
|
||||
string? normalizedName,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
user.NormalizedUserName = normalizedName;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Inserts the account and its initial credential within a single database transaction, mirroring the
|
||||
/// previous <c>AuthRepository.RegisterUserAsync</c> behavior. <see cref="UserManager{TUser}" /> already
|
||||
/// checks username/email uniqueness before calling this method, so the duplicate-key branch here only
|
||||
/// guards against a genuine race between two concurrent registrations.
|
||||
/// </remarks>
|
||||
public async Task<IdentityResult> CreateAsync(
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbTransaction transaction = await connection.BeginTransactionAsync(
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
try
|
||||
{
|
||||
Guid userAccountId = await connection.ExecuteScalarAsync<Guid>(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
INSERT INTO dbo.UserAccount (Username, FirstName, LastName, DateOfBirth, Email)
|
||||
OUTPUT INSERTED.UserAccountID
|
||||
VALUES (@UserName, @FirstName, @LastName, @DateOfBirth, @Email);
|
||||
""",
|
||||
new
|
||||
{
|
||||
user.UserName,
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.DateOfBirth,
|
||||
user.Email,
|
||||
},
|
||||
transaction,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
INSERT INTO dbo.UserCredential (UserAccountId, Hash)
|
||||
VALUES (@UserAccountId, @Hash);
|
||||
""",
|
||||
new { UserAccountId = userAccountId, Hash = user.PasswordHash },
|
||||
transaction,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
user.Id = userAccountId;
|
||||
}
|
||||
catch (SqlException ex) when (IsDuplicateKeyViolation(ex))
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return IdentityResult.Failed(
|
||||
new IdentityError
|
||||
{
|
||||
Code = "DuplicateUserName",
|
||||
Description = "Username or email already exists.",
|
||||
}
|
||||
);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
|
||||
return IdentityResult.Success;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Persists profile field changes and keeps <c>dbo.UserVerification</c> in sync with
|
||||
/// <see cref="ApplicationUser.EmailConfirmed" />: a row is inserted (idempotently, matching the
|
||||
/// previous <c>AuthRepository.ConfirmUserAccountAsync</c> behavior) when
|
||||
/// <see langword="true" />, and removed when <see langword="false" /> -- notably after
|
||||
/// <see cref="UserManager{TUser}.SetEmailAsync" /> resets confirmation on an email change.
|
||||
/// </remarks>
|
||||
public async Task<IdentityResult> UpdateAsync(
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
UPDATE dbo.UserAccount
|
||||
SET Username = @UserName, FirstName = @FirstName, LastName = @LastName,
|
||||
Email = @Email, DateOfBirth = @DateOfBirth, UpdatedAt = GETDATE()
|
||||
WHERE UserAccountID = @Id
|
||||
""",
|
||||
new
|
||||
{
|
||||
user.Id,
|
||||
user.UserName,
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.Email,
|
||||
user.DateOfBirth,
|
||||
},
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
if (user.EmailConfirmed)
|
||||
await EnsureVerificationRowAsync(connection, user.Id, cancellationToken);
|
||||
else
|
||||
await RemoveVerificationRowAsync(connection, user.Id, cancellationToken);
|
||||
|
||||
return IdentityResult.Success;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// A plain <c>DELETE</c> against <c>dbo.UserAccount</c>: its foreign keys from
|
||||
/// <c>dbo.UserCredential</c>, <c>dbo.UserVerification</c>, and <c>dbo.UserProfile</c> cascade
|
||||
/// (<c>dbo.UserAvatar</c> cascades transitively through <c>dbo.UserProfile</c>), so no manual
|
||||
/// child cleanup is needed. Other tables (posts, comments, photos, follows) reference the
|
||||
/// account with <c>ON DELETE NO ACTION</c> by design; deleting an account that still has any of
|
||||
/// those fails with a foreign key violation, which is surfaced as a failed
|
||||
/// <see cref="IdentityResult" /> rather than an unhandled <see cref="SqlException" />.
|
||||
/// </remarks>
|
||||
public async Task<IdentityResult> DeleteAsync(
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
|
||||
try
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"DELETE FROM dbo.UserAccount WHERE UserAccountID = @Id",
|
||||
new { user.Id },
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
}
|
||||
catch (SqlException ex) when (IsForeignKeyViolation(ex))
|
||||
{
|
||||
return IdentityResult.Failed(
|
||||
new IdentityError
|
||||
{
|
||||
Code = "AccountHasDependentRecords",
|
||||
Description =
|
||||
"Account cannot be deleted because other records still reference it.",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return IdentityResult.Success;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApplicationUser?> FindByIdAsync(
|
||||
string userId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (!Guid.TryParse(userId, out Guid id))
|
||||
return null;
|
||||
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
ApplicationUser? user = await connection.QueryFirstOrDefaultAsync<ApplicationUser>(
|
||||
new CommandDefinition(
|
||||
$"{SelectUserSql} WHERE UserAccountID = @Id",
|
||||
new { Id = id },
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
if (user != null)
|
||||
await HydrateAsync(connection, user, cancellationToken);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApplicationUser?> FindByNameAsync(
|
||||
string normalizedUserName,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
ApplicationUser? user = await connection.QueryFirstOrDefaultAsync<ApplicationUser>(
|
||||
new CommandDefinition(
|
||||
$"{SelectUserSql} WHERE Username = @UserName",
|
||||
new { UserName = normalizedUserName },
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
if (user != null)
|
||||
await HydrateAsync(connection, user, cancellationToken);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// For an existing account (<see cref="ApplicationUser.Id" /> already assigned), rotates the
|
||||
/// credential transactionally -- revoking the previous <c>dbo.UserCredential</c> row(s) and inserting
|
||||
/// the new hash -- mirroring the previous <c>AuthRepository.RotateCredentialAsync</c> behavior. During
|
||||
/// registration <see cref="UserManager{TUser}" /> calls this before the account has an ID (i.e. before
|
||||
/// <see cref="CreateAsync" /> runs); in that case only the in-memory hash is set, and
|
||||
/// <see cref="CreateAsync" /> writes the initial credential itself as part of its own transaction.
|
||||
/// </remarks>
|
||||
public async Task SetPasswordHashAsync(
|
||||
ApplicationUser user,
|
||||
string? passwordHash,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
string? previousHash = user.PasswordHash;
|
||||
user.PasswordHash = passwordHash;
|
||||
|
||||
if (user.Id == Guid.Empty || passwordHash == null || passwordHash == previousHash)
|
||||
return;
|
||||
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbTransaction transaction = await connection.BeginTransactionAsync(
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
try
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
UPDATE dbo.UserCredential
|
||||
SET IsRevoked = 1, RevokedAt = GETDATE()
|
||||
WHERE UserAccountId = @UserAccountId AND IsRevoked = 0
|
||||
""",
|
||||
new { UserAccountId = user.Id },
|
||||
transaction,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
INSERT INTO dbo.UserCredential (UserAccountId, Hash)
|
||||
VALUES (@UserAccountId, @Hash);
|
||||
""",
|
||||
new { UserAccountId = user.Id, Hash = passwordHash },
|
||||
transaction,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetPasswordHashAsync(
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken
|
||||
) => Task.FromResult(user.PasswordHash);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> HasPasswordAsync(ApplicationUser user, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(user.PasswordHash != null);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetEmailAsync(
|
||||
ApplicationUser user,
|
||||
string? email,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
user.Email = email ?? string.Empty;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetEmailAsync(ApplicationUser user, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<string?>(user.Email);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> GetEmailConfirmedAsync(
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken
|
||||
) => Task.FromResult(user.EmailConfirmed);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetEmailConfirmedAsync(
|
||||
ApplicationUser user,
|
||||
bool confirmed,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
user.EmailConfirmed = confirmed;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApplicationUser?> FindByEmailAsync(
|
||||
string normalizedEmail,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
ApplicationUser? user = await connection.QueryFirstOrDefaultAsync<ApplicationUser>(
|
||||
new CommandDefinition(
|
||||
$"{SelectUserSql} WHERE Email = @Email",
|
||||
new { Email = normalizedEmail },
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
if (user != null)
|
||||
await HydrateAsync(connection, user, cancellationToken);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetNormalizedEmailAsync(
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken
|
||||
) => Task.FromResult(user.NormalizedEmail);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetNormalizedEmailAsync(
|
||||
ApplicationUser user,
|
||||
string? normalizedEmail,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
user.NormalizedEmail = normalizedEmail;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Loads the active credential hash and verification status onto a freshly queried user.</summary>
|
||||
private static async Task HydrateAsync(
|
||||
DbConnection connection,
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
user.PasswordHash = await connection.ExecuteScalarAsync<string?>(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
SELECT Hash
|
||||
FROM dbo.UserCredential
|
||||
WHERE UserAccountId = @UserAccountId AND IsRevoked = 0
|
||||
""",
|
||||
new { UserAccountId = user.Id },
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
int? verified = await connection.ExecuteScalarAsync<int?>(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
SELECT TOP 1 1
|
||||
FROM dbo.UserVerification
|
||||
WHERE UserAccountID = @UserAccountId
|
||||
""",
|
||||
new { UserAccountId = user.Id },
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
user.EmailConfirmed = verified.HasValue;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// If a concurrent request verifies the same user first, the resulting duplicate-key SQL exception
|
||||
/// (error 2601/2627) is swallowed to keep the operation idempotent.
|
||||
/// </remarks>
|
||||
private static async Task EnsureVerificationRowAsync(
|
||||
DbConnection connection,
|
||||
Guid userAccountId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
int? alreadyVerified = await connection.ExecuteScalarAsync<int?>(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
SELECT TOP 1 1
|
||||
FROM dbo.UserVerification
|
||||
WHERE UserAccountID = @UserAccountId
|
||||
""",
|
||||
new { UserAccountId = userAccountId },
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
if (alreadyVerified.HasValue)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
INSERT INTO dbo.UserVerification (UserAccountId, VerificationDateTime)
|
||||
VALUES (@UserAccountId, GETDATE())
|
||||
""",
|
||||
new { UserAccountId = userAccountId },
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
}
|
||||
catch (SqlException ex) when (IsDuplicateKeyViolation(ex))
|
||||
{
|
||||
// A concurrent request verified this user first. Keep behavior idempotent.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes the <c>dbo.UserVerification</c> row for a user, if one exists.</summary>
|
||||
private static Task RemoveVerificationRowAsync(
|
||||
DbConnection connection,
|
||||
Guid userAccountId,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"DELETE FROM dbo.UserVerification WHERE UserAccountID = @UserAccountId",
|
||||
new { UserAccountId = userAccountId },
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
/// <summary>Determines whether a <see cref="SqlException" /> is a duplicate key violation (SQL Server error 2601 or 2627).</summary>
|
||||
private static bool IsDuplicateKeyViolation(SqlException ex) => ex.Number is 2601 or 2627;
|
||||
|
||||
/// <summary>Determines whether a <see cref="SqlException" /> is a foreign key constraint violation (SQL Server error 547).</summary>
|
||||
private static bool IsForeignKeyViolation(SqlException ex) => ex.Number == 547;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Features.Auth.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Converts the persistence-agnostic <see cref="UserAccount" /> domain entity into the
|
||||
/// <see cref="ApplicationUser" /> shape required by <see cref="Microsoft.AspNetCore.Identity.UserManager{TUser}" />.
|
||||
/// Kept here, in Features.Users, rather than on <see cref="UserAccount" /> itself so the domain layer has
|
||||
/// no dependency on ASP.NET Core Identity.
|
||||
/// </summary>
|
||||
public static class UserAccountExtensions
|
||||
{
|
||||
public static ApplicationUser ToApplicationUser(this UserAccount userAccount) =>
|
||||
new()
|
||||
{
|
||||
Id = userAccount.UserAccountId,
|
||||
UserName = userAccount.Username,
|
||||
Email = userAccount.Email,
|
||||
FirstName = userAccount.FirstName,
|
||||
LastName = userAccount.LastName,
|
||||
DateOfBirth = userAccount.DateOfBirth,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Domain.Entities;
|
||||
using Features.Auth.Repository;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Queries.GetAllUsers;
|
||||
|
||||
public class GetAllUsersHandler(IUserListRepository repository)
|
||||
: IRequestHandler<GetAllUsersQuery, IEnumerable<UserAccount>>
|
||||
{
|
||||
public Task<IEnumerable<UserAccount>> Handle(
|
||||
GetAllUsersQuery request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
return repository.GetAllAsync(request.Limit, request.Offset);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Domain.Entities;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Queries.GetAllUsers;
|
||||
|
||||
/// <summary>
|
||||
/// Requests a page of user accounts, ordered by creation date descending.
|
||||
/// </summary>
|
||||
/// <param name="Limit"><c>null</c> for no limit.</param>
|
||||
/// <param name="Offset"><c>null</c> for no offset.</param>
|
||||
public record GetAllUsersQuery(int? Limit, int? Offset) : IRequest<IEnumerable<UserAccount>>;
|
||||
@@ -0,0 +1,22 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Repository;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Queries.GetUserById;
|
||||
|
||||
public class GetUserByIdHandler(IUserListRepository repository)
|
||||
: IRequestHandler<GetUserByIdQuery, UserAccount>
|
||||
{
|
||||
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
|
||||
public async Task<UserAccount> Handle(
|
||||
GetUserByIdQuery request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
UserAccount? user = await repository.GetByIdAsync(request.UserAccountId);
|
||||
if (user is null)
|
||||
throw new NotFoundException($"User with ID {request.UserAccountId} not found");
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using Domain.Entities;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Queries.GetUserById;
|
||||
|
||||
public record GetUserByIdQuery(Guid UserAccountId) : IRequest<UserAccount>;
|
||||
@@ -0,0 +1,21 @@
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Features.Auth.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// Read access for user account listing. Everything else on user accounts (lookup by
|
||||
/// username/email, create/update/delete) already goes through
|
||||
/// <see cref="Microsoft.AspNetCore.Identity.UserManager{TUser}" />; this repository only covers
|
||||
/// what <c>IUserStore</c> has no equivalent for: fetching by ID with full audit fields, and
|
||||
/// paginated listing.
|
||||
/// </summary>
|
||||
public interface IUserListRepository
|
||||
{
|
||||
/// <summary>Returns <c>null</c> if no user account exists with the given ID.</summary>
|
||||
Task<UserAccount?> GetByIdAsync(Guid id);
|
||||
|
||||
/// <summary>Returns user accounts ordered by creation date descending.</summary>
|
||||
/// <param name="limit"><c>null</c> for no limit.</param>
|
||||
/// <param name="offset"><c>null</c> for no offset.</param>
|
||||
Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Data.Common;
|
||||
using Dapper;
|
||||
using Database.Connection;
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Features.Auth.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// Dapper-based implementation of <see cref="IUserListRepository" />.
|
||||
/// </summary>
|
||||
public class UserListRepository(ISqlConnectionFactory connectionFactory)
|
||||
: DapperRepository(connectionFactory),
|
||||
IUserListRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<UserAccount?> GetByIdAsync(Guid id)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.QueryFirstOrDefaultAsync<UserAccount>(
|
||||
"""
|
||||
SELECT
|
||||
UserAccountID,
|
||||
Username,
|
||||
FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
|
||||
FROM dbo.UserAccount
|
||||
WHERE UserAccountID = @UserAccountId
|
||||
""",
|
||||
new { UserAccountId = id }
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Always applies pagination via <c>OFFSET</c>/<c>FETCH</c>, ordered by creation date descending.
|
||||
/// A <c>null</c> <paramref name="limit" />/<paramref name="offset" /> is treated as "no limit"/"no offset".
|
||||
/// </remarks>
|
||||
public async Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.QueryAsync<UserAccount>(
|
||||
"""
|
||||
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
|
||||
FROM dbo.UserAccount
|
||||
ORDER BY CreatedAt DESC
|
||||
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY
|
||||
""",
|
||||
new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Features.Auth.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the kind of token being generated or validated.
|
||||
/// </summary>
|
||||
public enum TokenType
|
||||
{
|
||||
/// <summary>A short-lived token used to authorize API requests.</summary>
|
||||
AccessToken,
|
||||
|
||||
/// <summary>A long-lived token used to obtain new access tokens.</summary>
|
||||
RefreshToken,
|
||||
|
||||
/// <summary>A short-lived token used to confirm a user's email/account.</summary>
|
||||
ConfirmationToken,
|
||||
}
|
||||
|
||||
/// <summary>Represents the result of successfully validating a token.</summary>
|
||||
public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal);
|
||||
|
||||
/// <summary>Represents the result of refreshing a user's session.</summary>
|
||||
public record RefreshTokenResult(
|
||||
Guid UserId,
|
||||
string Username,
|
||||
string RefreshToken,
|
||||
string AccessToken
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Defines the expiration windows, in hours, for each type of token issued by <see cref="ITokenService" />.
|
||||
/// </summary>
|
||||
public static class TokenServiceExpirationHours
|
||||
{
|
||||
/// <summary>1 hour.</summary>
|
||||
public const double AccessTokenHours = 1;
|
||||
|
||||
/// <summary>21 days.</summary>
|
||||
public const double RefreshTokenHours = 504;
|
||||
|
||||
/// <summary>30 minutes.</summary>
|
||||
public const double ConfirmationTokenHours = 0.5;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines operations for generating and validating JWTs used for access, refresh, and account confirmation.
|
||||
/// </summary>
|
||||
public interface ITokenService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a signed access token for the given user, expiring after
|
||||
/// <see cref="TokenServiceExpirationHours.AccessTokenHours" /> hours.
|
||||
/// </summary>
|
||||
string GenerateAccessToken(Guid userId, string username);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a signed refresh token for the given user, expiring after
|
||||
/// <see cref="TokenServiceExpirationHours.RefreshTokenHours" /> hours.
|
||||
/// </summary>
|
||||
string GenerateRefreshToken(Guid userId, string username);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a signed confirmation token for the given user, expiring after
|
||||
/// <see cref="TokenServiceExpirationHours.ConfirmationTokenHours" /> hours.
|
||||
/// </summary>
|
||||
string GenerateConfirmationToken(Guid userId, string username);
|
||||
|
||||
/// <summary>Validates a refresh token's signature and expiration and extracts the caller's identity.</summary>
|
||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||
/// </exception>
|
||||
Task<ValidatedToken> ValidateRefreshTokenAsync(string token);
|
||||
|
||||
/// <summary>Validates a confirmation token's signature and expiration and extracts the caller's identity.</summary>
|
||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||
/// </exception>
|
||||
Task<ValidatedToken> ValidateConfirmationTokenAsync(string token);
|
||||
|
||||
/// <summary>Validates a refresh token and issues a new access/refresh token pair for the associated user.</summary>
|
||||
/// <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>
|
||||
Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString);
|
||||
}
|
||||
150
web/backend/Features/Features.Users/Services/TokenService.cs
Normal file
150
web/backend/Features/Features.Users/Services/TokenService.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Identity;
|
||||
using Infrastructure.Configuration;
|
||||
using Infrastructure.Jwt;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Features.Auth.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="ITokenService" /> that generates and validates JWTs
|
||||
/// for access, refresh, and confirmation flows using secrets read from configuration.
|
||||
/// </summary>
|
||||
public class TokenService : ITokenService
|
||||
{
|
||||
private readonly string _accessTokenSecret;
|
||||
private readonly string _confirmationTokenSecret;
|
||||
private readonly string _refreshTokenSecret;
|
||||
private readonly ITokenInfrastructure _tokenInfrastructure;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when any of the <c>ACCESS_TOKEN_SECRET</c>, <c>REFRESH_TOKEN_SECRET</c>, or
|
||||
/// <c>CONFIRMATION_TOKEN_SECRET</c> configuration values are not set.
|
||||
/// </exception>
|
||||
public TokenService(
|
||||
ITokenInfrastructure tokenInfrastructure,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IConfiguration configuration
|
||||
)
|
||||
{
|
||||
_tokenInfrastructure = tokenInfrastructure;
|
||||
_userManager = userManager;
|
||||
|
||||
_accessTokenSecret = ConfigurationHelpers.GetKeyOrThrow(
|
||||
configuration,
|
||||
ConfigurationKeys.AccessTokenSecret
|
||||
);
|
||||
|
||||
_refreshTokenSecret = ConfigurationHelpers.GetKeyOrThrow(
|
||||
configuration,
|
||||
ConfigurationKeys.RefreshTokenSecret
|
||||
);
|
||||
|
||||
_confirmationTokenSecret = ConfigurationHelpers.GetKeyOrThrow(
|
||||
configuration,
|
||||
ConfigurationKeys.ConfirmationTokenSecret
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GenerateAccessToken(Guid userId, string username)
|
||||
{
|
||||
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
|
||||
return _tokenInfrastructure.GenerateJwt(userId, username, expiresAt, _accessTokenSecret);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GenerateRefreshToken(Guid userId, string username)
|
||||
{
|
||||
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
||||
TokenServiceExpirationHours.RefreshTokenHours
|
||||
);
|
||||
return _tokenInfrastructure.GenerateJwt(userId, username, expiresAt, _refreshTokenSecret);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GenerateConfirmationToken(Guid userId, string username)
|
||||
{
|
||||
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
||||
TokenServiceExpirationHours.ConfirmationTokenHours
|
||||
);
|
||||
return _tokenInfrastructure.GenerateJwt(
|
||||
userId,
|
||||
username,
|
||||
expiresAt,
|
||||
_confirmationTokenSecret
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ValidatedToken> ValidateRefreshTokenAsync(string token)
|
||||
{
|
||||
return await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ValidatedToken> ValidateConfirmationTokenAsync(string token)
|
||||
{
|
||||
return await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
|
||||
{
|
||||
ValidatedToken validated = await ValidateRefreshTokenAsync(refreshTokenString);
|
||||
ApplicationUser? user = await _userManager.FindByIdAsync(validated.UserId.ToString());
|
||||
if (user == null)
|
||||
throw new UnauthorizedException("User account not found");
|
||||
|
||||
string newAccess = GenerateAccessToken(user.Id, user.UserName);
|
||||
string newRefresh = GenerateRefreshToken(user.Id, user.UserName);
|
||||
|
||||
return new RefreshTokenResult(user.Id, user.UserName, newRefresh, newAccess);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs the shared validation logic for access, refresh, and confirmation tokens:
|
||||
/// validates the JWT signature/expiration, then extracts and parses the user ID and username claims.
|
||||
/// </summary>
|
||||
/// <param name="tokenType">A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages.</param>
|
||||
/// <exception cref="UnauthorizedException">
|
||||
/// Thrown when required claims are missing, the user ID claim is not a valid <see cref="Guid" />,
|
||||
/// or the underlying token validation fails for any other reason (e.g. expired or invalid signature).
|
||||
/// </exception>
|
||||
private async Task<ValidatedToken> ValidateTokenInternalAsync(
|
||||
string token,
|
||||
string secret,
|
||||
string tokenType
|
||||
)
|
||||
{
|
||||
try
|
||||
{
|
||||
ClaimsPrincipal principal = await _tokenInfrastructure.ValidateJwtAsync(token, secret);
|
||||
|
||||
string? userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
|
||||
string? usernameClaim = principal.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(userIdClaim) || string.IsNullOrEmpty(usernameClaim))
|
||||
throw new UnauthorizedException(
|
||||
$"Invalid {tokenType} token: missing required claims"
|
||||
);
|
||||
|
||||
if (!Guid.TryParse(userIdClaim, out Guid userId))
|
||||
throw new UnauthorizedException($"Invalid {tokenType} token: malformed user ID");
|
||||
|
||||
return new ValidatedToken(userId, usernameClaim, principal);
|
||||
}
|
||||
catch (UnauthorizedException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new UnauthorizedException($"Failed to validate {tokenType} token: {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user