Format ./web/backend/Features/Features.Auth

This commit is contained in:
Aaron Po
2026-06-20 15:09:40 -04:00
parent 254431928f
commit 79ae18b3e2
21 changed files with 189 additions and 146 deletions

View File

@@ -7,4 +7,4 @@ namespace Features.Auth.Commands.ConfirmUser;
/// Validates a confirmation token and confirms the corresponding user account. /// Validates a confirmation token and confirms the corresponding user account.
/// </summary> /// </summary>
/// <param name="Token">The confirmation token issued to the user, typically delivered via email.</param> /// <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>;

View File

@@ -13,22 +13,26 @@ namespace Features.Auth.Commands.ConfirmUser;
/// </summary> /// </summary>
/// <param name="authRepository">Repository used to look up and confirm user accounts.</param> /// <param name="authRepository">Repository used to look up and confirm user accounts.</param>
/// <param name="tokenService">Service used to validate the confirmation token.</param> /// <param name="tokenService">Service used to validate the confirmation token.</param>
public class ConfirmUserHandler( public class ConfirmUserHandler(IAuthRepository authRepository, ITokenService tokenService)
IAuthRepository authRepository, : IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
ITokenService tokenService
) : IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
{ {
/// <exception cref="UnauthorizedException"> /// <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> /// </exception>
public async Task<ConfirmationPayload> Handle(ConfirmUserCommand request, CancellationToken cancellationToken) public async Task<ConfirmationPayload> Handle(
ConfirmUserCommand request,
CancellationToken cancellationToken
)
{ {
ValidatedToken validatedToken = await tokenService.ValidateConfirmationTokenAsync(request.Token); ValidatedToken validatedToken = await tokenService.ValidateConfirmationTokenAsync(
request.Token
);
UserAccount? 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); return new ConfirmationPayload(user.UserAccountId, DateTime.UtcNow);
} }
} }

View File

@@ -7,4 +7,4 @@ namespace Features.Auth.Commands.RefreshToken;
/// Exchanges a valid refresh token for a new access/refresh token pair. Bound directly from the /// 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>. /// request body of <c>POST /api/auth/refresh</c>.
/// </summary> /// </summary>
public record RefreshTokenCommand(string RefreshToken) : IRequest<LoginPayload>; public record RefreshTokenCommand(string RefreshToken) : IRequest<LoginPayload>;

View File

@@ -12,10 +12,17 @@ namespace Features.Auth.Commands.RefreshToken;
public class RefreshTokenHandler(ITokenService tokenService) public class RefreshTokenHandler(ITokenService tokenService)
: IRequestHandler<RefreshTokenCommand, LoginPayload> : IRequestHandler<RefreshTokenCommand, LoginPayload>
{ {
public async Task<LoginPayload> Handle(RefreshTokenCommand request, CancellationToken cancellationToken) public async Task<LoginPayload> Handle(
RefreshTokenCommand request,
CancellationToken cancellationToken
)
{ {
RefreshTokenResult result = await tokenService.RefreshTokenAsync(request.RefreshToken); RefreshTokenResult result = await tokenService.RefreshTokenAsync(request.RefreshToken);
return new LoginPayload(result.UserAccount.UserAccountId, result.UserAccount.Username, result.RefreshToken, return new LoginPayload(
result.AccessToken); result.UserAccount.UserAccountId,
result.UserAccount.Username,
result.RefreshToken,
result.AccessToken
);
} }
} }

View File

@@ -12,8 +12,6 @@ public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
/// </summary> /// </summary>
public RefreshTokenValidator() public RefreshTokenValidator()
{ {
RuleFor(x => x.RefreshToken) RuleFor(x => x.RefreshToken).NotEmpty().WithMessage("Refresh token is required");
.NotEmpty()
.WithMessage("Refresh token is required");
} }
} }

View File

@@ -25,4 +25,4 @@ public record RegisterUserCommand(
string Email, string Email,
DateTime DateOfBirth, DateTime DateOfBirth,
string Password string Password
) : IRequest<RegistrationPayload>; ) : IRequest<RegistrationPayload>;

View File

