mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
Update XML doc comment in backend projects
This commit is contained in:
@@ -11,6 +11,10 @@ namespace Features.Auth.Commands.RefreshToken;
|
||||
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
|
||||
|
||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.RefreshToken;
|
||||
|
||||
/// <summary>Validates that <see cref="RefreshTokenCommand.RefreshToken" /> is present.</summary>
|
||||
public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
|
||||
{
|
||||
public RefreshTokenValidator()
|
||||
|
||||
@@ -2,6 +2,10 @@ 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()
|
||||
|
||||
@@ -23,6 +23,11 @@ namespace Features.Auth.Controllers;
|
||||
[Authorize(AuthenticationSchemes = "JWT")]
|
||||
public class AuthController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
/// <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(
|
||||
@@ -40,6 +45,10 @@ public class AuthController(IMediator mediator) : ControllerBase
|
||||
);
|
||||
}
|
||||
|
||||
/// <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] LoginQuery query)
|
||||
@@ -54,6 +63,11 @@ public class AuthController(IMediator mediator) : ControllerBase
|
||||
);
|
||||
}
|
||||
|
||||
/// <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
|
||||
@@ -69,6 +83,12 @@ public class AuthController(IMediator mediator) : ControllerBase
|
||||
);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
@@ -76,6 +96,11 @@ public class AuthController(IMediator mediator) : ControllerBase
|
||||
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. Returns 200 OK with the new tokens, or 401 Unauthorized if the refresh token is
|
||||
/// invalid or expired.
|
||||
/// </remarks>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("refresh")]
|
||||
public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh(
|
||||
|
||||
@@ -5,8 +5,13 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Features.Auth.DependencyInjection;
|
||||
|
||||
/// <summary>Registers the services required by Features.Auth with the dependency injection container.</summary>
|
||||
public static class FeaturesAuthServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds <see cref="IAuthRepository" />, <see cref="ITokenService" />, and the Argon2 password hashing
|
||||
/// infrastructure as scoped services.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddFeaturesAuth(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IAuthRepository, AuthRepository>();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
namespace Features.Auth.Dtos;
|
||||
|
||||
/// <summary>Data required to persist a new user account and its initial credential.</summary>
|
||||
/// <param name="PasswordHash">The already-hashed password; never a plaintext value.</param>
|
||||
public record UserRegistrationDto(
|
||||
string Username,
|
||||
string FirstName,
|
||||
|
||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Queries.Login;
|
||||
|
||||
/// <summary>Validates that <see cref="LoginQuery.Username" /> and <see cref="LoginQuery.Password" /> are present.</summary>
|
||||
public class LoginValidator : AbstractValidator<LoginQuery>
|
||||
{
|
||||
public LoginValidator()
|
||||
|
||||
@@ -19,11 +19,8 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
private const string SelectColumns =
|
||||
"UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, Timer";
|
||||
|
||||
/// <summary>
|
||||
/// Registers a new user account and its initial credential in a single transaction, then fetches
|
||||
/// and returns the newly created user.
|
||||
/// </summary>
|
||||
/// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception>
|
||||
/// <inheritdoc />
|
||||
/// <remarks>The account and credential are inserted within a single database transaction.</remarks>
|
||||
public async Task<UserAccount> RegisterUserAsync(UserRegistrationDto userRegistrationDto)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
@@ -69,7 +66,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
?? throw new Exception("Failed to retrieve newly registered user.");
|
||||
}
|
||||
|
||||
/// <summary>Retrieves a user account by email.</summary>
|
||||
/// <inheritdoc />
|
||||
public async Task<UserAccount?> GetUserByEmailAsync(string email)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
@@ -79,7 +76,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Retrieves a user account by username.</summary>
|
||||
/// <inheritdoc />
|
||||
public async Task<UserAccount?> GetUserByUsernameAsync(string username)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
@@ -89,7 +86,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Retrieves the active (non-revoked) credential for a user account.</summary>
|
||||
/// <inheritdoc />
|
||||
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
@@ -103,10 +100,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rotates a user's credential by revoking all existing credentials and creating a new one.
|
||||
/// </summary>
|
||||
/// <exception cref="NotFoundException">Thrown when no account with <paramref name="userAccountId" /> exists.</exception>
|
||||
/// <inheritdoc />
|
||||
public async Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
@@ -151,7 +145,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Retrieves a user account by ID.</summary>
|
||||
/// <inheritdoc />
|
||||
public async Task<UserAccount?> GetUserByIdAsync(Guid userAccountId)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
@@ -161,11 +155,12 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a user account as confirmed by inserting a verification record. 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>
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Confirmation is recorded by inserting a row into <c>dbo.UserVerification</c>. 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>
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">
|
||||
/// Thrown when the database command fails for a reason other than a duplicate verification record.
|
||||
/// </exception>
|
||||
@@ -197,7 +192,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
return await GetUserByIdAsync(userAccountId);
|
||||
}
|
||||
|
||||
/// <summary>Checks whether a user account has been verified by querying the <c>dbo.UserVerification</c> table.</summary>
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
|
||||
@@ -8,21 +8,32 @@ namespace Features.Auth.Repository;
|
||||
/// </summary>
|
||||
public interface IAuthRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a new user account and its initial credential as a single atomic operation, then
|
||||
/// returns the created account.
|
||||
/// </summary>
|
||||
/// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception>
|
||||
Task<UserAccount> RegisterUserAsync(UserRegistrationDto userRegistrationDto);
|
||||
|
||||
/// <summary>Retrieves a user account by email, or <see langword="null" /> if none exists.</summary>
|
||||
Task<UserAccount?> GetUserByEmailAsync(string email);
|
||||
|
||||
/// <summary>Retrieves a user account by username, or <see langword="null" /> if none exists.</summary>
|
||||
Task<UserAccount?> GetUserByUsernameAsync(string username);
|
||||
|
||||
/// <summary>Retrieves the active (non-revoked) credential for a user account, or <see langword="null" /> if none exists.</summary>
|
||||
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId);
|
||||
|
||||
/// <summary>Rotates a user's credential by invalidating all existing credentials and creating a new one.</summary>
|
||||
/// <exception cref="Domain.Exceptions.NotFoundException">Thrown when no account with <paramref name="userAccountId" /> exists.</exception>
|
||||
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
|
||||
|
||||
/// <summary>Marks a user account as confirmed. Idempotent if the account is already verified.</summary>
|
||||
Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
|
||||
|
||||
/// <summary>Retrieves a user account by ID, or <see langword="null" /> if none exists.</summary>
|
||||
Task<UserAccount?> GetUserByIdAsync(Guid userAccountId);
|
||||
|
||||
/// <summary>Determines whether a user account has been verified.</summary>
|
||||
Task<bool> IsUserVerifiedAsync(Guid userAccountId);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ public record RefreshTokenResult(UserAccount UserAccount, string RefreshToken, s
|
||||
/// </summary>
|
||||
public static class TokenServiceExpirationHours
|
||||
{
|
||||
/// <summary>1 hour.</summary>
|
||||
public const double AccessTokenHours = 1;
|
||||
|
||||
/// <summary>21 days.</summary>
|
||||
@@ -43,10 +44,22 @@ public static class TokenServiceExpirationHours
|
||||
/// </summary>
|
||||
public interface ITokenService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a signed access token for the given user, expiring after
|
||||
/// <see cref="TokenServiceExpirationHours.AccessTokenHours" /> hours.
|
||||
/// </summary>
|
||||
string GenerateAccessToken(UserAccount user);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a signed refresh token for the given user, expiring after
|
||||
/// <see cref="TokenServiceExpirationHours.RefreshTokenHours" /> hours.
|
||||
/// </summary>
|
||||
string GenerateRefreshToken(UserAccount user);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a signed confirmation token for the given user, expiring after
|
||||
/// <see cref="TokenServiceExpirationHours.ConfirmationTokenHours" /> hours.
|
||||
/// </summary>
|
||||
string GenerateConfirmationToken(UserAccount user);
|
||||
|
||||
/// <summary>Generates a token of the type specified by <typeparamref name="T" />.</summary>
|
||||
@@ -54,12 +67,28 @@ public interface ITokenService
|
||||
string GenerateToken<T>(UserAccount user)
|
||||
where T : struct, Enum;
|
||||
|
||||
/// <summary>Validates an access 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> ValidateAccessTokenAsync(string token);
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ public class TokenService : ITokenService
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GenerateAccessToken(UserAccount user)
|
||||
{
|
||||
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
|
||||
@@ -59,6 +60,7 @@ public class TokenService : ITokenService
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GenerateRefreshToken(UserAccount user)
|
||||
{
|
||||
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
||||
@@ -72,6 +74,7 @@ public class TokenService : ITokenService
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GenerateConfirmationToken(UserAccount user)
|
||||
{
|
||||
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
||||
@@ -109,33 +112,25 @@ public class TokenService : ITokenService
|
||||
};
|
||||
}
|
||||
|
||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||
/// </exception>
|
||||
/// <inheritdoc />
|
||||
public async Task<ValidatedToken> ValidateAccessTokenAsync(string token)
|
||||
{
|
||||
return await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
|
||||
}
|
||||
|
||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||
/// </exception>
|
||||
/// <inheritdoc />
|
||||
public async Task<ValidatedToken> ValidateRefreshTokenAsync(string token)
|
||||
{
|
||||
return await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
|
||||
}
|
||||
|
||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||
/// </exception>
|
||||
/// <inheritdoc />
|
||||
public async Task<ValidatedToken> ValidateConfirmationTokenAsync(string token)
|
||||
{
|
||||
return await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
|
||||
}
|
||||
|
||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||
/// Thrown when the refresh token is invalid, or when the user account it refers to no longer exists.
|
||||
/// </exception>
|
||||
/// <inheritdoc />
|
||||
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
|
||||
{
|
||||
ValidatedToken validated = await ValidateRefreshTokenAsync(refreshTokenString);
|
||||
|
||||
Reference in New Issue
Block a user