Update XML doc comment in backend projects

This commit is contained in:
Aaron Po
2026-08-18 16:28:25 -04:00
parent 6a53167cb8
commit 693f74bb5b
53 changed files with 287 additions and 83 deletions

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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(

View File

@@ -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>();

View File

@@ -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,

View File

@@ -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()

View File

@@ -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();

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);