@@ -28,7 +28,10 @@ public class RegisterUserHandler(
/// <exception cref="ConflictException"> /// <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> /// </exception>
public async Task<RegistrationPayload> Handle(RegisterUserCommand request, CancellationToken cancellationToken) public async Task<RegistrationPayload> Handle(
RegisterUserCommand request,
CancellationToken cancellationToken
)
{ {
await ValidateUserDoesNotExist(request.Username, request.Email); await ValidateUserDoesNotExist(request.Username, request.Email);
@@ -48,14 +51,23 @@ public class RegisterUserHandler(
string confirmationToken = tokenService.GenerateConfirmationToken(createdUser); string confirmationToken = tokenService.GenerateConfirmationToken(createdUser);
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken)) if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, string.Empty, string.Empty, return new RegistrationPayload(
false); createdUser.UserAccountId,
createdUser.Username,
string.Empty,
string.Empty,
false
);
bool emailSent = false; bool emailSent = false;
try try
{ {
await mediator.Send( await mediator.Send(
new SendRegistrationEmailCommand(createdUser.FirstName, createdUser.Email, confirmationToken), new SendRegistrationEmailCommand(
createdUser.FirstName,
createdUser.Email,
confirmationToken
),
cancellationToken cancellationToken
); );
emailSent = true; emailSent = true;
@@ -67,8 +79,13 @@ public class RegisterUserHandler(
// ignored // ignored
} }
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, refreshToken, accessToken, return new RegistrationPayload(
emailSent); createdUser.UserAccountId,
createdUser.Username,
refreshToken,
accessToken,
emailSent
);
} }
/// <exception cref="ConflictException"> /// <exception cref="ConflictException">
@@ -82,4 +99,4 @@ public class RegisterUserHandler(
if (existingUsername != null || existingEmail != null) if (existingUsername != null || existingEmail != null)
throw new ConflictException("Username or email already exists"); throw new ConflictException("Username or email already exists");
} }
} }

View File

@@ -61,8 +61,6 @@ public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
.Matches("[0-9]") .Matches("[0-9]")
.WithMessage("Password must contain at least one number") .WithMessage("Password must contain at least one number")
.Matches("[^a-zA-Z0-9]") .Matches("[^a-zA-Z0-9]")
.WithMessage( .WithMessage("Password must contain at least one special character");
"Password must contain at least one special character"
);
} }
} }

View File

@@ -6,4 +6,4 @@ namespace Features.Auth.Commands.ResendConfirmationEmail;
/// 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> /// </summary>
/// <param name="UserId">The unique identifier of the user requesting the resend.</param> /// <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;

View File

@@ -23,12 +23,17 @@ public class ResendConfirmationEmailHandler(
IMediator mediator IMediator mediator
) : IRequestHandler<ResendConfirmationEmailCommand> ) : IRequestHandler<ResendConfirmationEmailCommand>
{ {
public async Task Handle(ResendConfirmationEmailCommand request, CancellationToken cancellationToken) public async Task Handle(
ResendConfirmationEmailCommand request,
CancellationToken cancellationToken
)
{ {
UserAccount? user = await authRepository.GetUserByIdAsync(request.UserId); UserAccount? user = await authRepository.GetUserByIdAsync(request.UserId);
if (user == null) return; // Silent return to prevent user enumeration 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
string confirmationToken = tokenService.GenerateConfirmationToken(user); string confirmationToken = tokenService.GenerateConfirmationToken(user);
await mediator.Send( await mediator.Send(
@@ -36,4 +41,4 @@ public class ResendConfirmationEmailHandler(
cancellationToken cancellationToken
); );
} }
} }

View File

@@ -40,11 +40,14 @@ public class AuthController(IMediator mediator) : ControllerBase
) )
{ {
RegistrationPayload payload = await mediator.Send(command); RegistrationPayload payload = await mediator.Send(command);
return Created("/", new ResponseBody<RegistrationPayload> return Created(
{ "/",
Message = "User registered successfully.", new ResponseBody<RegistrationPayload>
Payload = payload {
}); Message = "User registered successfully.",
Payload = payload,
}
);
} }
/// <summary> /// <summary>
@@ -61,11 +64,13 @@ public class AuthController(IMediator mediator) : ControllerBase
public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginQuery query) public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginQuery query)
{ {
LoginPayload payload = await mediator.Send(query); LoginPayload payload = await mediator.Send(query);
return Ok(new ResponseBody<LoginPayload> return Ok(
{ new ResponseBody<LoginPayload>
Message = "Logged in successfully.", {
Payload = payload Message = "Logged in successfully.",
}); Payload = payload,
}
);
} }
/// <summary> /// <summary>
@@ -78,14 +83,18 @@ public class AuthController(IMediator mediator) : ControllerBase
/// <param name="token">The confirmation token supplied via the confirmation email link.</param> /// <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> /// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="ConfirmationPayload" />.</returns>
[HttpPost("confirm")] [HttpPost("confirm")]
public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm([FromQuery] string token) public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm(
[FromQuery] string token
)
{ {
ConfirmationPayload payload = await mediator.Send(new ConfirmUserCommand(token)); ConfirmationPayload payload = await mediator.Send(new ConfirmUserCommand(token));
return Ok(new ResponseBody<ConfirmationPayload> return Ok(
{ new ResponseBody<ConfirmationPayload>
Message = "User with ID " + payload.UserAccountId + " is confirmed.", {
Payload = payload Message = "User with ID " + payload.UserAccountId + " is confirmed.",
}); Payload = payload,
}
);
} }
/// <summary> /// <summary>
@@ -119,10 +128,12 @@ public class AuthController(IMediator mediator) : ControllerBase
) )
{ {
LoginPayload payload = await mediator.Send(command); LoginPayload payload = await mediator.Send(command);
return Ok(new ResponseBody<LoginPayload> return Ok(
{ new ResponseBody<LoginPayload>
Message = "Token refreshed successfully.", {
Payload = payload Message = "Token refreshed successfully.",
}); Payload = payload,
}
);
} }
} }

