feat(backend): Implement Refresh Token mechanism (Day 5 Phase 1)

Implemented secure refresh token rotation with the following features:
- RefreshToken domain entity with IsExpired(), IsRevoked(), IsActive(), Revoke() methods
- IRefreshTokenService with token generation, rotation, and revocation
- RefreshTokenService with SHA-256 hashing and token family tracking
- RefreshTokenRepository for database operations
- Database migration for refresh_tokens table with proper indexes
- Updated LoginCommandHandler and RegisterTenantCommandHandler to return refresh tokens
- Added POST /api/auth/refresh endpoint (token rotation)
- Added POST /api/auth/logout endpoint (revoke single token)
- Added POST /api/auth/logout-all endpoint (revoke all user tokens)
- Updated JWT access token expiration to 15 minutes (from 60)
- Refresh token expiration set to 7 days
- Security features: token reuse detection, IP address tracking, user-agent logging

Changes:
- Domain: RefreshToken.cs, IRefreshTokenRepository.cs
- Application: IRefreshTokenService.cs, updated LoginResponseDto and RegisterTenantResult
- Infrastructure: RefreshTokenService.cs, RefreshTokenRepository.cs, RefreshTokenConfiguration.cs
- API: AuthController.cs (3 new endpoints), RefreshTokenRequest.cs, LogoutRequest.cs
- Configuration: appsettings.Development.json (updated JWT settings)
- DI: DependencyInjection.cs (registered new services)
- Migration: AddRefreshTokens migration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2025-11-03 14:44:36 +01:00
parent 1f66b25f30
commit 9e2edb2965
32 changed files with 4669 additions and 28 deletions

View File

