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"
|
||||
|
||||
Reference in New Issue
Block a user