View File

@@ -17,4 +17,4 @@ public static class FeaturesAuthServiceCollectionExtensions
services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>(); services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
return services; return services;
} }
} }

View File

@@ -35,4 +35,4 @@ public record RegistrationPayload(
/// </summary> /// </summary>
/// <param name="UserAccountId">The unique identifier of the user account that was confirmed.</param> /// <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> /// <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);

View File

@@ -7,16 +7,16 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App"/> <FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj"/> <ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj"/> <ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj"/> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj"/> <ProjectReference Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj"/> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj"/> <ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj"/> <ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -46,4 +46,4 @@ public class LoginHandler(
return new LoginPayload(user.UserAccountId, user.Username, refreshToken, accessToken); return new LoginPayload(user.UserAccountId, user.Username, refreshToken, accessToken);
} }
} }

View File

@@ -7,4 +7,4 @@ namespace Features.Auth.Queries.Login;
/// Authenticates a user using their username and password and issues new tokens. Bound directly /// 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>. /// from the request body of <c>POST /api/auth/login</c>.
/// </summary> /// </summary>
public record LoginQuery(string Username, string Password) : IRequest<LoginPayload>; public record LoginQuery(string Username, string Password) : IRequest<LoginPayload>;

View File

@@ -17,4 +17,4 @@ public class LoginValidator : AbstractValidator<LoginQuery>
RuleFor(x => x.Password).NotEmpty().WithMessage("Password is required"); RuleFor(x => x.Password).NotEmpty().WithMessage("Password is required");
} }
} }

View File

