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,88 @@
using ColaFlow.Shared.Kernel.Common;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users;
/// <summary>
/// Refresh Token entity for secure token rotation
/// </summary>
public sealed class RefreshToken : Entity
{
public string TokenHash { get; private set; } = null!;
public UserId UserId { get; private set; } = null!;
public Guid TenantId { get; private set; }
// Token lifecycle
public DateTime ExpiresAt { get; private set; }
public DateTime CreatedAt { get; private set; }
public DateTime? RevokedAt { get; private set; }
public string? RevokedReason { get; private set; }
// Security tracking
public string? IpAddress { get; private set; }
public string? UserAgent { get; private set; }
// Token rotation (token family tracking)
public string? ReplacedByToken { get; private set; }
// Navigation properties
public string? DeviceInfo { get; private set; }
// Private constructor for EF Core
private RefreshToken() : base() { }
// Factory method
public static RefreshToken Create(
string tokenHash,
UserId userId,
Guid tenantId,
DateTime expiresAt,
string? ipAddress = null,
string? userAgent = null,
string? deviceInfo = null)
{
if (string.IsNullOrWhiteSpace(tokenHash))
throw new ArgumentException("Token hash cannot be empty", nameof(tokenHash));
if (expiresAt <= DateTime.UtcNow)
throw new ArgumentException("Expiration date must be in the future", nameof(expiresAt));
return new RefreshToken
{
Id = Guid.NewGuid(),
TokenHash = tokenHash,
UserId = userId,
TenantId = tenantId,
ExpiresAt = expiresAt,
CreatedAt = DateTime.UtcNow,
IpAddress = ipAddress,
UserAgent = userAgent,
DeviceInfo = deviceInfo
};
}
// Business methods
public bool IsExpired() => DateTime.UtcNow >= ExpiresAt;
public bool IsRevoked() => RevokedAt.HasValue;
public bool IsActive() => !IsExpired() && !IsRevoked();
public void Revoke(string reason)
{
if (IsRevoked())
throw new InvalidOperationException("Token is already revoked");
RevokedAt = DateTime.UtcNow;
RevokedReason = reason;
}
public void MarkAsReplaced(string newTokenHash)
{
if (string.IsNullOrWhiteSpace(newTokenHash))
throw new ArgumentException("New token hash cannot be empty", nameof(newTokenHash));
ReplacedByToken = newTokenHash;
RevokedAt = DateTime.UtcNow;
RevokedReason = "Token rotated";
}
}

View File

@@ -0,0 +1,13 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
namespace ColaFlow.Modules.Identity.Domain.Repositories;
public interface IRefreshTokenRepository
{
Task<RefreshToken?> GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken = default);
Task<IReadOnlyList<RefreshToken>> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default);
Task AddAsync(RefreshToken refreshToken, CancellationToken cancellationToken = default);
Task UpdateAsync(RefreshToken refreshToken, CancellationToken cancellationToken = default);
Task RevokeAllUserTokensAsync(Guid userId, string reason, CancellationToken cancellationToken = default);
Task DeleteExpiredTokensAsync(CancellationToken cancellationToken = default);
}