mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Add user avatar and profile feature, consolidate Auth and UserManagement features (#257)
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Features.Auth.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the kind of token being generated or validated.
|
||||
/// </summary>
|
||||
public enum TokenType
|
||||
{
|
||||
/// <summary>A short-lived token used to authorize API requests.</summary>
|
||||
AccessToken,
|
||||
|
||||
/// <summary>A long-lived token used to obtain new access tokens.</summary>
|
||||
RefreshToken,
|
||||
|
||||
/// <summary>A short-lived token used to confirm a user's email/account.</summary>
|
||||
ConfirmationToken,
|
||||
}
|
||||
|
||||
/// <summary>Represents the result of successfully validating a token.</summary>
|
||||
public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal);
|
||||
|
||||
/// <summary>Represents the result of refreshing a user's session.</summary>
|
||||
public record RefreshTokenResult(
|
||||
Guid UserId,
|
||||
string Username,
|
||||
string RefreshToken,
|
||||
string AccessToken
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Defines the expiration windows, in hours, for each type of token issued by <see cref="ITokenService" />.
|
||||
/// </summary>
|
||||
public static class TokenServiceExpirationHours
|
||||
{
|
||||
/// <summary>1 hour.</summary>
|
||||
public const double AccessTokenHours = 1;
|
||||
|
||||
/// <summary>21 days.</summary>
|
||||
public const double RefreshTokenHours = 504;
|
||||
|
||||
/// <summary>30 minutes.</summary>
|
||||
public const double ConfirmationTokenHours = 0.5;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines operations for generating and validating JWTs used for access, refresh, and account confirmation.
|
||||
/// </summary>
|
||||
public interface ITokenService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a signed access token for the given user, expiring after
|
||||
/// <see cref="TokenServiceExpirationHours.AccessTokenHours" /> hours.
|
||||
/// </summary>
|
||||
string GenerateAccessToken(Guid userId, string username);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a signed refresh token for the given user, expiring after
|
||||
/// <see cref="TokenServiceExpirationHours.RefreshTokenHours" /> hours.
|
||||
/// </summary>
|
||||
string GenerateRefreshToken(Guid userId, string username);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a signed confirmation token for the given user, expiring after
|
||||
/// <see cref="TokenServiceExpirationHours.ConfirmationTokenHours" /> hours.
|
||||
/// </summary>
|
||||
string GenerateConfirmationToken(Guid userId, string username);
|
||||
|
||||
/// <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);
|
||||
}
|
||||
150
web/backend/Features/Features.Users/Services/TokenService.cs
Normal file
150
web/backend/Features/Features.Users/Services/TokenService.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Identity;
|
||||
using Infrastructure.Configuration;
|
||||
using Infrastructure.Jwt;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Features.Auth.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="ITokenService" /> that generates and validates JWTs
|
||||
/// for access, refresh, and confirmation flows using secrets read from configuration.
|
||||
/// </summary>
|
||||
public class TokenService : ITokenService
|
||||
{
|
||||
private readonly string _accessTokenSecret;
|
||||
private readonly string _confirmationTokenSecret;
|
||||
private readonly string _refreshTokenSecret;
|
||||
private readonly ITokenInfrastructure _tokenInfrastructure;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when any of the <c>ACCESS_TOKEN_SECRET</c>, <c>REFRESH_TOKEN_SECRET</c>, or
|
||||
/// <c>CONFIRMATION_TOKEN_SECRET</c> configuration values are not set.
|
||||
/// </exception>
|
||||
public TokenService(
|
||||
ITokenInfrastructure tokenInfrastructure,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IConfiguration configuration
|
||||
)
|
||||
{
|
||||
_tokenInfrastructure = tokenInfrastructure;
|
||||
_userManager = userManager;
|
||||
|
||||
_accessTokenSecret = ConfigurationHelpers.GetKeyOrThrow(
|
||||
configuration,
|
||||
ConfigurationKeys.AccessTokenSecret
|
||||
);
|
||||
|
||||
_refreshTokenSecret = ConfigurationHelpers.GetKeyOrThrow(
|
||||
configuration,
|
||||
ConfigurationKeys.RefreshTokenSecret
|
||||
);
|
||||
|
||||
_confirmationTokenSecret = ConfigurationHelpers.GetKeyOrThrow(
|
||||
configuration,
|
||||
ConfigurationKeys.ConfirmationTokenSecret
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GenerateAccessToken(Guid userId, string username)
|
||||
{
|
||||
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
|
||||
return _tokenInfrastructure.GenerateJwt(userId, username, expiresAt, _accessTokenSecret);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GenerateRefreshToken(Guid userId, string username)
|
||||
{
|
||||
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
||||
TokenServiceExpirationHours.RefreshTokenHours
|
||||
);
|
||||
return _tokenInfrastructure.GenerateJwt(userId, username, expiresAt, _refreshTokenSecret);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GenerateConfirmationToken(Guid userId, string username)
|
||||
{
|
||||
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
||||
TokenServiceExpirationHours.ConfirmationTokenHours
|
||||
);
|
||||
return _tokenInfrastructure.GenerateJwt(
|
||||
userId,
|
||||
username,
|
||||
expiresAt,
|
||||
_confirmationTokenSecret
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ValidatedToken> ValidateRefreshTokenAsync(string token)
|
||||
{
|
||||
return await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ValidatedToken> ValidateConfirmationTokenAsync(string token)
|
||||
{
|
||||
return await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
|
||||
{
|
||||
ValidatedToken validated = await ValidateRefreshTokenAsync(refreshTokenString);
|
||||
ApplicationUser? user = await _userManager.FindByIdAsync(validated.UserId.ToString());
|
||||
if (user == null)
|
||||
throw new UnauthorizedException("User account not found");
|
||||
|
||||
string newAccess = GenerateAccessToken(user.Id, user.UserName);
|
||||
string newRefresh = GenerateRefreshToken(user.Id, user.UserName);
|
||||
|
||||
return new RefreshTokenResult(user.Id, user.UserName, newRefresh, newAccess);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs the shared validation logic for access, refresh, and confirmation tokens:
|
||||
/// validates the JWT signature/expiration, then extracts and parses the user ID and username claims.
|
||||
/// </summary>
|
||||
/// <param name="tokenType">A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages.</param>
|
||||
/// <exception cref="UnauthorizedException">
|
||||
/// Thrown when required claims are missing, the user ID claim is not a valid <see cref="Guid" />,
|
||||
/// or the underlying token validation fails for any other reason (e.g. expired or invalid signature).
|
||||
/// </exception>
|
||||
private async Task<ValidatedToken> ValidateTokenInternalAsync(
|
||||
string token,
|
||||
string secret,
|
||||
string tokenType
|
||||
)
|
||||
{
|
||||
try
|
||||
{
|
||||
ClaimsPrincipal principal = await _tokenInfrastructure.ValidateJwtAsync(token, secret);
|
||||
|
||||
string? userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
|
||||
string? usernameClaim = principal.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(userIdClaim) || string.IsNullOrEmpty(usernameClaim))
|
||||
throw new UnauthorizedException(
|
||||
$"Invalid {tokenType} token: missing required claims"
|
||||
);
|
||||
|
||||
if (!Guid.TryParse(userIdClaim, out Guid userId))
|
||||
throw new UnauthorizedException($"Invalid {tokenType} token: malformed user ID");
|
||||
|
||||
return new ValidatedToken(userId, usernameClaim, principal);
|
||||
}
|
||||
catch (UnauthorizedException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new UnauthorizedException($"Failed to validate {tokenType} token: {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user