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:
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,5 +15,6 @@ public record RegisterTenantCommand(
|
||||
public record RegisterTenantResult(
|
||||
TenantDto Tenant,
|
||||
UserDto AdminUser,
|
||||
string AccessToken
|
||||
string AccessToken,
|
||||
string RefreshToken
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ColaFlow.Modules.Identity.Application.Services;
|
||||
|
||||
public interface IPasswordHasher
|
||||
{
|
||||
string HashPassword(string password);
|
||||
bool VerifyPassword(string password, string hashedPassword);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user