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

@@ -1,4 +1,5 @@
using ColaFlow.Modules.Identity.Application.Dtos;
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;
@@ -10,14 +11,22 @@ public class LoginCommandHandler : IRequestHandler<LoginCommand, LoginResponseDt
{
private readonly ITenantRepository _tenantRepository;
private readonly IUserRepository _userRepository;
// Note: In production, inject IPasswordHasher and IJwtService
private readonly IJwtService _jwtService;
private readonly IPasswordHasher _passwordHasher;
private readonly IRefreshTokenService _refreshTokenService;
public LoginCommandHandler(
ITenantRepository tenantRepository,
IUserRepository userRepository)
IUserRepository userRepository,
IJwtService jwtService,
IPasswordHasher passwordHasher,
IRefreshTokenService refreshTokenService)
{
_tenantRepository = tenantRepository;
_userRepository = userRepository;
_jwtService = jwtService;
_passwordHasher = passwordHasher;
_refreshTokenService = refreshTokenService;
}
public async Task<LoginResponseDto> Handle(LoginCommand request, CancellationToken cancellationToken)
@@ -38,20 +47,27 @@ public class LoginCommandHandler : IRequestHandler<LoginCommand, LoginResponseDt
throw new UnauthorizedAccessException("Invalid credentials");
}
// 3. Verify password (simplified - TODO: use IPasswordHasher)
// if (!PasswordHasher.Verify(request.Password, user.PasswordHash))
// {
// throw new UnauthorizedAccessException("Invalid credentials");
// }
// 3. Verify password
if (user.PasswordHash == null || !_passwordHasher.VerifyPassword(request.Password, user.PasswordHash))
{
throw new UnauthorizedAccessException("Invalid credentials");
}
// 4. Generate JWT token (simplified - TODO: use IJwtService)
var accessToken = "dummy-token";
// 4. Generate JWT token
var accessToken = _jwtService.GenerateToken(user, tenant);
// 5. Update last login time
// 5. Generate refresh token
var refreshToken = await _refreshTokenService.GenerateRefreshTokenAsync(
user,
ipAddress: null,
userAgent: null,
cancellationToken);
// 6. Update last login time
user.RecordLogin();
await _userRepository.UpdateAsync(user, cancellationToken);
// 6. Return result
// 7. Return result
return new LoginResponseDto
{
User = new UserDto
@@ -78,7 +94,8 @@ public class LoginCommandHandler : IRequestHandler<LoginCommand, LoginResponseDt
CreatedAt = tenant.CreatedAt,
UpdatedAt = tenant.UpdatedAt ?? tenant.CreatedAt
},
AccessToken = accessToken
AccessToken = accessToken,
RefreshToken = refreshToken
};
}
}

View File

@@ -15,5 +15,6 @@ public record RegisterTenantCommand(
public record RegisterTenantResult(
TenantDto Tenant,
UserDto AdminUser,
string AccessToken
string AccessToken,
string RefreshToken
);

View File

@@ -1,3 +1,4 @@
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;
@@ -9,14 +10,22 @@ public class RegisterTenantCommandHandler : IRequestHandler<RegisterTenantComman
{
private readonly ITenantRepository _tenantRepository;
private readonly IUserRepository _userRepository;
// Note: In production, inject IJwtService and IPasswordHasher
private readonly IJwtService _jwtService;
private readonly IPasswordHasher _passwordHasher;
private readonly IRefreshTokenService _refreshTokenService;
public RegisterTenantCommandHandler(
ITenantRepository tenantRepository,
IUserRepository userRepository)
IUserRepository userRepository,
IJwtService jwtService,
IPasswordHasher passwordHasher,
IRefreshTokenService refreshTokenService)
{
_tenantRepository = tenantRepository;
_userRepository = userRepository;
_jwtService = jwtService;
_passwordHasher = passwordHasher;
_refreshTokenService = refreshTokenService;
}
public async Task<RegisterTenantResult> Handle(
@@ -40,20 +49,27 @@ public class RegisterTenantCommandHandler : IRequestHandler<RegisterTenantComman
await _tenantRepository.AddAsync(tenant, cancellationToken);
// 3. Create admin user
// Note: In production, hash password first using IPasswordHasher
// 3. Create admin user with hashed password
var hashedPassword = _passwordHasher.HashPassword(request.AdminPassword);
var adminUser = User.CreateLocal(
TenantId.Create(tenant.Id),
Email.Create(request.AdminEmail),
request.AdminPassword, // TODO: Hash password
hashedPassword,
FullName.Create(request.AdminFullName));
await _userRepository.AddAsync(adminUser, cancellationToken);
// 4. Generate JWT token (simplified - TODO: use IJwtService)
var accessToken = "dummy-token";
// 4. Generate JWT token
var accessToken = _jwtService.GenerateToken(adminUser, tenant);
// 5. Return result
// 5. Generate refresh token
var refreshToken = await _refreshTokenService.GenerateRefreshTokenAsync(
adminUser,
ipAddress: null,
userAgent: null,
cancellationToken);
// 6. Return result
return new RegisterTenantResult(
new Dtos.TenantDto
{
@@ -78,6 +94,7 @@ public class RegisterTenantCommandHandler : IRequestHandler<RegisterTenantComman
IsEmailVerified = adminUser.EmailVerifiedAt.HasValue,
CreatedAt = adminUser.CreatedAt
},
accessToken);
accessToken,
refreshToken);
}
}

View File

@@ -5,4 +5,7 @@ public class LoginResponseDto
public UserDto User { get; set; } = null!;
public TenantDto Tenant { get; set; } = null!;
public string AccessToken { get; set; } = string.Empty;
public string RefreshToken { get; set; } = string.Empty;
public int ExpiresIn { get; set; } = 900; // 15 minutes in seconds
public string TokenType { get; set; } = "Bearer";
}

View File

@@ -0,0 +1,10 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
namespace ColaFlow.Modules.Identity.Application.Services;
public interface IJwtService
{
string GenerateToken(User user, Tenant tenant);
Task<string> GenerateRefreshTokenAsync(User user, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,7 @@
namespace ColaFlow.Modules.Identity.Application.Services;
public interface IPasswordHasher
{
string HashPassword(string password);
bool VerifyPassword(string password, string hashedPassword);
}

View File

@@ -0,0 +1,39 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
namespace ColaFlow.Modules.Identity.Application.Services;
public interface IRefreshTokenService
{
/// <summary>
/// Generate a new refresh token for the user
/// </summary>
Task<string> GenerateRefreshTokenAsync(
User user,
string? ipAddress = null,
string? userAgent = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Refresh access token using refresh token (with token rotation)
/// </summary>
Task<(string accessToken, string refreshToken)> RefreshTokenAsync(
string refreshToken,
string? ipAddress = null,
string? userAgent = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Revoke a specific refresh token
/// </summary>
Task RevokeTokenAsync(
string refreshToken,
string? ipAddress = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Revoke all refresh tokens for a user
/// </summary>
Task RevokeAllUserTokensAsync(
Guid userId,
CancellationToken cancellationToken = default);
}