mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-07-16 17:47:22 +00:00
code style cleanup
This commit is contained in:
@@ -4,7 +4,7 @@ using MediatR;
|
||||
namespace Features.Auth.Commands.ConfirmUser;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a confirmation token and confirms the corresponding user account.
|
||||
/// Validates a confirmation token and confirms the corresponding user account.
|
||||
/// </summary>
|
||||
/// <param name="Token">The confirmation token issued to the user, typically delivered via email.</param>
|
||||
public record ConfirmUserCommand(string Token) : IRequest<ConfirmationPayload>;
|
||||
public record ConfirmUserCommand(string Token) : IRequest<ConfirmationPayload>;
|
||||
@@ -1,3 +1,4 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Repository;
|
||||
@@ -7,8 +8,8 @@ using MediatR;
|
||||
namespace Features.Auth.Commands.ConfirmUser;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="ConfirmUserCommand"/> by validating the confirmation token and marking the
|
||||
/// corresponding user account as confirmed.
|
||||
/// Handles <see cref="ConfirmUserCommand" /> by validating the confirmation token and marking the
|
||||
/// corresponding user account as confirmed.
|
||||
/// </summary>
|
||||
/// <param name="authRepository">Repository used to look up and confirm user accounts.</param>
|
||||
/// <param name="tokenService">Service used to validate the confirmation token.</param>
|
||||
@@ -18,19 +19,16 @@ public class ConfirmUserHandler(
|
||||
) : IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
|
||||
{
|
||||
/// <exception cref="UnauthorizedException">
|
||||
/// Thrown when the confirmation token is invalid or expired, or when the associated user account cannot be found.
|
||||
/// Thrown when the confirmation token is invalid or expired, or when the associated user account cannot be found.
|
||||
/// </exception>
|
||||
public async Task<ConfirmationPayload> Handle(ConfirmUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var validatedToken = await tokenService.ValidateConfirmationTokenAsync(request.Token);
|
||||
ValidatedToken validatedToken = await tokenService.ValidateConfirmationTokenAsync(request.Token);
|
||||
|
||||
var user = await authRepository.ConfirmUserAccountAsync(validatedToken.UserId);
|
||||
UserAccount? user = await authRepository.ConfirmUserAccountAsync(validatedToken.UserId);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
throw new UnauthorizedException("User account not found");
|
||||
}
|
||||
if (user == null) throw new UnauthorizedException("User account not found");
|
||||
|
||||
return new ConfirmationPayload(user.UserAccountId, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ using MediatR;
|
||||
namespace Features.Auth.Commands.RefreshToken;
|
||||
|
||||
/// <summary>
|
||||
/// Exchanges a valid refresh token for a new access/refresh token pair. Bound directly from the
|
||||
/// request body of <c>POST /api/auth/refresh</c>.
|
||||
/// Exchanges a valid refresh token for a new access/refresh token pair. Bound directly from the
|
||||
/// request body of <c>POST /api/auth/refresh</c>.
|
||||
/// </summary>
|
||||
public record RefreshTokenCommand(string RefreshToken) : IRequest<LoginPayload>;
|
||||
public record RefreshTokenCommand(string RefreshToken) : IRequest<LoginPayload>;
|
||||
@@ -5,8 +5,8 @@ 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.
|
||||
/// Handles <see cref="RefreshTokenCommand" /> by validating the refresh token and issuing a new
|
||||
/// access/refresh token pair.
|
||||
/// </summary>
|
||||
/// <param name="tokenService">Service used to validate and exchange the refresh token.</param>
|
||||
public class RefreshTokenHandler(ITokenService tokenService)
|
||||
@@ -14,7 +14,8 @@ public class RefreshTokenHandler(ITokenService tokenService)
|
||||
{
|
||||
public async Task<LoginPayload> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await tokenService.RefreshTokenAsync(request.RefreshToken);
|
||||
return new LoginPayload(result.UserAccount.UserAccountId, result.UserAccount.Username, result.RefreshToken, result.AccessToken);
|
||||
RefreshTokenResult result = await tokenService.RefreshTokenAsync(request.RefreshToken);
|
||||
return new LoginPayload(result.UserAccount.UserAccountId, result.UserAccount.Username, result.RefreshToken,
|
||||
result.AccessToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,12 @@ using FluentValidation;
|
||||
namespace Features.Auth.Commands.RefreshToken;
|
||||
|
||||
/// <summary>
|
||||
/// Validates <see cref="RefreshTokenCommand"/> instances before they are processed.
|
||||
/// Validates <see cref="RefreshTokenCommand" /> instances before they are processed.
|
||||
/// </summary>
|
||||
public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures a validation rule requiring <see cref="RefreshTokenCommand.RefreshToken"/> to be non-empty.
|
||||
/// Configures a validation rule requiring <see cref="RefreshTokenCommand.RefreshToken" /> to be non-empty.
|
||||
/// </summary>
|
||||
public RefreshTokenValidator()
|
||||
{
|
||||
@@ -16,4 +16,4 @@ public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
|
||||
.NotEmpty()
|
||||
.WithMessage("Refresh token is required");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,20 @@ 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>.
|
||||
/// 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="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>
|
||||
/// <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,
|
||||
@@ -19,4 +25,4 @@ public record RegisterUserCommand(
|
||||
string Email,
|
||||
DateTime DateOfBirth,
|
||||
string Password
|
||||
) : IRequest<RegistrationPayload>;
|
||||
) : IRequest<RegistrationPayload>;
|
||||
@@ -1,3 +1,4 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Repository;
|
||||
@@ -9,9 +10,9 @@ using Shared.Application.Emails;
|
||||
namespace Features.Auth.Commands.RegisterUser;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="RegisterUserCommand"/>: validates uniqueness, hashes the password, persists the
|
||||
/// account, issues access/refresh/confirmation tokens, and attempts to send the registration
|
||||
/// confirmation email via Features.Emails.
|
||||
/// Handles <see cref="RegisterUserCommand" />: validates uniqueness, hashes the password, persists the
|
||||
/// account, issues access/refresh/confirmation tokens, and attempts to send the registration
|
||||
/// confirmation email via Features.Emails.
|
||||
/// </summary>
|
||||
/// <param name="authRepo">Repository used to check for existing users and persist the new account.</param>
|
||||
/// <param name="passwordInfrastructure">Infrastructure component used to hash the user's plain-text password.</param>
|
||||
@@ -25,15 +26,15 @@ public class RegisterUserHandler(
|
||||
) : IRequestHandler<RegisterUserCommand, RegistrationPayload>
|
||||
{
|
||||
/// <exception cref="ConflictException">
|
||||
/// Thrown when an existing account already has the same username or email address.
|
||||
/// Thrown when an existing account already has the same username or email address.
|
||||
/// </exception>
|
||||
public async Task<RegistrationPayload> Handle(RegisterUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
await ValidateUserDoesNotExist(request.Username, request.Email);
|
||||
|
||||
var hashed = passwordInfrastructure.Hash(request.Password);
|
||||
string hashed = passwordInfrastructure.Hash(request.Password);
|
||||
|
||||
var createdUser = await authRepo.RegisterUserAsync(
|
||||
UserAccount createdUser = await authRepo.RegisterUserAsync(
|
||||
request.Username,
|
||||
request.FirstName,
|
||||
request.LastName,
|
||||
@@ -42,16 +43,15 @@ public class RegisterUserHandler(
|
||||
hashed
|
||||
);
|
||||
|
||||
var accessToken = tokenService.GenerateAccessToken(createdUser);
|
||||
var refreshToken = tokenService.GenerateRefreshToken(createdUser);
|
||||
var confirmationToken = tokenService.GenerateConfirmationToken(createdUser);
|
||||
string accessToken = tokenService.GenerateAccessToken(createdUser);
|
||||
string refreshToken = tokenService.GenerateRefreshToken(createdUser);
|
||||
string confirmationToken = tokenService.GenerateConfirmationToken(createdUser);
|
||||
|
||||
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
|
||||
{
|
||||
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, string.Empty, string.Empty, false);
|
||||
}
|
||||
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, string.Empty, string.Empty,
|
||||
false);
|
||||
|
||||
var emailSent = false;
|
||||
bool emailSent = false;
|
||||
try
|
||||
{
|
||||
await mediator.Send(
|
||||
@@ -67,20 +67,19 @@ public class RegisterUserHandler(
|
||||
// ignored
|
||||
}
|
||||
|
||||
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, refreshToken, accessToken, emailSent);
|
||||
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, refreshToken, accessToken,
|
||||
emailSent);
|
||||
}
|
||||
|
||||
/// <exception cref="ConflictException">
|
||||
/// Thrown when an existing account already has the same username or email address.
|
||||
/// Thrown when an existing account already has the same username or email address.
|
||||
/// </exception>
|
||||
private async Task ValidateUserDoesNotExist(string username, string email)
|
||||
{
|
||||
var existingUsername = await authRepo.GetUserByUsernameAsync(username);
|
||||
var existingEmail = await authRepo.GetUserByEmailAsync(email);
|
||||
UserAccount? existingUsername = await authRepo.GetUserByUsernameAsync(username);
|
||||
UserAccount? existingEmail = await authRepo.GetUserByEmailAsync(email);
|
||||
|
||||
if (existingUsername != null || existingEmail != null)
|
||||
{
|
||||
throw new ConflictException("Username or email already exists");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,13 @@ using FluentValidation;
|
||||
namespace Features.Auth.Commands.RegisterUser;
|
||||
|
||||
/// <summary>
|
||||
/// Validates <see cref="RegisterUserCommand"/> instances before they are processed.
|
||||
/// Validates <see cref="RegisterUserCommand" /> instances before they are processed.
|
||||
/// </summary>
|
||||
public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures validation rules for username format and length, first/last name length, email format and
|
||||
/// length, minimum age based on date of birth, and password strength requirements.
|
||||
/// Configures validation rules for username format and length, first/last name length, email format and
|
||||
/// length, minimum age based on date of birth, and password strength requirements.
|
||||
/// </summary>
|
||||
public RegisterUserValidator()
|
||||
{
|
||||
@@ -65,4 +65,4 @@ public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
|
||||
"Password must contain at least one special character"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ using MediatR;
|
||||
namespace Features.Auth.Commands.ResendConfirmationEmail;
|
||||
|
||||
/// <summary>
|
||||
/// Resends the account confirmation email to a user, generating a fresh confirmation token.
|
||||
/// Resends the account confirmation email to a user, generating a fresh confirmation token.
|
||||
/// </summary>
|
||||
/// <param name="UserId">The unique identifier of the user requesting the resend.</param>
|
||||
public record ResendConfirmationEmailCommand(Guid UserId) : IRequest;
|
||||
public record ResendConfirmationEmailCommand(Guid UserId) : IRequest;
|
||||
@@ -1,3 +1,4 @@
|
||||
using Domain.Entities;
|
||||
using Features.Auth.Repository;
|
||||
using Features.Auth.Services;
|
||||
using MediatR;
|
||||
@@ -6,15 +7,15 @@ 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.
|
||||
/// Handles <see cref="ResendConfirmationEmailCommand" /> by generating a fresh confirmation token and
|
||||
/// sending it via Features.Emails.
|
||||
/// </summary>
|
||||
/// <param name="authRepository">Repository used to look up the user and check verification status.</param>
|
||||
/// <param name="tokenService">Service used to generate the confirmation token.</param>
|
||||
/// <param name="mediator">Used to send the cross-slice command that triggers the email.</param>
|
||||
/// <remarks>
|
||||
/// Returns silently without sending an email if the user does not exist (to prevent user enumeration)
|
||||
/// or if the user's account is already verified.
|
||||
/// Returns silently without sending an email if the user does not exist (to prevent user enumeration)
|
||||
/// or if the user's account is already verified.
|
||||
/// </remarks>
|
||||
public class ResendConfirmationEmailHandler(
|
||||
IAuthRepository authRepository,
|
||||
@@ -24,21 +25,15 @@ public class ResendConfirmationEmailHandler(
|
||||
{
|
||||
public async Task Handle(ResendConfirmationEmailCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await authRepository.GetUserByIdAsync(request.UserId);
|
||||
if (user == null)
|
||||
{
|
||||
return; // Silent return to prevent user enumeration
|
||||
}
|
||||
UserAccount? user = await authRepository.GetUserByIdAsync(request.UserId);
|
||||
if (user == null) return; // Silent return to prevent user enumeration
|
||||
|
||||
if (await authRepository.IsUserVerifiedAsync(request.UserId))
|
||||
{
|
||||
return; // Already confirmed, no-op
|
||||
}
|
||||
if (await authRepository.IsUserVerifiedAsync(request.UserId)) return; // Already confirmed, no-op
|
||||
|
||||
var confirmationToken = tokenService.GenerateConfirmationToken(user);
|
||||
string confirmationToken = tokenService.GenerateConfirmationToken(user);
|
||||
await mediator.Send(
|
||||
new SendResendConfirmationEmailCommand(user.FirstName, user.Email, confirmationToken),
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,121 +9,120 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Shared.Contracts;
|
||||
|
||||
namespace Features.Auth.Controllers
|
||||
namespace Features.Auth.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Handles user authentication concerns: registration, login, email confirmation, and token refresh.
|
||||
/// </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>
|
||||
/// <param name="mediator">Used to dispatch auth commands and queries to their handlers.</param>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(AuthenticationSchemes = "JWT")]
|
||||
public class AuthController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles user authentication concerns: registration, login, email confirmation, and token refresh.
|
||||
/// Registers a new user account.
|
||||
/// </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.
|
||||
/// Allows anonymous access. On success, responds with <c>201 Created</c> containing the new user's ID,
|
||||
/// username, issued refresh/access tokens, and whether a confirmation email was sent.
|
||||
/// </remarks>
|
||||
/// <param name="mediator">Used to dispatch auth commands and queries to their handlers.</param>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(AuthenticationSchemes = "JWT")]
|
||||
public class AuthController(IMediator mediator) : ControllerBase
|
||||
/// <param name="command">The registration details, including username, name, email, date of birth, and password.</param>
|
||||
/// <returns>A <c>201 Created</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="RegistrationPayload" />.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("register")]
|
||||
public async Task<ActionResult<ResponseBody<RegistrationPayload>>> Register(
|
||||
[FromBody] RegisterUserCommand command
|
||||
)
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a new user account.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Allows anonymous access. On success, responds with <c>201 Created</c> containing the new user's ID,
|
||||
/// username, issued refresh/access tokens, and whether a confirmation email was sent.
|
||||
/// </remarks>
|
||||
/// <param name="command">The registration details, including username, name, email, date of birth, and password.</param>
|
||||
/// <returns>A <c>201 Created</c> result wrapping a <see cref="ResponseBody{T}"/> of <see cref="RegistrationPayload"/>.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("register")]
|
||||
public async Task<ActionResult<ResponseBody<RegistrationPayload>>> Register(
|
||||
[FromBody] RegisterUserCommand command
|
||||
)
|
||||
RegistrationPayload payload = await mediator.Send(command);
|
||||
return Created("/", new ResponseBody<RegistrationPayload>
|
||||
{
|
||||
var payload = await mediator.Send(command);
|
||||
return Created("/", new ResponseBody<RegistrationPayload>
|
||||
{
|
||||
Message = "User registered successfully.",
|
||||
Payload = payload,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates a user with a username and password.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Allows anonymous access. On success, responds with <c>200 OK</c> containing the user's ID,
|
||||
/// username, and a newly issued refresh/access token pair.
|
||||
/// </remarks>
|
||||
/// <param name="query">The login credentials (username and password).</param>
|
||||
/// <returns>An <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of <see cref="LoginPayload"/>.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("login")]
|
||||
public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginQuery query)
|
||||
{
|
||||
var payload = await mediator.Send(query);
|
||||
return Ok(new ResponseBody<LoginPayload>
|
||||
{
|
||||
Message = "Logged in successfully.",
|
||||
Payload = payload,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms a user account using a confirmation token.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Requires JWT authentication. On success, responds with <c>200 OK</c> containing the confirmed
|
||||
/// user's ID and the confirmation timestamp.
|
||||
/// </remarks>
|
||||
/// <param name="token">The confirmation token supplied via the confirmation email link.</param>
|
||||
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of <see cref="ConfirmationPayload"/>.</returns>
|
||||
[HttpPost("confirm")]
|
||||
public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm([FromQuery] string token)
|
||||
{
|
||||
var 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 specified user.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Requires JWT authentication. On success, responds with <c>200 OK</c>.
|
||||
/// </remarks>
|
||||
/// <param name="userId">The unique identifier of the user account to resend the confirmation email for.</param>
|
||||
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody"/> confirming the email was resent.</returns>
|
||||
[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>
|
||||
/// Allows anonymous access. On success, responds with <c>200 OK</c> containing the user's ID,
|
||||
/// username, and the newly issued refresh/access token pair.
|
||||
/// </remarks>
|
||||
/// <param name="command">The request containing the refresh token to exchange.</param>
|
||||
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of <see cref="LoginPayload"/>.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("refresh")]
|
||||
public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh(
|
||||
[FromBody] RefreshTokenCommand command
|
||||
)
|
||||
{
|
||||
var payload = await mediator.Send(command);
|
||||
return Ok(new ResponseBody<LoginPayload>
|
||||
{
|
||||
Message = "Token refreshed successfully.",
|
||||
Payload = payload,
|
||||
});
|
||||
}
|
||||
Message = "User registered successfully.",
|
||||
Payload = payload
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates a user with a username and password.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Allows anonymous access. On success, responds with <c>200 OK</c> containing the user's ID,
|
||||
/// username, and a newly issued refresh/access token pair.
|
||||
/// </remarks>
|
||||
/// <param name="query">The login credentials (username and password).</param>
|
||||
/// <returns>An <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="LoginPayload" />.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("login")]
|
||||
public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginQuery query)
|
||||
{
|
||||
LoginPayload payload = await mediator.Send(query);
|
||||
return Ok(new ResponseBody<LoginPayload>
|
||||
{
|
||||
Message = "Logged in successfully.",
|
||||
Payload = payload
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms a user account using a confirmation token.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Requires JWT authentication. On success, responds with <c>200 OK</c> containing the confirmed
|
||||
/// user's ID and the confirmation timestamp.
|
||||
/// </remarks>
|
||||
/// <param name="token">The confirmation token supplied via the confirmation email link.</param>
|
||||
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="ConfirmationPayload" />.</returns>
|
||||
[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 specified user.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Requires JWT authentication. On success, responds with <c>200 OK</c>.
|
||||
/// </remarks>
|
||||
/// <param name="userId">The unique identifier of the user account to resend the confirmation email for.</param>
|
||||
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody" /> confirming the email was resent.</returns>
|
||||
[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>
|
||||
/// Allows anonymous access. On success, responds with <c>200 OK</c> containing the user's ID,
|
||||
/// username, and the newly issued refresh/access token pair.
|
||||
/// </remarks>
|
||||
/// <param name="command">The request containing the refresh token to exchange.</param>
|
||||
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="LoginPayload" />.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("refresh")]
|
||||
public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh(
|
||||
[FromBody] RefreshTokenCommand command
|
||||
)
|
||||
{
|
||||
LoginPayload payload = await mediator.Send(command);
|
||||
return Ok(new ResponseBody<LoginPayload>
|
||||
{
|
||||
Message = "Token refreshed successfully.",
|
||||
Payload = payload
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
namespace Features.Auth.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Registers the services owned by the Auth feature slice.
|
||||
/// Registers the services owned by the Auth feature slice.
|
||||
/// </summary>
|
||||
public static class FeaturesAuthServiceCollectionExtensions
|
||||
{
|
||||
@@ -17,4 +17,4 @@ public static class FeaturesAuthServiceCollectionExtensions
|
||||
services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace Features.Auth.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Payload returned to the client after a successful login or token refresh.
|
||||
/// Payload returned to the client after a successful login or token refresh.
|
||||
/// </summary>
|
||||
/// <param name="UserAccountId">The unique identifier of the authenticated user account.</param>
|
||||
/// <param name="Username">The username of the authenticated user account.</param>
|
||||
@@ -15,7 +15,7 @@ public record LoginPayload(
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Payload returned to the client after a successful registration.
|
||||
/// Payload returned to the client after a successful registration.
|
||||
/// </summary>
|
||||
/// <param name="UserAccountId">The unique identifier of the newly created user account.</param>
|
||||
/// <param name="Username">The username of the newly created user account.</param>
|
||||
@@ -31,8 +31,8 @@ public record RegistrationPayload(
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Payload returned to the client after a user account's email has been confirmed.
|
||||
/// Payload returned to the client after a user account's email has been confirmed.
|
||||
/// </summary>
|
||||
/// <param name="UserAccountId">The unique identifier of the user account that was confirmed.</param>
|
||||
/// <param name="ConfirmedDate">The date and time at which the account was confirmed.</param>
|
||||
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);
|
||||
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);
|
||||
@@ -1,22 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Features.Auth</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Features.Auth</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<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.Jwt\Infrastructure.Jwt.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj"/>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj"/>
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj"/>
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj"/>
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj"/>
|
||||
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj"/>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Repository;
|
||||
@@ -8,10 +9,13 @@ using MediatR;
|
||||
namespace Features.Auth.Queries.Login;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="LoginQuery"/> by verifying credentials and issuing access/refresh tokens.
|
||||
/// Handles <see cref="LoginQuery" /> by verifying credentials and issuing access/refresh tokens.
|
||||
/// </summary>
|
||||
/// <param name="authRepo">Repository used to look up the user account and its active credential.</param>
|
||||
/// <param name="passwordInfrastructure">Infrastructure component used to verify a plain-text password against a stored hash.</param>
|
||||
/// <param name="passwordInfrastructure">
|
||||
/// Infrastructure component used to verify a plain-text password against a stored
|
||||
/// hash.
|
||||
/// </param>
|
||||
/// <param name="tokenService">Service used to generate access and refresh tokens for the authenticated user.</param>
|
||||
public class LoginHandler(
|
||||
IAuthRepository authRepo,
|
||||
@@ -20,26 +24,26 @@ public class LoginHandler(
|
||||
) : IRequestHandler<LoginQuery, LoginPayload>
|
||||
{
|
||||
/// <exception cref="UnauthorizedException">
|
||||
/// Thrown when the username does not match any account, the account has no active credential,
|
||||
/// or the supplied password does not match the stored hash.
|
||||
/// Thrown when the username does not match any account, the account has no active credential,
|
||||
/// or the supplied password does not match the stored hash.
|
||||
/// </exception>
|
||||
public async Task<LoginPayload> Handle(LoginQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user =
|
||||
UserAccount user =
|
||||
await authRepo.GetUserByUsernameAsync(request.Username)
|
||||
?? throw new UnauthorizedException("Invalid username or password.");
|
||||
|
||||
// @todo handle expired passwords
|
||||
var activeCred =
|
||||
UserCredential activeCred =
|
||||
await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId)
|
||||
?? throw new UnauthorizedException("Invalid username or password.");
|
||||
|
||||
if (!passwordInfrastructure.Verify(request.Password, activeCred.Hash))
|
||||
throw new UnauthorizedException("Invalid username or password.");
|
||||
|
||||
var accessToken = tokenService.GenerateAccessToken(user);
|
||||
var refreshToken = tokenService.GenerateRefreshToken(user);
|
||||
string accessToken = tokenService.GenerateAccessToken(user);
|
||||
string refreshToken = tokenService.GenerateRefreshToken(user);
|
||||
|
||||
return new LoginPayload(user.UserAccountId, user.Username, refreshToken, accessToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ using MediatR;
|
||||
namespace Features.Auth.Queries.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>.
|
||||
/// 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 LoginQuery(string Username, string Password) : IRequest<LoginPayload>;
|
||||
public record LoginQuery(string Username, string Password) : IRequest<LoginPayload>;
|
||||
@@ -3,13 +3,13 @@ using FluentValidation;
|
||||
namespace Features.Auth.Queries.Login;
|
||||
|
||||
/// <summary>
|
||||
/// Validates <see cref="LoginQuery"/> instances before they are processed.
|
||||
/// Validates <see cref="LoginQuery" /> instances before they are processed.
|
||||
/// </summary>
|
||||
public class LoginValidator : AbstractValidator<LoginQuery>
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures validation rules requiring both <see cref="LoginQuery.Username"/> and
|
||||
/// <see cref="LoginQuery.Password"/> to be non-empty.
|
||||
/// Configures validation rules requiring both <see cref="LoginQuery.Username" /> and
|
||||
/// <see cref="LoginQuery.Password" /> to be non-empty.
|
||||
/// </summary>
|
||||
public LoginValidator()
|
||||
{
|
||||
@@ -17,4 +17,4 @@ public class LoginValidator : AbstractValidator<LoginQuery>
|
||||
|
||||
RuleFor(x => x.Password).NotEmpty().WithMessage("Password is required");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,23 +7,23 @@ using Microsoft.Data.SqlClient;
|
||||
namespace Features.Auth.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// ADO.NET-based implementation of <see cref="IAuthRepository"/> backed by SQL Server stored procedures,
|
||||
/// handling user registration, credential lookup/rotation, and account verification.
|
||||
/// ADO.NET-based implementation of <see cref="IAuthRepository" /> backed by SQL Server stored procedures,
|
||||
/// handling user registration, credential lookup/rotation, and account verification.
|
||||
/// </summary>
|
||||
/// <param name="connectionFactory">The factory used to create database connections.</param>
|
||||
public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
: Infrastructure.Sql.Repository<Domain.Entities.UserAccount>(connectionFactory),
|
||||
: Repository<UserAccount>(connectionFactory),
|
||||
IAuthRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a new user account and initial credential using the <c>USP_RegisterUser</c> stored
|
||||
/// procedure, then fetches and returns the newly created user.
|
||||
/// Registers a new user account and initial credential using the <c>USP_RegisterUser</c> stored
|
||||
/// procedure, then fetches and returns the newly created user.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The stored procedure's scalar result (expected to be the new user's ID) is parsed defensively:
|
||||
/// it may be returned as a <see cref="Guid"/>, a parseable <see cref="string"/>, or a 16-byte array.
|
||||
/// If the result cannot be interpreted, <see cref="Guid.Empty"/> is used, which will cause the
|
||||
/// subsequent lookup to fail.
|
||||
/// The stored procedure's scalar result (expected to be the new user's ID) is parsed defensively:
|
||||
/// it may be returned as a <see cref="Guid" />, a parseable <see cref="string" />, or a 16-byte array.
|
||||
/// If the result cannot be interpreted, <see cref="Guid.Empty" /> is used, which will cause the
|
||||
/// subsequent lookup to fail.
|
||||
/// </remarks>
|
||||
/// <param name="username">Unique username for the user</param>
|
||||
/// <param name="firstName">User's first name</param>
|
||||
@@ -34,7 +34,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <returns>The newly created UserAccount with generated ID</returns>
|
||||
/// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception>
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||
public async Task<Domain.Entities.UserAccount> RegisterUserAsync(
|
||||
public async Task<UserAccount> RegisterUserAsync(
|
||||
string username,
|
||||
string firstName,
|
||||
string lastName,
|
||||
@@ -43,8 +43,8 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
string passwordHash
|
||||
)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
|
||||
command.CommandText = "USP_RegisterUser";
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
@@ -56,89 +56,82 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
AddParameter(command, "@DateOfBirth", dateOfBirth);
|
||||
AddParameter(command, "@Hash", passwordHash);
|
||||
|
||||
var result = await command.ExecuteScalarAsync();
|
||||
object? result = await command.ExecuteScalarAsync();
|
||||
|
||||
Guid userAccountId = Guid.Empty;
|
||||
if (result != null && result != DBNull.Value)
|
||||
{
|
||||
if (result is Guid g)
|
||||
{
|
||||
userAccountId = g;
|
||||
}
|
||||
else if (result is string s && Guid.TryParse(s, out var parsed))
|
||||
{
|
||||
else if (result is string s && Guid.TryParse(s, out Guid parsed))
|
||||
userAccountId = parsed;
|
||||
}
|
||||
else if (result is byte[] bytes && bytes.Length == 16)
|
||||
{
|
||||
userAccountId = new Guid(bytes);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback: try to convert and parse string representation
|
||||
try
|
||||
{
|
||||
var str = result.ToString();
|
||||
if (!string.IsNullOrEmpty(str) && Guid.TryParse(str, out var p))
|
||||
string? str = result.ToString();
|
||||
if (!string.IsNullOrEmpty(str) && Guid.TryParse(str, out Guid p))
|
||||
userAccountId = p;
|
||||
}
|
||||
catch
|
||||
{
|
||||
userAccountId = Guid.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await GetUserByIdAsync(userAccountId) ?? throw new Exception("Failed to retrieve newly registered user.");
|
||||
return await GetUserByIdAsync(userAccountId) ??
|
||||
throw new Exception("Failed to retrieve newly registered user.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a user account by email address (typically used for login) using the
|
||||
/// <c>usp_GetUserAccountByEmail</c> stored procedure.
|
||||
/// Retrieves a user account by email address (typically used for login) using the
|
||||
/// <c>usp_GetUserAccountByEmail</c> stored procedure.
|
||||
/// </summary>
|
||||
/// <param name="email">Email address to search for</param>
|
||||
/// <returns>UserAccount if found, null otherwise</returns>
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||
public async Task<Domain.Entities.UserAccount?> GetUserByEmailAsync(
|
||||
public async Task<UserAccount?> GetUserByEmailAsync(
|
||||
string email
|
||||
)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
command.CommandText = "usp_GetUserAccountByEmail";
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
AddParameter(command, "@Email", email);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
await using DbDataReader reader = await command.ExecuteReaderAsync();
|
||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a user account by username (typically used for login) using the
|
||||
/// <c>usp_GetUserAccountByUsername</c> stored procedure.
|
||||
/// Retrieves a user account by username (typically used for login) using the
|
||||
/// <c>usp_GetUserAccountByUsername</c> stored procedure.
|
||||
/// </summary>
|
||||
/// <param name="username">Username to search for</param>
|
||||
/// <returns>UserAccount if found, null otherwise</returns>
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||
public async Task<Domain.Entities.UserAccount?> GetUserByUsernameAsync(
|
||||
public async Task<UserAccount?> GetUserByUsernameAsync(
|
||||
string username
|
||||
)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
command.CommandText = "usp_GetUserAccountByUsername";
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
AddParameter(command, "@Username", username);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
await using DbDataReader reader = await command.ExecuteReaderAsync();
|
||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the active (non-revoked) credential for a user account using the
|
||||
/// <c>USP_GetActiveUserCredentialByUserAccountId</c> stored procedure.
|
||||
/// Retrieves the active (non-revoked) credential for a user account using the
|
||||
/// <c>USP_GetActiveUserCredentialByUserAccountId</c> stored procedure.
|
||||
/// </summary>
|
||||
/// <param name="userAccountId">ID of the user account</param>
|
||||
/// <returns>Active UserCredential if found, null otherwise</returns>
|
||||
@@ -147,20 +140,20 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
Guid userAccountId
|
||||
)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
command.CommandText = "USP_GetActiveUserCredentialByUserAccountId";
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
AddParameter(command, "@UserAccountId", userAccountId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
await using DbDataReader reader = await command.ExecuteReaderAsync();
|
||||
return await reader.ReadAsync() ? MapToCredentialEntity(reader) : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rotates a user's credential by invalidating all existing credentials and creating a new one,
|
||||
/// using the <c>USP_RotateUserCredential</c> stored procedure.
|
||||
/// Rotates a user's credential by invalidating all existing credentials and creating a new one,
|
||||
/// using the <c>USP_RotateUserCredential</c> stored procedure.
|
||||
/// </summary>
|
||||
/// <param name="userAccountId">ID of the user account</param>
|
||||
/// <param name="newPasswordHash">New hashed password</param>
|
||||
@@ -170,8 +163,8 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
string newPasswordHash
|
||||
)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
command.CommandText = "USP_RotateUserCredential";
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
@@ -182,53 +175,50 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a user account by ID using the <c>usp_GetUserAccountById</c> stored procedure.
|
||||
/// Retrieves a user account by ID using the <c>usp_GetUserAccountById</c> stored procedure.
|
||||
/// </summary>
|
||||
/// <param name="userAccountId">ID of the user account</param>
|
||||
/// <returns>UserAccount if found, null otherwise</returns>
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||
public async Task<Domain.Entities.UserAccount?> GetUserByIdAsync(
|
||||
public async Task<UserAccount?> GetUserByIdAsync(
|
||||
Guid userAccountId
|
||||
)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
command.CommandText = "usp_GetUserAccountById";
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
AddParameter(command, "@UserAccountId", userAccountId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
await using DbDataReader reader = await command.ExecuteReaderAsync();
|
||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a user account as confirmed by creating a verification record via the
|
||||
/// <c>USP_CreateUserVerification</c> stored procedure. If the user is already verified, this is a
|
||||
/// no-op and the existing user is returned (idempotent). If a concurrent request verifies the user
|
||||
/// first, the resulting duplicate-key SQL exception (error 2601/2627) is swallowed.
|
||||
/// Marks a user account as confirmed by creating a verification record via the
|
||||
/// <c>USP_CreateUserVerification</c> stored procedure. If the user is already verified, this is a
|
||||
/// no-op and the existing user is returned (idempotent). If a concurrent request verifies the user
|
||||
/// first, the resulting duplicate-key SQL exception (error 2601/2627) is swallowed.
|
||||
/// </summary>
|
||||
/// <param name="userAccountId">ID of the user account to confirm</param>
|
||||
/// <returns>The confirmed <see cref="Domain.Entities.UserAccount"/>, or <c>null</c> if the user account does not exist.</returns>
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails for a reason other than a duplicate verification record.</exception>
|
||||
public async Task<Domain.Entities.UserAccount?> ConfirmUserAccountAsync(
|
||||
/// <returns>The confirmed <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if the user account does not exist.</returns>
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">
|
||||
/// Thrown when the database command fails for a reason other than
|
||||
/// a duplicate verification record.
|
||||
/// </exception>
|
||||
public async Task<UserAccount?> ConfirmUserAccountAsync(
|
||||
Guid userAccountId
|
||||
)
|
||||
{
|
||||
var user = await GetUserByIdAsync(userAccountId);
|
||||
if (user == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
UserAccount? user = await GetUserByIdAsync(userAccountId);
|
||||
if (user == null) return null;
|
||||
|
||||
// Idempotency: if already verified, treat as successful confirmation.
|
||||
if (await IsUserVerifiedAsync(userAccountId))
|
||||
{
|
||||
return user;
|
||||
}
|
||||
if (await IsUserVerifiedAsync(userAccountId)) return user;
|
||||
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
command.CommandText = "USP_CreateUserVerification";
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
@@ -248,29 +238,29 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a user account has been verified by querying the
|
||||
/// <c>dbo.UserVerification</c> table for a matching record.
|
||||
/// Checks whether a user account has been verified by querying the
|
||||
/// <c>dbo.UserVerification</c> table for a matching record.
|
||||
/// </summary>
|
||||
/// <param name="userAccountId">ID of the user account</param>
|
||||
/// <returns>True if the user has a verification record, false otherwise</returns>
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
command.CommandText =
|
||||
"SELECT TOP 1 1 FROM dbo.UserVerification WHERE UserAccountID = @UserAccountID";
|
||||
command.CommandType = CommandType.Text;
|
||||
|
||||
AddParameter(command, "@UserAccountID", userAccountId);
|
||||
|
||||
var result = await command.ExecuteScalarAsync();
|
||||
object? result = await command.ExecuteScalarAsync();
|
||||
return result != null && result != DBNull.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a <see cref="SqlException"/> represents a duplicate key violation
|
||||
/// (SQL Server error 2601 or 2627), used to detect a concurrent duplicate verification insert.
|
||||
/// Determines whether a <see cref="SqlException" /> represents a duplicate key violation
|
||||
/// (SQL Server error 2601 or 2627), used to detect a concurrent duplicate verification insert.
|
||||
/// </summary>
|
||||
/// <param name="ex">The SQL exception to inspect.</param>
|
||||
/// <returns>True if the exception represents a duplicate key violation, false otherwise.</returns>
|
||||
@@ -282,15 +272,15 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Maps a data reader row to a UserAccount entity.
|
||||
/// Maps a data reader row to a UserAccount entity.
|
||||
/// </summary>
|
||||
/// <param name="reader">The data reader positioned on the row to map.</param>
|
||||
/// <returns>The mapped <see cref="Domain.Entities.UserAccount"/> instance.</returns>
|
||||
protected override Domain.Entities.UserAccount MapToEntity(
|
||||
/// <returns>The mapped <see cref="Domain.Entities.UserAccount" /> instance.</returns>
|
||||
protected override UserAccount MapToEntity(
|
||||
DbDataReader reader
|
||||
)
|
||||
{
|
||||
return new Domain.Entities.UserAccount
|
||||
return new UserAccount
|
||||
{
|
||||
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
|
||||
Username = reader.GetString(reader.GetOrdinal("Username")),
|
||||
@@ -304,33 +294,33 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")),
|
||||
Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
|
||||
? null
|
||||
: (byte[])reader["Timer"],
|
||||
: (byte[])reader["Timer"]
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a data reader row to a UserCredential entity. The <c>Timer</c> column is mapped only if
|
||||
/// present in the reader's schema, allowing this method to support result sets that omit it.
|
||||
/// Maps a data reader row to a UserCredential entity. The <c>Timer</c> column is mapped only if
|
||||
/// present in the reader's schema, allowing this method to support result sets that omit it.
|
||||
/// </summary>
|
||||
/// <param name="reader">The data reader positioned on the row to map.</param>
|
||||
/// <returns>The mapped <see cref="UserCredential"/> instance.</returns>
|
||||
/// <returns>The mapped <see cref="UserCredential" /> instance.</returns>
|
||||
private static UserCredential MapToCredentialEntity(DbDataReader reader)
|
||||
{
|
||||
var entity = new UserCredential
|
||||
UserCredential entity = new()
|
||||
{
|
||||
UserCredentialId = reader.GetGuid(
|
||||
reader.GetOrdinal("UserCredentialId")
|
||||
),
|
||||
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
|
||||
Hash = reader.GetString(reader.GetOrdinal("Hash")),
|
||||
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")),
|
||||
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt"))
|
||||
};
|
||||
|
||||
// Optional columns
|
||||
var hasTimer =
|
||||
bool hasTimer =
|
||||
reader
|
||||
.GetSchemaTable()
|
||||
?.Rows.Cast<System.Data.DataRow>()
|
||||
?.Rows.Cast<DataRow>()
|
||||
.Any(r =>
|
||||
string.Equals(
|
||||
r["ColumnName"]?.ToString(),
|
||||
@@ -340,31 +330,29 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
) ?? false;
|
||||
|
||||
if (hasTimer)
|
||||
{
|
||||
entity.Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
|
||||
? null
|
||||
: (byte[])reader["Timer"];
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to add a parameter to a database command, converting <c>null</c> values to
|
||||
/// <see cref="DBNull.Value"/>.
|
||||
/// Helper method to add a parameter to a database command, converting <c>null</c> values to
|
||||
/// <see cref="DBNull.Value" />.
|
||||
/// </summary>
|
||||
/// <param name="command">The command to add the parameter to.</param>
|
||||
/// <param name="name">The parameter name (including any prefix, e.g. "@Username").</param>
|
||||
/// <param name="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value"/>.</param>
|
||||
/// <param name="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value" />.</param>
|
||||
private static void AddParameter(
|
||||
DbCommand command,
|
||||
string name,
|
||||
object? value
|
||||
)
|
||||
{
|
||||
var p = command.CreateParameter();
|
||||
DbParameter p = command.CreateParameter();
|
||||
p.ParameterName = name;
|
||||
p.Value = value ?? DBNull.Value;
|
||||
command.Parameters.Add(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,13 @@ using Domain.Entities;
|
||||
namespace Features.Auth.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for authentication-related database operations including user registration and credential management.
|
||||
/// Repository for authentication-related database operations including user registration and credential management.
|
||||
/// </summary>
|
||||
public interface IAuthRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a new user with account details and initial credential.
|
||||
/// Uses stored procedure: USP_RegisterUser
|
||||
/// Registers a new user with account details and initial credential.
|
||||
/// Uses stored procedure: USP_RegisterUser
|
||||
/// </summary>
|
||||
/// <param name="username">Unique username for the user</param>
|
||||
/// <param name="firstName">User's first name</param>
|
||||
@@ -18,7 +18,7 @@ public interface IAuthRepository
|
||||
/// <param name="dateOfBirth">User's date of birth</param>
|
||||
/// <param name="passwordHash">Hashed password</param>
|
||||
/// <returns>The newly created UserAccount with generated ID</returns>
|
||||
Task<Domain.Entities.UserAccount> RegisterUserAsync(
|
||||
Task<UserAccount> RegisterUserAsync(
|
||||
string username,
|
||||
string firstName,
|
||||
string lastName,
|
||||
@@ -28,24 +28,24 @@ public interface IAuthRepository
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a user account by email address (typically used for login).
|
||||
/// Uses stored procedure: usp_GetUserAccountByEmail
|
||||
/// Retrieves a user account by email address (typically used for login).
|
||||
/// Uses stored procedure: usp_GetUserAccountByEmail
|
||||
/// </summary>
|
||||
/// <param name="email">Email address to search for</param>
|
||||
/// <returns>UserAccount if found, null otherwise</returns>
|
||||
Task<Domain.Entities.UserAccount?> GetUserByEmailAsync(string email);
|
||||
Task<UserAccount?> GetUserByEmailAsync(string email);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a user account by username (typically used for login).
|
||||
/// Uses stored procedure: usp_GetUserAccountByUsername
|
||||
/// Retrieves a user account by username (typically used for login).
|
||||
/// Uses stored procedure: usp_GetUserAccountByUsername
|
||||
/// </summary>
|
||||
/// <param name="username">Username to search for</param>
|
||||
/// <returns>UserAccount if found, null otherwise</returns>
|
||||
Task<Domain.Entities.UserAccount?> GetUserByUsernameAsync(string username);
|
||||
Task<UserAccount?> GetUserByUsernameAsync(string username);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the active (non-revoked) credential for a user account.
|
||||
/// Uses stored procedure: USP_GetActiveUserCredentialByUserAccountId
|
||||
/// Retrieves the active (non-revoked) credential for a user account.
|
||||
/// Uses stored procedure: USP_GetActiveUserCredentialByUserAccountId
|
||||
/// </summary>
|
||||
/// <param name="userAccountId">ID of the user account</param>
|
||||
/// <returns>Active UserCredential if found, null otherwise</returns>
|
||||
@@ -54,31 +54,31 @@ public interface IAuthRepository
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Rotates a user's credential by invalidating all existing credentials and creating a new one.
|
||||
/// Uses stored procedure: USP_RotateUserCredential
|
||||
/// Rotates a user's credential by invalidating all existing credentials and creating a new one.
|
||||
/// Uses stored procedure: USP_RotateUserCredential
|
||||
/// </summary>
|
||||
/// <param name="userAccountId">ID of the user account</param>
|
||||
/// <param name="newPasswordHash">New hashed password</param>
|
||||
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
|
||||
|
||||
/// <summary>
|
||||
/// Marks a user account as confirmed.
|
||||
/// Marks a user account as confirmed.
|
||||
/// </summary>
|
||||
/// <param name="userAccountId">ID of the user account to confirm</param>
|
||||
/// <returns>The confirmed UserAccount entity, or null if the user account does not exist</returns>
|
||||
Task<Domain.Entities.UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
|
||||
Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a user account by ID.
|
||||
/// Retrieves a user account by ID.
|
||||
/// </summary>
|
||||
/// <param name="userAccountId">ID of the user account</param>
|
||||
/// <returns>UserAccount if found, null otherwise</returns>
|
||||
Task<Domain.Entities.UserAccount?> GetUserByIdAsync(Guid userAccountId);
|
||||
Task<UserAccount?> GetUserByIdAsync(Guid userAccountId);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a user account has been verified.
|
||||
/// Checks whether a user account has been verified.
|
||||
/// </summary>
|
||||
/// <param name="userAccountId">ID of the user account</param>
|
||||
/// <returns>True if the user has a verification record, false otherwise</returns>
|
||||
Task<bool> IsUserVerifiedAsync(Guid userAccountId);
|
||||
}
|
||||
}
|
||||
@@ -4,28 +4,30 @@ using Domain.Entities;
|
||||
namespace Features.Auth.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the kind of token being generated or validated.
|
||||
/// 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,
|
||||
ConfirmationToken
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of successfully validating a token.
|
||||
/// Represents the result of successfully validating a token.
|
||||
/// </summary>
|
||||
/// <param name="UserId">The unique identifier of the user the token belongs to.</param>
|
||||
/// <param name="Username">The username extracted from the token's claims.</param>
|
||||
/// <param name="Principal">The <see cref="ClaimsPrincipal"/> produced from the validated token.</param>
|
||||
/// <param name="Principal">The <see cref="ClaimsPrincipal" /> produced from the validated token.</param>
|
||||
public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal);
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of refreshing a user's session.
|
||||
/// Represents the result of refreshing a user's session.
|
||||
/// </summary>
|
||||
/// <param name="UserAccount">The user account associated with the refreshed session.</param>
|
||||
/// <param name="RefreshToken">The newly issued refresh token.</param>
|
||||
@@ -37,77 +39,79 @@ public record RefreshTokenResult(
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Defines the expiration windows, in hours, for each type of token issued by <see cref="ITokenService"/>.
|
||||
/// Defines the expiration windows, in hours, for each type of token issued by <see cref="ITokenService" />.
|
||||
/// </summary>
|
||||
public static class TokenServiceExpirationHours
|
||||
{
|
||||
/// <summary>The expiration window, in hours, for access tokens.</summary>
|
||||
public const double AccessTokenHours = 1;
|
||||
|
||||
/// <summary>The expiration window, in hours, for refresh tokens (21 days).</summary>
|
||||
public const double RefreshTokenHours = 504; // 21 days
|
||||
|
||||
/// <summary>The expiration window, in hours, for confirmation tokens (30 minutes).</summary>
|
||||
public const double ConfirmationTokenHours = 0.5; // 30 minutes
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines operations for generating and validating JWTs used for access, refresh, and account confirmation.
|
||||
/// Defines operations for generating and validating JWTs used for access, refresh, and account confirmation.
|
||||
/// </summary>
|
||||
public interface ITokenService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a new access token for the given user.
|
||||
/// Generates a new access token for the given user.
|
||||
/// </summary>
|
||||
/// <param name="user">The user account to generate the token for.</param>
|
||||
/// <returns>The signed access token string.</returns>
|
||||
string GenerateAccessToken(UserAccount user);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a new refresh token for the given user.
|
||||
/// Generates a new refresh token for the given user.
|
||||
/// </summary>
|
||||
/// <param name="user">The user account to generate the token for.</param>
|
||||
/// <returns>The signed refresh token string.</returns>
|
||||
string GenerateRefreshToken(UserAccount user);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a new confirmation token for the given user.
|
||||
/// Generates a new confirmation token for the given user.
|
||||
/// </summary>
|
||||
/// <param name="user">The user account to generate the token for.</param>
|
||||
/// <returns>The signed confirmation token string.</returns>
|
||||
string GenerateConfirmationToken(UserAccount user);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a token of the type specified by <typeparamref name="T"/>, which must be <see cref="TokenType"/>.
|
||||
/// Generates a token of the type specified by <typeparamref name="T" />, which must be <see cref="TokenType" />.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType"/>.</typeparam>
|
||||
/// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType" />.</typeparam>
|
||||
/// <param name="user">The user account to generate the token for.</param>
|
||||
/// <returns>The signed token string corresponding to the requested token type.</returns>
|
||||
string GenerateToken<T>(UserAccount user) where T : struct, Enum;
|
||||
|
||||
/// <summary>
|
||||
/// Validates an access token.
|
||||
/// Validates an access token.
|
||||
/// </summary>
|
||||
/// <param name="token">The access token string to validate.</param>
|
||||
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns>
|
||||
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
|
||||
Task<ValidatedToken> ValidateAccessTokenAsync(string token);
|
||||
|
||||
/// <summary>
|
||||
/// Validates a refresh token.
|
||||
/// Validates a refresh token.
|
||||
/// </summary>
|
||||
/// <param name="token">The refresh token string to validate.</param>
|
||||
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns>
|
||||
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
|
||||
Task<ValidatedToken> ValidateRefreshTokenAsync(string token);
|
||||
|
||||
/// <summary>
|
||||
/// Validates a confirmation token.
|
||||
/// Validates a confirmation token.
|
||||
/// </summary>
|
||||
/// <param name="token">The confirmation token string to validate.</param>
|
||||
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns>
|
||||
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
|
||||
Task<ValidatedToken> ValidateConfirmationTokenAsync(string token);
|
||||
|
||||
/// <summary>
|
||||
/// Validates a refresh token and issues a new access/refresh token pair for the associated user.
|
||||
/// Validates a refresh token and issues a new access/refresh token pair for the associated user.
|
||||
/// </summary>
|
||||
/// <param name="refreshTokenString">The refresh token string to validate and exchange.</param>
|
||||
/// <returns>A <see cref="RefreshTokenResult"/> containing the user and newly issued tokens.</returns>
|
||||
/// <returns>A <see cref="RefreshTokenResult" /> containing the user and newly issued tokens.</returns>
|
||||
Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Security.Claims;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Repository;
|
||||
@@ -8,27 +8,26 @@ using Infrastructure.Jwt;
|
||||
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 environment variables.
|
||||
/// Default implementation of <see cref="ITokenService" /> that generates and validates JWTs
|
||||
/// for access, refresh, and confirmation flows using secrets read from environment variables.
|
||||
/// </summary>
|
||||
public class TokenService : ITokenService
|
||||
{
|
||||
private readonly ITokenInfrastructure _tokenInfrastructure;
|
||||
private readonly IAuthRepository _authRepository;
|
||||
|
||||
private readonly string _accessTokenSecret;
|
||||
private readonly string _refreshTokenSecret;
|
||||
private readonly IAuthRepository _authRepository;
|
||||
private readonly string _confirmationTokenSecret;
|
||||
private readonly string _refreshTokenSecret;
|
||||
private readonly ITokenInfrastructure _tokenInfrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="TokenService"/>, loading the access, refresh, and
|
||||
/// confirmation token signing secrets from environment variables.
|
||||
/// Initializes a new instance of <see cref="TokenService" />, loading the access, refresh, and
|
||||
/// confirmation token signing secrets from environment variables.
|
||||
/// </summary>
|
||||
/// <param name="tokenInfrastructure">The infrastructure component used to generate and validate JWTs.</param>
|
||||
/// <param name="authRepository">Repository used to look up user accounts during token refresh.</param>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when any of the <c>ACCESS_TOKEN_SECRET</c>, <c>REFRESH_TOKEN_SECRET</c>, or
|
||||
/// <c>CONFIRMATION_TOKEN_SECRET</c> environment variables are not set.
|
||||
/// Thrown when any of the <c>ACCESS_TOKEN_SECRET</c>, <c>REFRESH_TOKEN_SECRET</c>, or
|
||||
/// <c>CONFIRMATION_TOKEN_SECRET</c> environment variables are not set.
|
||||
/// </exception>
|
||||
public TokenService(
|
||||
ITokenInfrastructure tokenInfrastructure,
|
||||
@@ -39,139 +38,170 @@ public class TokenService : ITokenService
|
||||
_authRepository = authRepository;
|
||||
|
||||
_accessTokenSecret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET")
|
||||
?? throw new InvalidOperationException("ACCESS_TOKEN_SECRET environment variable is not set");
|
||||
?? throw new InvalidOperationException(
|
||||
"ACCESS_TOKEN_SECRET environment variable is not set");
|
||||
|
||||
_refreshTokenSecret = Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET")
|
||||
?? throw new InvalidOperationException("REFRESH_TOKEN_SECRET environment variable is not set");
|
||||
?? throw new InvalidOperationException(
|
||||
"REFRESH_TOKEN_SECRET environment variable is not set");
|
||||
|
||||
_confirmationTokenSecret = Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET")
|
||||
?? throw new InvalidOperationException("CONFIRMATION_TOKEN_SECRET environment variable is not set");
|
||||
?? throw new InvalidOperationException(
|
||||
"CONFIRMATION_TOKEN_SECRET environment variable is not set");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates an access token for the given user, signed with the access token secret and
|
||||
/// expiring after <see cref="TokenServiceExpirationHours.AccessTokenHours"/> hours.
|
||||
/// Generates an access token for the given user, signed with the access token secret and
|
||||
/// expiring after <see cref="TokenServiceExpirationHours.AccessTokenHours" /> hours.
|
||||
/// </summary>
|
||||
/// <param name="user">The user account to generate the token for.</param>
|
||||
/// <returns>The signed access token string.</returns>
|
||||
public string GenerateAccessToken(UserAccount user)
|
||||
{
|
||||
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
|
||||
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
|
||||
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _accessTokenSecret);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a refresh token for the given user, signed with the refresh token secret and
|
||||
/// expiring after <see cref="TokenServiceExpirationHours.RefreshTokenHours"/> hours.
|
||||
/// Generates a refresh token for the given user, signed with the refresh token secret and
|
||||
/// expiring after <see cref="TokenServiceExpirationHours.RefreshTokenHours" /> hours.
|
||||
/// </summary>
|
||||
/// <param name="user">The user account to generate the token for.</param>
|
||||
/// <returns>The signed refresh token string.</returns>
|
||||
public string GenerateRefreshToken(UserAccount user)
|
||||
{
|
||||
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.RefreshTokenHours);
|
||||
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.RefreshTokenHours);
|
||||
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _refreshTokenSecret);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a confirmation token for the given user, signed with the confirmation token secret and
|
||||
/// expiring after <see cref="TokenServiceExpirationHours.ConfirmationTokenHours"/> hours.
|
||||
/// Generates a confirmation token for the given user, signed with the confirmation token secret and
|
||||
/// expiring after <see cref="TokenServiceExpirationHours.ConfirmationTokenHours" /> hours.
|
||||
/// </summary>
|
||||
/// <param name="user">The user account to generate the token for.</param>
|
||||
/// <returns>The signed confirmation token string.</returns>
|
||||
public string GenerateConfirmationToken(UserAccount user)
|
||||
{
|
||||
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.ConfirmationTokenHours);
|
||||
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.ConfirmationTokenHours);
|
||||
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _confirmationTokenSecret);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a token of the kind specified by <typeparamref name="T"/>, dispatching to
|
||||
/// <see cref="GenerateAccessToken"/>, <see cref="GenerateRefreshToken"/>, or
|
||||
/// <see cref="GenerateConfirmationToken"/> based on the corresponding <see cref="TokenType"/> value.
|
||||
/// Generates a token of the kind specified by <typeparamref name="T" />, dispatching to
|
||||
/// <see cref="GenerateAccessToken" />, <see cref="GenerateRefreshToken" />, or
|
||||
/// <see cref="GenerateConfirmationToken" /> based on the corresponding <see cref="TokenType" /> value.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType"/>.</typeparam>
|
||||
/// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType" />.</typeparam>
|
||||
/// <param name="user">The user account to generate the token for.</param>
|
||||
/// <returns>The signed token string corresponding to the requested token type.</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when <typeparamref name="T"/> is not <see cref="TokenType"/> or does not resolve to a known token type.
|
||||
/// Thrown when <typeparamref name="T" /> is not <see cref="TokenType" /> or does not resolve to a known token type.
|
||||
/// </exception>
|
||||
public string GenerateToken<T>(UserAccount user) where T : struct, Enum
|
||||
{
|
||||
if (typeof(T) != typeof(TokenType))
|
||||
throw new InvalidOperationException("Invalid token type");
|
||||
|
||||
var tokenTypeName = typeof(T).Name;
|
||||
if (!Enum.TryParse(typeof(TokenType), tokenTypeName, out var parsed))
|
||||
string tokenTypeName = typeof(T).Name;
|
||||
if (!Enum.TryParse(typeof(TokenType), tokenTypeName, out object? parsed))
|
||||
throw new InvalidOperationException("Invalid token type");
|
||||
|
||||
var tokenType = (TokenType)parsed;
|
||||
TokenType tokenType = (TokenType)parsed;
|
||||
return tokenType switch
|
||||
{
|
||||
TokenType.AccessToken => GenerateAccessToken(user),
|
||||
TokenType.RefreshToken => GenerateRefreshToken(user),
|
||||
TokenType.ConfirmationToken => GenerateConfirmationToken(user),
|
||||
_ => throw new InvalidOperationException("Invalid token type"),
|
||||
_ => throw new InvalidOperationException("Invalid token type")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates an access token against the access token secret.
|
||||
/// Validates an access token against the access token secret.
|
||||
/// </summary>
|
||||
/// <param name="token">The access token string to validate.</param>
|
||||
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns>
|
||||
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
|
||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||
/// </exception>
|
||||
public async Task<ValidatedToken> ValidateAccessTokenAsync(string token)
|
||||
=> await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
|
||||
{
|
||||
return await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a refresh token against the refresh token secret.
|
||||
/// Validates a refresh token against the refresh token secret.
|
||||
/// </summary>
|
||||
/// <param name="token">The refresh token string to validate.</param>
|
||||
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns>
|
||||
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
|
||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||
/// </exception>
|
||||
public async Task<ValidatedToken> ValidateRefreshTokenAsync(string token)
|
||||
=> await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
|
||||
{
|
||||
return await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a confirmation token against the confirmation token secret.
|
||||
/// Validates a confirmation token against the confirmation token secret.
|
||||
/// </summary>
|
||||
/// <param name="token">The confirmation token string to validate.</param>
|
||||
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns>
|
||||
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
|
||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||
/// </exception>
|
||||
public async Task<ValidatedToken> ValidateConfirmationTokenAsync(string token)
|
||||
=> await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
|
||||
{
|
||||
return await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// Validates the given refresh token, looks up the associated user, and issues a fresh
|
||||
/// access/refresh token pair.
|
||||
/// </summary>
|
||||
/// <param name="refreshTokenString">The refresh token string to validate and exchange.</param>
|
||||
/// <returns>A <see cref="RefreshTokenResult" /> containing the user and newly issued tokens.</returns>
|
||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||
/// Thrown when the refresh token is invalid, or when the user account it refers to no longer exists.
|
||||
/// </exception>
|
||||
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
|
||||
{
|
||||
ValidatedToken validated = await ValidateRefreshTokenAsync(refreshTokenString);
|
||||
UserAccount? user = await _authRepository.GetUserByIdAsync(validated.UserId);
|
||||
if (user == null)
|
||||
throw new UnauthorizedException("User account not found");
|
||||
|
||||
string newAccess = GenerateAccessToken(user);
|
||||
string newRefresh = GenerateRefreshToken(user);
|
||||
|
||||
return new RefreshTokenResult(user, 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="token">The token string to validate.</param>
|
||||
/// <param name="secret">The secret key to validate the token's signature against.</param>
|
||||
/// <param name="tokenType">A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages.</param>
|
||||
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns>
|
||||
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
|
||||
/// <exception cref="Domain.Exceptions.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).
|
||||
/// 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
|
||||
{
|
||||
var principal = await _tokenInfrastructure.ValidateJwtAsync(token, secret);
|
||||
ClaimsPrincipal principal = await _tokenInfrastructure.ValidateJwtAsync(token, secret);
|
||||
|
||||
var userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
|
||||
var usernameClaim = principal.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
|
||||
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 var userId))
|
||||
if (!Guid.TryParse(userIdClaim, out Guid userId))
|
||||
throw new UnauthorizedException($"Invalid {tokenType} token: malformed user ID");
|
||||
|
||||
return new ValidatedToken(userId, usernameClaim, principal);
|
||||
@@ -185,26 +215,4 @@ public class TokenService : ITokenService
|
||||
throw new UnauthorizedException($"Failed to validate {tokenType} token: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the given refresh token, looks up the associated user, and issues a fresh
|
||||
/// access/refresh token pair.
|
||||
/// </summary>
|
||||
/// <param name="refreshTokenString">The refresh token string to validate and exchange.</param>
|
||||
/// <returns>A <see cref="RefreshTokenResult"/> containing the user and newly issued tokens.</returns>
|
||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||
/// Thrown when the refresh token is invalid, or when the user account it refers to no longer exists.
|
||||
/// </exception>
|
||||
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
|
||||
{
|
||||
var validated = await ValidateRefreshTokenAsync(refreshTokenString);
|
||||
var user = await _authRepository.GetUserByIdAsync(validated.UserId);
|
||||
if (user == null)
|
||||
throw new UnauthorizedException("User account not found");
|
||||
|
||||
var newAccess = GenerateAccessToken(user);
|
||||
var newRefresh = GenerateRefreshToken(user);
|
||||
|
||||
return new RefreshTokenResult(user, newRefresh, newAccess);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user