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:
@@ -7,6 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
using ColaFlow.API.Models;
|
||||
using ColaFlow.Modules.Identity.Application.Commands.Login;
|
||||
using ColaFlow.Modules.Identity.Application.Services;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace ColaFlow.API.Controllers;
|
||||
|
||||
@@ -9,10 +14,17 @@ namespace ColaFlow.API.Controllers;
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IRefreshTokenService _refreshTokenService;
|
||||
private readonly ILogger<AuthController> _logger;
|
||||
|
||||
public AuthController(IMediator mediator)
|
||||
public AuthController(
|
||||
IMediator mediator,
|
||||
IRefreshTokenService refreshTokenService,
|
||||
ILogger<AuthController> logger)
|
||||
{
|
||||
_mediator = mediator;
|
||||
_refreshTokenService = refreshTokenService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -29,10 +41,106 @@ public class AuthController : ControllerBase
|
||||
/// Get current user (requires authentication)
|
||||
/// </summary>
|
||||
[HttpGet("me")]
|
||||
// [Authorize] // TODO: Add after JWT middleware is configured
|
||||
public async Task<IActionResult> GetCurrentUser()
|
||||
[Authorize]
|
||||
public IActionResult GetCurrentUser()
|
||||
{
|
||||
// TODO: Implement after JWT middleware
|
||||
return Ok(new { message = "Current user endpoint - to be implemented" });
|
||||
// Extract user information from JWT Claims
|
||||
var userId = User.FindFirst("user_id")?.Value;
|
||||
var tenantId = User.FindFirst("tenant_id")?.Value;
|
||||
var email = User.FindFirst(ClaimTypes.Email)?.Value;
|
||||
var fullName = User.FindFirst("full_name")?.Value;
|
||||
var tenantSlug = User.FindFirst("tenant_slug")?.Value;
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
userId,
|
||||
tenantId,
|
||||
email,
|
||||
fullName,
|
||||
tenantSlug,
|
||||
claims = User.Claims.Select(c => new { c.Type, c.Value })
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refresh access token using refresh token
|
||||
/// </summary>
|
||||
[HttpPost("refresh")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var userAgent = HttpContext.Request.Headers["User-Agent"].ToString();
|
||||
|
||||
var (accessToken, newRefreshToken) = await _refreshTokenService.RefreshTokenAsync(
|
||||
request.RefreshToken,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
HttpContext.RequestAborted);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
accessToken,
|
||||
refreshToken = newRefreshToken,
|
||||
expiresIn = 900, // 15 minutes in seconds
|
||||
tokenType = "Bearer"
|
||||
});
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Refresh token failed");
|
||||
return Unauthorized(new { message = "Invalid or expired refresh token" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logout (revoke refresh token)
|
||||
/// </summary>
|
||||
[HttpPost("logout")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Logout([FromBody] LogoutRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
|
||||
await _refreshTokenService.RevokeTokenAsync(
|
||||
request.RefreshToken,
|
||||
ipAddress,
|
||||
HttpContext.RequestAborted);
|
||||
|
||||
return Ok(new { message = "Logged out successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Logout failed");
|
||||
return BadRequest(new { message = "Logout failed" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logout from all devices (revoke all user refresh tokens)
|
||||
/// </summary>
|
||||
[HttpPost("logout-all")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> LogoutAllDevices()
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue("user_id")!);
|
||||
|
||||
await _refreshTokenService.RevokeAllUserTokensAsync(
|
||||
userId,
|
||||
HttpContext.RequestAborted);
|
||||
|
||||
return Ok(new { message = "Logged out from all devices successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Logout from all devices failed");
|
||||
return BadRequest(new { message = "Logout failed" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
6
colaflow-api/src/ColaFlow.API/Models/LogoutRequest.cs
Normal file
6
colaflow-api/src/ColaFlow.API/Models/LogoutRequest.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace ColaFlow.API.Models;
|
||||
|
||||
public class LogoutRequest
|
||||
{
|
||||
public string RefreshToken { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ColaFlow.API.Models;
|
||||
|
||||
public class RefreshTokenRequest
|
||||
{
|
||||
public string RefreshToken { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -2,7 +2,10 @@ using ColaFlow.API.Extensions;
|
||||
using ColaFlow.API.Handlers;
|
||||
using ColaFlow.Modules.Identity.Application;
|
||||
using ColaFlow.Modules.Identity.Infrastructure;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Scalar.AspNetCore;
|
||||
using System.Text;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -20,6 +23,29 @@ builder.Services.AddControllers();
|
||||
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
|
||||
builder.Services.AddProblemDetails();
|
||||
|
||||
// Configure Authentication
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = builder.Configuration["Jwt:Issuer"],
|
||||
ValidAudience = builder.Configuration["Jwt:Audience"],
|
||||
IssuerSigningKey = new SymmetricSecurityKey(
|
||||
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("JWT SecretKey not configured")))
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
// Configure CORS for frontend
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
@@ -50,6 +76,11 @@ app.UseExceptionHandler();
|
||||
app.UseCors("AllowFrontend");
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// Authentication & Authorization
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"Jwt": {
|
||||
"SecretKey": "your-super-secret-key-min-32-characters-long-12345",
|
||||
"Issuer": "ColaFlow.API",
|
||||
"Audience": "ColaFlow.Web",
|
||||
"ExpirationMinutes": "15",
|
||||
"RefreshTokenExpirationDays": "7"
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"PMDatabase": "Host=localhost;Port=5432;Database=colaflow_pm;Username=colaflow;Password=colaflow_dev_password",
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=colaflow_identity;Username=colaflow;Password=colaflow_dev_password"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10">
|
||||
@@ -12,7 +13,9 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.14.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ColaFlow.Modules.Identity.Application.Services;
|
||||
using ColaFlow.Modules.Identity.Domain.Repositories;
|
||||
using ColaFlow.Modules.Identity.Infrastructure.Persistence;
|
||||
using ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories;
|
||||
@@ -27,6 +28,12 @@ public static class DependencyInjection
|
||||
// Repositories
|
||||
services.AddScoped<ITenantRepository, TenantRepository>();
|
||||
services.AddScoped<IUserRepository, UserRepository>();
|
||||
services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
|
||||
|
||||
// Application Services
|
||||
services.AddScoped<IJwtService, JwtService>();
|
||||
services.AddScoped<IPasswordHasher, PasswordHasher>();
|
||||
services.AddScoped<IRefreshTokenService, RefreshTokenService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Configurations;
|
||||
|
||||
public class RefreshTokenConfiguration : IEntityTypeConfiguration<RefreshToken>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<RefreshToken> builder)
|
||||
{
|
||||
builder.ToTable("refresh_tokens", "identity");
|
||||
|
||||
builder.HasKey(rt => rt.Id);
|
||||
|
||||
builder.Property(rt => rt.TokenHash)
|
||||
.HasColumnName("token_hash")
|
||||
.HasMaxLength(500)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(rt => rt.TenantId)
|
||||
.HasColumnName("tenant_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(rt => rt.ExpiresAt)
|
||||
.HasColumnName("expires_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(rt => rt.CreatedAt)
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(rt => rt.RevokedAt)
|
||||
.HasColumnName("revoked_at")
|
||||
.IsRequired(false);
|
||||
|
||||
builder.Property(rt => rt.RevokedReason)
|
||||
.HasColumnName("revoked_reason")
|
||||
.HasMaxLength(500)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.Property(rt => rt.IpAddress)
|
||||
.HasColumnName("ip_address")
|
||||
.HasMaxLength(50)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.Property(rt => rt.UserAgent)
|
||||
.HasColumnName("user_agent")
|
||||
.HasMaxLength(500)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.Property(rt => rt.ReplacedByToken)
|
||||
.HasColumnName("replaced_by_token")
|
||||
.HasMaxLength(500)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.Property(rt => rt.DeviceInfo)
|
||||
.HasColumnName("device_info")
|
||||
.HasMaxLength(500)
|
||||
.IsRequired(false);
|
||||
|
||||
// Value object conversion for UserId
|
||||
builder.Property(rt => rt.UserId)
|
||||
.HasColumnName("user_id")
|
||||
.HasConversion(
|
||||
id => id.Value,
|
||||
value => UserId.Create(value))
|
||||
.IsRequired();
|
||||
|
||||
// Indexes for performance
|
||||
builder.HasIndex(rt => rt.TokenHash)
|
||||
.HasDatabaseName("ix_refresh_tokens_token_hash")
|
||||
.IsUnique();
|
||||
|
||||
builder.HasIndex(rt => rt.UserId)
|
||||
.HasDatabaseName("ix_refresh_tokens_user_id");
|
||||
|
||||
builder.HasIndex(rt => rt.ExpiresAt)
|
||||
.HasDatabaseName("ix_refresh_tokens_expires_at");
|
||||
|
||||
builder.HasIndex(rt => rt.TenantId)
|
||||
.HasDatabaseName("ix_refresh_tokens_tenant_id");
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ public class IdentityDbContext : DbContext
|
||||
|
||||
public DbSet<Tenant> Tenants => Set<Tenant>();
|
||||
public DbSet<User> Users => Set<User>();
|
||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using ColaFlow.Modules.Identity.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(IdentityDbContext))]
|
||||
[Migration("20251103133337_AddRefreshTokens")]
|
||||
partial class AddRefreshTokens
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.10")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Tenant", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("MaxProjects")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("max_projects");
|
||||
|
||||
b.Property<int>("MaxStorageGB")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("max_storage_gb");
|
||||
|
||||
b.Property<int>("MaxUsers")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("max_users");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Plan")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("plan");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<string>("SsoConfig")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("sso_config");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<DateTime?>("SuspendedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("suspended_at");
|
||||
|
||||
b.Property<string>("SuspensionReason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("suspension_reason");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_tenants_slug");
|
||||
|
||||
b.ToTable("tenants", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DeviceInfo")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("device_info");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
b.Property<string>("IpAddress")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("ip_address");
|
||||
|
||||
b.Property<string>("ReplacedByToken")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("replaced_by_token");
|
||||
|
||||
b.Property<DateTime?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("revoked_at");
|
||||
|
||||
b.Property<string>("RevokedReason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("revoked_reason");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("token_hash");
|
||||
|
||||
b.Property<string>("UserAgent")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("user_agent");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExpiresAt")
|
||||
.HasDatabaseName("ix_refresh_tokens_expires_at");
|
||||
|
||||
b.HasIndex("TenantId")
|
||||
.HasDatabaseName("ix_refresh_tokens_tenant_id");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_refresh_tokens_token_hash");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_refresh_tokens_user_id");
|
||||
|
||||
b.ToTable("refresh_tokens", "identity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("AuthProvider")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("auth_provider");
|
||||
|
||||
b.Property<string>("AvatarUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("avatar_url");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("email");
|
||||
|
||||
b.Property<string>("EmailVerificationToken")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("email_verification_token");
|
||||
|
||||
b.Property<DateTime?>("EmailVerifiedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("email_verified_at");
|
||||
|
||||
b.Property<string>("ExternalEmail")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("external_email");
|
||||
|
||||
b.Property<string>("ExternalUserId")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("external_user_id");
|
||||
|
||||
b.Property<string>("FullName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("full_name");
|
||||
|
||||
b.Property<string>("JobTitle")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("job_title");
|
||||
|
||||
b.Property<DateTime?>("LastLoginAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_login_at");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("password_hash");
|
||||
|
||||
b.Property<string>("PasswordResetToken")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("password_reset_token");
|
||||
|
||||
b.Property<DateTime?>("PasswordResetTokenExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("password_reset_token_expires_at");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("phone_number");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "Email")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_users_tenant_id_email");
|
||||
|
||||
b.ToTable("users", (string)null);
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRefreshTokens : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "identity");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "refresh_tokens",
|
||||
schema: "identity",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
token_hash = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
expires_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
revoked_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
revoked_reason = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
ip_address = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||
user_agent = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
replaced_by_token = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
device_info = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_refresh_tokens", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_refresh_tokens_expires_at",
|
||||
schema: "identity",
|
||||
table: "refresh_tokens",
|
||||
column: "expires_at");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_refresh_tokens_tenant_id",
|
||||
schema: "identity",
|
||||
table: "refresh_tokens",
|
||||
column: "tenant_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_refresh_tokens_token_hash",
|
||||
schema: "identity",
|
||||
table: "refresh_tokens",
|
||||
column: "token_hash",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_refresh_tokens_user_id",
|
||||
schema: "identity",
|
||||
table: "refresh_tokens",
|
||||
column: "user_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "refresh_tokens",
|
||||
schema: "identity");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,81 @@ namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("tenants", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DeviceInfo")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("device_info");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
b.Property<string>("IpAddress")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("ip_address");
|
||||
|
||||
b.Property<string>("ReplacedByToken")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("replaced_by_token");
|
||||
|
||||
b.Property<DateTime?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("revoked_at");
|
||||
|
||||
b.Property<string>("RevokedReason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("revoked_reason");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("token_hash");
|
||||
|
||||
b.Property<string>("UserAgent")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("user_agent");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExpiresAt")
|
||||
.HasDatabaseName("ix_refresh_tokens_expires_at");
|
||||
|
||||
b.HasIndex("TenantId")
|
||||
.HasDatabaseName("ix_refresh_tokens_tenant_id");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_refresh_tokens_token_hash");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_refresh_tokens_user_id");
|
||||
|
||||
b.ToTable("refresh_tokens", "identity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||
using ColaFlow.Modules.Identity.Domain.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories;
|
||||
|
||||
public class RefreshTokenRepository : IRefreshTokenRepository
|
||||
{
|
||||
private readonly IdentityDbContext _context;
|
||||
|
||||
public RefreshTokenRepository(IdentityDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<RefreshToken?> GetByTokenHashAsync(
|
||||
string tokenHash,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.RefreshTokens
|
||||
.FirstOrDefaultAsync(rt => rt.TokenHash == tokenHash, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RefreshToken>> GetByUserIdAsync(
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.RefreshTokens
|
||||
.Where(rt => rt.UserId.Value == userId)
|
||||
.OrderByDescending(rt => rt.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddAsync(
|
||||
RefreshToken refreshToken,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.RefreshTokens.AddAsync(refreshToken, cancellationToken);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(
|
||||
RefreshToken refreshToken,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.RefreshTokens.Update(refreshToken);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task RevokeAllUserTokensAsync(
|
||||
Guid userId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tokens = await _context.RefreshTokens
|
||||
.Where(rt => rt.UserId.Value == userId && rt.RevokedAt == null)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
token.Revoke(reason);
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DeleteExpiredTokensAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var expiredTokens = await _context.RefreshTokens
|
||||
.Where(rt => rt.ExpiresAt < DateTime.UtcNow)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
_context.RefreshTokens.RemoveRange(expiredTokens);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -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