@@ -81,8 +81,8 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
} }
} }
return await GetUserByIdAsync(userAccountId) ?? return await GetUserByIdAsync(userAccountId)
throw new Exception("Failed to retrieve newly registered user."); ?? throw new Exception("Failed to retrieve newly registered user.");
} }
/// <summary> /// <summary>
@@ -92,9 +92,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <param name="email">Email address to search for</param> /// <param name="email">Email address to search for</param>
/// <returns>UserAccount if found, null otherwise</returns> /// <returns>UserAccount if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception> /// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<UserAccount?> GetUserByEmailAsync( public async Task<UserAccount?> GetUserByEmailAsync(string email)
string email
)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
@@ -114,9 +112,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <param name="username">Username to search for</param> /// <param name="username">Username to search for</param>
/// <returns>UserAccount if found, null otherwise</returns> /// <returns>UserAccount if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception> /// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<UserAccount?> GetUserByUsernameAsync( public async Task<UserAccount?> GetUserByUsernameAsync(string username)
string username
)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
@@ -136,9 +132,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <param name="userAccountId">ID of the user account</param> /// <param name="userAccountId">ID of the user account</param>
/// <returns>Active UserCredential if found, null otherwise</returns> /// <returns>Active UserCredential if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception> /// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync( public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId)
Guid userAccountId
)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
@@ -158,10 +152,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <param name="userAccountId">ID of the user account</param> /// <param name="userAccountId">ID of the user account</param>
/// <param name="newPasswordHash">New hashed password</param> /// <param name="newPasswordHash">New hashed password</param>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception> /// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task RotateCredentialAsync( public async Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash)
Guid userAccountId,
string newPasswordHash
)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
@@ -180,9 +171,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <param name="userAccountId">ID of the user account</param> /// <param name="userAccountId">ID of the user account</param>
/// <returns>UserAccount if found, null otherwise</returns> /// <returns>UserAccount if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception> /// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<UserAccount?> GetUserByIdAsync( public async Task<UserAccount?> GetUserByIdAsync(Guid userAccountId)
Guid userAccountId
)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
@@ -207,15 +196,15 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// Thrown when the database command fails for a reason other than /// Thrown when the database command fails for a reason other than
/// a duplicate verification record. /// a duplicate verification record.
/// </exception> /// </exception>
public async Task<UserAccount?> ConfirmUserAccountAsync( public async Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId)
Guid userAccountId
)
{ {
UserAccount? user = await GetUserByIdAsync(userAccountId); UserAccount? user = await GetUserByIdAsync(userAccountId);
if (user == null) return null; if (user == null)
return null;
// Idempotency: if already verified, treat as successful confirmation. // Idempotency: if already verified, treat as successful confirmation.
if (await IsUserVerifiedAsync(userAccountId)) return user; if (await IsUserVerifiedAsync(userAccountId))
return user;
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
@@ -270,15 +259,12 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
return ex.Number == 2601 || ex.Number == 2627; return ex.Number == 2601 || ex.Number == 2627;
} }
/// <summary> /// <summary>
/// Maps a data reader row to a UserAccount entity. /// Maps a data reader row to a UserAccount entity.
/// </summary> /// </summary>
/// <param name="reader">The data reader positioned on the row to map.</param> /// <param name="reader">The data reader positioned on the row to map.</param>
/// <returns>The mapped <see cref="Domain.Entities.UserAccount" /> instance.</returns> /// <returns>The mapped <see cref="Domain.Entities.UserAccount" /> instance.</returns>
protected override UserAccount MapToEntity( protected override UserAccount MapToEntity(DbDataReader reader)
DbDataReader reader
)
{ {
return new UserAccount return new UserAccount
{ {
@@ -292,9 +278,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
? null ? null
: reader.GetDateTime(reader.GetOrdinal("UpdatedAt")), : reader.GetDateTime(reader.GetOrdinal("UpdatedAt")),
DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")), DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")),
Timer = reader.IsDBNull(reader.GetOrdinal("Timer")) Timer = reader.IsDBNull(reader.GetOrdinal("Timer")) ? null : (byte[])reader["Timer"],
? null
: (byte[])reader["Timer"]
}; };
} }
@@ -308,12 +292,10 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
{ {
UserCredential entity = new() UserCredential entity = new()
{ {
UserCredentialId = reader.GetGuid( UserCredentialId = reader.GetGuid(reader.GetOrdinal("UserCredentialId")),
reader.GetOrdinal("UserCredentialId")
),
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")), UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
Hash = reader.GetString(reader.GetOrdinal("Hash")), Hash = reader.GetString(reader.GetOrdinal("Hash")),
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")) CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")),
}; };
// Optional columns // Optional columns
@@ -327,7 +309,8 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
"Timer", "Timer",
StringComparison.OrdinalIgnoreCase StringComparison.OrdinalIgnoreCase
) )
) ?? false; )
?? false;
if (hasTimer) if (hasTimer)
entity.Timer = reader.IsDBNull(reader.GetOrdinal("Timer")) entity.Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
@@ -344,15 +327,11 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <param name="command">The command to add the parameter to.</param> /// <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="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( private static void AddParameter(DbCommand command, string name, object? value)
DbCommand command,
string name,
object? value
)
{ {
DbParameter p = command.CreateParameter(); DbParameter p = command.CreateParameter();
p.ParameterName = name; p.ParameterName = name;
p.Value = value ?? DBNull.Value; p.Value = value ?? DBNull.Value;
command.Parameters.Add(p); command.Parameters.Add(p);
} }
} }

