feat(app): implement RefreshCommand handler with token rotation and chain revocation

This commit is contained in:
2026-04-14 13:28:06 -03:00
parent 25639398c2
commit f5e67b78a5
4 changed files with 120 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Auth.Refresh;
public sealed record RefreshCommand(string AccessToken, string RefreshToken);

View File

@@ -0,0 +1,95 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
using SIGCM2.Domain.Security;
namespace SIGCM2.Application.Auth.Refresh;
public sealed class RefreshCommandHandler : ICommandHandler<RefreshCommand, RefreshResponseDto>
{
private readonly IRefreshTokenRepository _refreshRepo;
private readonly IUsuarioRepository _usuarioRepo;
private readonly IJwtService _jwt;
private readonly IRefreshTokenGenerator _refreshGenerator;
private readonly IClientContext _clientCtx;
private readonly AuthOptions _authOptions;
public RefreshCommandHandler(
IRefreshTokenRepository refreshRepo,
IUsuarioRepository usuarioRepo,
IJwtService jwt,
IRefreshTokenGenerator refreshGenerator,
IClientContext clientCtx,
AuthOptions authOptions)
{
_refreshRepo = refreshRepo;
_usuarioRepo = usuarioRepo;
_jwt = jwt;
_refreshGenerator = refreshGenerator;
_clientCtx = clientCtx;
_authOptions = authOptions;
}
public async Task<RefreshResponseDto> Handle(RefreshCommand command)
{
// 1. Validate access token signature (lifetime=false) and extract sub
System.Security.Claims.ClaimsPrincipal principal;
try
{
principal = _jwt.GetPrincipalFromExpiredToken(command.AccessToken);
}
catch
{
throw new InvalidRefreshTokenException();
}
if (!int.TryParse(principal.FindFirst("sub")?.Value, out var accessUserId))
throw new InvalidRefreshTokenException();
// 2. Hash the refresh token to look it up
var hash = TokenHasher.Sha256Base64Url(command.RefreshToken);
// 3. Look up in DB (returns record regardless of revoked/expired status)
var stored = await _refreshRepo.GetByHashAsync(hash);
if (stored is null)
throw new InvalidRefreshTokenException();
var now = DateTime.UtcNow;
// 4. Reuse detection: already revoked → chain revocation and throw
if (stored.IsRevoked)
{
await _refreshRepo.RevokeFamilyAsync(stored.FamilyId, now);
throw new TokenReuseDetectedException();
}
// 5. Absolute expiration check
if (stored.IsExpired(now))
throw new InvalidRefreshTokenException();
// 6. UsuarioId must match access token's sub claim
if (stored.UsuarioId != accessUserId)
throw new InvalidRefreshTokenException();
// 7. Load current user (so access token has up-to-date claims)
var usuario = await _usuarioRepo.GetByIdAsync(stored.UsuarioId)
?? throw new InvalidRefreshTokenException();
if (!usuario.Activo)
throw new InvalidRefreshTokenException();
// 8. Rotate: create new token, persist, then revoke old
var newRaw = _refreshGenerator.Generate();
var newHash = TokenHasher.Sha256Base64Url(newRaw);
var rotated = RefreshToken.IssueRotation(stored, newHash, now, _clientCtx.Ip, _clientCtx.UserAgent);
var newId = await _refreshRepo.AddAsync(rotated);
await _refreshRepo.RevokeAsync(stored.Id, replacedById: newId, revokedAt: now);
// 9. Issue new access token
var newAccess = _jwt.GenerateAccessToken(usuario);
return new RefreshResponseDto(newAccess, newRaw, _authOptions.AccessTokenMinutes * 60);
}
}

View File

@@ -0,0 +1,16 @@
using FluentValidation;
namespace SIGCM2.Application.Auth.Refresh;
public sealed class RefreshCommandValidator : AbstractValidator<RefreshCommand>
{
public RefreshCommandValidator()
{
RuleFor(x => x.AccessToken)
.NotEmpty().WithMessage("accessToken is required");
RuleFor(x => x.RefreshToken)
.NotEmpty().WithMessage("refreshToken is required")
.MinimumLength(20).WithMessage("refreshToken must be at least 20 characters");
}
}

View File

@@ -0,0 +1,6 @@
namespace SIGCM2.Application.Auth.Refresh;
public sealed record RefreshResponseDto(
string AccessToken,
string RefreshToken,
int ExpiresIn);