Add user avatar and profile feature, consolidate Auth and UserManagement features (#257)

This commit is contained in:
Aaron Po
2026-08-31 00:11:48 -04:00
committed by GitHub
parent ade2128a73
commit 39c5a89ca7
130 changed files with 1005 additions and 613 deletions

View File

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

View 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}");
}
}
}