View File

@@ -49,9 +49,7 @@ public interface IAuthRepository
/// </summary> /// </summary>
/// <param name="userAccountId">ID of the user account</param> /// <param name="userAccountId">ID of the user account</param>
/// <returns>Active UserCredential if found, null otherwise</returns> /// <returns>Active UserCredential if found, null otherwise</returns>
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync( Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId);
Guid userAccountId
);
/// <summary> /// <summary>
/// Rotates a user's credential by invalidating all existing credentials and creating a new one. /// Rotates a user's credential by invalidating all existing credentials and creating a new one.
@@ -81,4 +79,4 @@ public interface IAuthRepository
/// <param name="userAccountId">ID of the user account</param> /// <param name="userAccountId">ID of the user account</param>
/// <returns>True if the user has a verification record, false otherwise</returns> /// <returns>True if the user has a verification record, false otherwise</returns>
Task<bool> IsUserVerifiedAsync(Guid userAccountId); Task<bool> IsUserVerifiedAsync(Guid userAccountId);
} }

View File

@@ -15,7 +15,7 @@ public enum TokenType
RefreshToken, RefreshToken,
/// <summary>A short-lived token used to confirm a user's email/account.</summary> /// <summary>A short-lived token used to confirm a user's email/account.</summary>
ConfirmationToken ConfirmationToken,
} }
/// <summary> /// <summary>
@@ -32,11 +32,7 @@ public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Princ
/// <param name="UserAccount">The user account associated with the refreshed session.</param> /// <param name="UserAccount">The user account associated with the refreshed session.</param>
/// <param name="RefreshToken">The newly issued refresh token.</param> /// <param name="RefreshToken">The newly issued refresh token.</param>
/// <param name="AccessToken">The newly issued access token.</param> /// <param name="AccessToken">The newly issued access token.</param>
public record RefreshTokenResult( public record RefreshTokenResult(UserAccount UserAccount, string RefreshToken, string AccessToken);
UserAccount UserAccount,
string RefreshToken,
string AccessToken
);
/// <summary> /// <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" />.
@@ -85,7 +81,8 @@ public interface ITokenService
/// <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> /// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed token string corresponding to the requested token type.</returns> /// <returns>The signed token string corresponding to the requested token type.</returns>
string GenerateToken<T>(UserAccount user) where T : struct, Enum; string GenerateToken<T>(UserAccount user)
where T : struct, Enum;
/// <summary> /// <summary>
/// Validates an access token. /// Validates an access token.
@@ -114,4 +111,4 @@ public interface ITokenService
/// <param name="refreshTokenString">The refresh token string to validate and exchange.</param> /// <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); Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString);
} }

View File

