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:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user