mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-07-16 17:47:22 +00:00
Format ./web/backend/Features/Features.Auth
This commit is contained in:
@@ -13,21 +13,25 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
"/",
|
||||||
|
new ResponseBody<RegistrationPayload>
|
||||||
{
|
{
|
||||||
Message = "User registered successfully.",
|
Message = "User registered successfully.",
|
||||||
Payload = payload
|
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.",
|
Message = "Logged in successfully.",
|
||||||
Payload = payload
|
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.",
|
Message = "User with ID " + payload.UserAccountId + " is confirmed.",
|
||||||
Payload = payload
|
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.",
|
Message = "Token refreshed successfully.",
|
||||||
Payload = payload
|
Payload = payload,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,11 +327,7 @@ 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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET")
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"ACCESS_TOKEN_SECRET environment variable is not set");
|
"ACCESS_TOKEN_SECRET environment variable is not set"
|
||||||
|
);
|
||||||
|
|
||||||
_refreshTokenSecret = Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET")
|
_refreshTokenSecret =
|
||||||
|
Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET")
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"REFRESH_TOKEN_SECRET environment variable is not set");
|
"REFRESH_TOKEN_SECRET environment variable is not set"
|
||||||
|
);
|
||||||
|
|
||||||
_confirmationTokenSecret = Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET")
|
_confirmationTokenSecret =
|
||||||
|
Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET")
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"CONFIRMATION_TOKEN_SECRET environment variable is not set");
|
"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");
|
||||||
|
|||||||
Reference in New Issue
Block a user