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