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

@@ -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>

View File

@@ -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" });
}
}
}

View File

@@ -0,0 +1,6 @@
namespace ColaFlow.API.Models;
public class LogoutRequest
{
public string RefreshToken { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,6 @@
namespace ColaFlow.API.Models;
public class RefreshTokenRequest
{
public string RefreshToken { get; set; } = string.Empty;
}

View File

@@ -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();

View File

@@ -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"