@@ -29,25 +29,28 @@ public class TokenService : ITokenService
/// Thrown when any of the <c>ACCESS_TOKEN_SECRET</c>, <c>REFRESH_TOKEN_SECRET</c>, or /// 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. /// <c>CONFIRMATION_TOKEN_SECRET</c> environment variables are not set.
/// </exception> /// </exception>
public TokenService( public TokenService(ITokenInfrastructure tokenInfrastructure, IAuthRepository authRepository)
ITokenInfrastructure tokenInfrastructure,
IAuthRepository authRepository
)
{ {
_tokenInfrastructure = tokenInfrastructure; _tokenInfrastructure = tokenInfrastructure;
_authRepository = authRepository; _authRepository = authRepository;
_accessTokenSecret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET") _accessTokenSecret =
?? throw new InvalidOperationException( Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET")
"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") _refreshTokenSecret =
?? throw new InvalidOperationException( Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET")
"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") _confirmationTokenSecret =
?? throw new InvalidOperationException( Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET")
"CONFIRMATION_TOKEN_SECRET environment variable is not set"); ?? throw new InvalidOperationException(
"CONFIRMATION_TOKEN_SECRET environment variable is not set"
);
} }
/// <summary> /// <summary>
@@ -59,7 +62,12 @@ public class TokenService : ITokenService
public string GenerateAccessToken(UserAccount user) public string GenerateAccessToken(UserAccount user)
{ {
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours); DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _accessTokenSecret); return _tokenInfrastructure.GenerateJwt(
user.UserAccountId,
user.Username,
expiresAt,
_accessTokenSecret
);
} }
/// <summary> /// <summary>
@@ -70,8 +78,15 @@ public class TokenService : ITokenService
/// <returns>The signed refresh token string.</returns> /// <returns>The signed refresh token string.</returns>
public string GenerateRefreshToken(UserAccount user) public string GenerateRefreshToken(UserAccount user)
{ {
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.RefreshTokenHours); DateTime expiresAt = DateTime.UtcNow.AddHours(
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _refreshTokenSecret); TokenServiceExpirationHours.RefreshTokenHours
);
return _tokenInfrastructure.GenerateJwt(
user.UserAccountId,
user.Username,
expiresAt,
_refreshTokenSecret
);
} }
/// <summary> /// <summary>
@@ -82,8 +97,15 @@ public class TokenService : ITokenService
/// <returns>The signed confirmation token string.</returns> /// <returns>The signed confirmation token string.</returns>
public string GenerateConfirmationToken(UserAccount user) public string GenerateConfirmationToken(UserAccount user)
{ {
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.ConfirmationTokenHours); DateTime expiresAt = DateTime.UtcNow.AddHours(
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _confirmationTokenSecret); TokenServiceExpirationHours.ConfirmationTokenHours
);
return _tokenInfrastructure.GenerateJwt(
user.UserAccountId,
user.Username,
expiresAt,
_confirmationTokenSecret
);
} }
/// <summary> /// <summary>
@@ -97,7 +119,8 @@ public class TokenService : ITokenService
/// <exception cref="InvalidOperationException"> /// <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> /// </exception>
public string GenerateToken<T>(UserAccount user) where T : struct, Enum public string GenerateToken<T>(UserAccount user)
where T : struct, Enum
{ {
if (typeof(T) != typeof(TokenType)) if (typeof(T) != typeof(TokenType))
throw new InvalidOperationException("Invalid token type"); throw new InvalidOperationException("Invalid token type");
@@ -112,7 +135,7 @@ public class TokenService : ITokenService
TokenType.AccessToken => GenerateAccessToken(user), TokenType.AccessToken => GenerateAccessToken(user),
TokenType.RefreshToken => GenerateRefreshToken(user), TokenType.RefreshToken => GenerateRefreshToken(user),
TokenType.ConfirmationToken => GenerateConfirmationToken(user), TokenType.ConfirmationToken => GenerateConfirmationToken(user),
_ => throw new InvalidOperationException("Invalid token type") _ => throw new InvalidOperationException("Invalid token type"),
}; };
} }
@@ -189,7 +212,11 @@ public class TokenService : ITokenService
/// Thrown when required claims are missing, the user ID claim is not a valid <see cref="Guid" />, /// 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). /// or the underlying token validation fails for any other reason (e.g. expired or invalid signature).
/// </exception> /// </exception>
private async Task<ValidatedToken> ValidateTokenInternalAsync(string token, string secret, string tokenType) private async Task<ValidatedToken> ValidateTokenInternalAsync(
string token,
string secret,
string tokenType
)
{ {
try try
{ {
@@ -199,7 +226,9 @@ public class TokenService : ITokenService
string? usernameClaim = principal.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value; string? usernameClaim = principal.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
if (string.IsNullOrEmpty(userIdClaim) || string.IsNullOrEmpty(usernameClaim)) if (string.IsNullOrEmpty(userIdClaim) || string.IsNullOrEmpty(usernameClaim))
throw new UnauthorizedException($"Invalid {tokenType} token: missing required claims"); throw new UnauthorizedException(
$"Invalid {tokenType} token: missing required claims"
);
if (!Guid.TryParse(userIdClaim, out Guid userId)) if (!Guid.TryParse(userIdClaim, out Guid userId))
throw new UnauthorizedException($"Invalid {tokenType} token: malformed user ID"); throw new UnauthorizedException($"Invalid {tokenType} token: malformed user ID");
@@ -215,4 +244,4 @@ public class TokenService : ITokenService
throw new UnauthorizedException($"Failed to validate {tokenType} token: {e.Message}"); throw new UnauthorizedException($"Failed to validate {tokenType} token: {e.Message}");
} }
} }
} }