@@ -0,0 +1,58 @@
using ColaFlow.Modules.Identity.Application.Services;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
namespace ColaFlow.Modules.Identity.Infrastructure.Services;
public class JwtService : IJwtService
{
private readonly IConfiguration _configuration;
public JwtService(IConfiguration configuration)
{
_configuration = configuration;
}
public string GenerateToken(User user, Tenant tenant)
{
var securityKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("JWT SecretKey not configured")));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new(JwtRegisteredClaimNames.Email, user.Email.Value),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new("user_id", user.Id.ToString()),
new("tenant_id", tenant.Id.ToString()),
new("tenant_slug", tenant.Slug.Value),
new("tenant_plan", tenant.Plan.ToString()),
new("full_name", user.FullName.Value),
new("auth_provider", user.AuthProvider.ToString()),
new(ClaimTypes.Role, "User") // TODO: Implement real roles
};
var token = new JwtSecurityToken(
issuer: _configuration["Jwt:Issuer"],
audience: _configuration["Jwt:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddMinutes(Convert.ToDouble(_configuration["Jwt:ExpirationMinutes"] ?? "60")),
signingCredentials: credentials
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public Task<string> GenerateRefreshTokenAsync(User user, CancellationToken cancellationToken = default)
{
// TODO: Implement refresh token generation and storage
throw new NotImplementedException("Refresh token not yet implemented");
}
}

View File

@@ -0,0 +1,16 @@
using ColaFlow.Modules.Identity.Application.Services;
namespace ColaFlow.Modules.Identity.Infrastructure.Services;
public class PasswordHasher : IPasswordHasher
{
public string HashPassword(string password)
{
return BCrypt.Net.BCrypt.HashPassword(password, workFactor: 12);
}
public bool VerifyPassword(string password, string hashedPassword)
{
return BCrypt.Net.BCrypt.Verify(password, hashedPassword);
}
}

View File

@@ -0,0 +1,201 @@
using ColaFlow.Modules.Identity.Application.Services;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Domain.Repositories;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System.Security.Cryptography;
using System.Text;
namespace ColaFlow.Modules.Identity.Infrastructure.Services;
public class RefreshTokenService : IRefreshTokenService
{
private readonly IRefreshTokenRepository _refreshTokenRepository;
private readonly IUserRepository _userRepository;
private readonly ITenantRepository _tenantRepository;
private readonly IJwtService _jwtService;
private readonly IConfiguration _configuration;
private readonly ILogger<RefreshTokenService> _logger;
public RefreshTokenService(
IRefreshTokenRepository refreshTokenRepository,
IUserRepository userRepository,
ITenantRepository tenantRepository,
IJwtService jwtService,
IConfiguration configuration,
ILogger<RefreshTokenService> logger)
{
_refreshTokenRepository = refreshTokenRepository;
_userRepository = userRepository;
_tenantRepository = tenantRepository;
_jwtService = jwtService;
_configuration = configuration;
_logger = logger;
}
public async Task<string> GenerateRefreshTokenAsync(
User user,
string? ipAddress = null,
string? userAgent = null,
CancellationToken cancellationToken = default)
{
// Generate cryptographically secure random token (64 bytes)
var randomBytes = new byte[64];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(randomBytes);
var token = Convert.ToBase64String(randomBytes);
// Hash token before storage (SHA-256)
var tokenHash = ComputeSha256Hash(token);
// Get expiration from configuration (default 7 days)
var expirationDays = _configuration.GetValue<int>("Jwt:RefreshTokenExpirationDays", 7);
var expiresAt = DateTime.UtcNow.AddDays(expirationDays);
// Create refresh token entity
var refreshToken = RefreshToken.Create(
tokenHash: tokenHash,
userId: UserId.Create(user.Id),
tenantId: user.TenantId.Value,
expiresAt: expiresAt,
ipAddress: ipAddress,
userAgent: userAgent,
deviceInfo: userAgent // Use user agent as device info for now
);
// Save to database
await _refreshTokenRepository.AddAsync(refreshToken, cancellationToken);
_logger.LogInformation(
"Generated refresh token for user {UserId}, expires at {ExpiresAt}",
user.Id, expiresAt);
// Return plain text token (only time we return it)
return token;
}
public async Task<(string accessToken, string refreshToken)> RefreshTokenAsync(
string refreshToken,
string? ipAddress = null,
string? userAgent = null,
CancellationToken cancellationToken = default)
{
// Hash the provided token to look it up
var tokenHash = ComputeSha256Hash(refreshToken);
// Find existing token
var existingToken = await _refreshTokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
if (existingToken == null)
{
_logger.LogWarning("Refresh token not found: {TokenHash}", tokenHash[..10] + "...");
throw new UnauthorizedAccessException("Invalid refresh token");
}
// Check if token is active (not expired and not revoked)
if (!existingToken.IsActive())
{
_logger.LogWarning(
"Attempted to use invalid refresh token for user {UserId}. Expired: {IsExpired}, Revoked: {IsRevoked}",
existingToken.UserId.Value, existingToken.IsExpired(), existingToken.IsRevoked());
// SECURITY: Token reuse detection - revoke all user tokens
if (existingToken.IsRevoked())
{
_logger.LogWarning(
"SECURITY ALERT: Revoked token reused for user {UserId}. Revoking all tokens.",
existingToken.UserId.Value);
await RevokeAllUserTokensAsync(existingToken.UserId.Value, cancellationToken);
}
throw new UnauthorizedAccessException("Invalid or expired refresh token");
}
// Get user and tenant
var user = await _userRepository.GetByIdAsync(existingToken.UserId, cancellationToken);
if (user == null || user.Status != UserStatus.Active)
{
_logger.LogWarning("User not found or inactive: {UserId}", existingToken.UserId.Value);
throw new UnauthorizedAccessException("User not found or inactive");
}
var tenant = await _tenantRepository.GetByIdAsync(TenantId.Create(existingToken.TenantId), cancellationToken);
if (tenant == null || tenant.Status != TenantStatus.Active)
{
_logger.LogWarning("Tenant not found or inactive: {TenantId}", existingToken.TenantId);
throw new UnauthorizedAccessException("Tenant not found or inactive");
}
// Generate new access token
var newAccessToken = _jwtService.GenerateToken(user, tenant);
// Generate new refresh token (token rotation)
var newRefreshToken = await GenerateRefreshTokenAsync(user, ipAddress, userAgent, cancellationToken);
// Mark old token as replaced
var newTokenHash = ComputeSha256Hash(newRefreshToken);
existingToken.MarkAsReplaced(newTokenHash);
await _refreshTokenRepository.UpdateAsync(existingToken, cancellationToken);
_logger.LogInformation(
"Rotated refresh token for user {UserId}",
user.Id);
return (newAccessToken, newRefreshToken);
}
public async Task RevokeTokenAsync(
string refreshToken,
string? ipAddress = null,
CancellationToken cancellationToken = default)
{
var tokenHash = ComputeSha256Hash(refreshToken);
var token = await _refreshTokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
if (token == null)
{
_logger.LogWarning("Attempted to revoke non-existent token");
return; // Silent failure for security
}
if (token.IsRevoked())
{
_logger.LogWarning("Token already revoked: {TokenId}", token.Id);
return;
}
var reason = ipAddress != null
? $"User logout from {ipAddress}"
: "User logout";
token.Revoke(reason);
await _refreshTokenRepository.UpdateAsync(token, cancellationToken);
_logger.LogInformation(
"Revoked refresh token {TokenId} for user {UserId}",
token.Id, token.UserId.Value);
}
public async Task RevokeAllUserTokensAsync(
Guid userId,
CancellationToken cancellationToken = default)
{
await _refreshTokenRepository.RevokeAllUserTokensAsync(
userId,
"User requested logout from all devices",
cancellationToken);
_logger.LogInformation(
"Revoked all refresh tokens for user {UserId}",
userId);
}
private static string ComputeSha256Hash(string input)
{
using var sha256 = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(input);
var hash = sha256.ComputeHash(bytes);
return Convert.ToBase64String(hash);
}
}