From f5e67b78a567b6acc62f81240808ec699148486f Mon Sep 17 00:00:00 2001 From: dmolinari Date: Tue, 14 Apr 2026 13:28:06 -0300 Subject: [PATCH] feat(app): implement RefreshCommand handler with token rotation and chain revocation --- .../Auth/Refresh/RefreshCommand.cs | 3 + .../Auth/Refresh/RefreshCommandHandler.cs | 95 +++++++++++++++++++ .../Auth/Refresh/RefreshCommandValidator.cs | 16 ++++ .../Auth/Refresh/RefreshResponseDto.cs | 6 ++ 4 files changed, 120 insertions(+) create mode 100644 src/api/SIGCM2.Application/Auth/Refresh/RefreshCommand.cs create mode 100644 src/api/SIGCM2.Application/Auth/Refresh/RefreshCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Auth/Refresh/RefreshCommandValidator.cs create mode 100644 src/api/SIGCM2.Application/Auth/Refresh/RefreshResponseDto.cs diff --git a/src/api/SIGCM2.Application/Auth/Refresh/RefreshCommand.cs b/src/api/SIGCM2.Application/Auth/Refresh/RefreshCommand.cs new file mode 100644 index 0000000..0d88e9b --- /dev/null +++ b/src/api/SIGCM2.Application/Auth/Refresh/RefreshCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Auth.Refresh; + +public sealed record RefreshCommand(string AccessToken, string RefreshToken); diff --git a/src/api/SIGCM2.Application/Auth/Refresh/RefreshCommandHandler.cs b/src/api/SIGCM2.Application/Auth/Refresh/RefreshCommandHandler.cs new file mode 100644 index 0000000..4bfb5cb --- /dev/null +++ b/src/api/SIGCM2.Application/Auth/Refresh/RefreshCommandHandler.cs @@ -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 +{ + 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 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); + } +} diff --git a/src/api/SIGCM2.Application/Auth/Refresh/RefreshCommandValidator.cs b/src/api/SIGCM2.Application/Auth/Refresh/RefreshCommandValidator.cs new file mode 100644 index 0000000..565d462 --- /dev/null +++ b/src/api/SIGCM2.Application/Auth/Refresh/RefreshCommandValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +namespace SIGCM2.Application.Auth.Refresh; + +public sealed class RefreshCommandValidator : AbstractValidator +{ + 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"); + } +} diff --git a/src/api/SIGCM2.Application/Auth/Refresh/RefreshResponseDto.cs b/src/api/SIGCM2.Application/Auth/Refresh/RefreshResponseDto.cs new file mode 100644 index 0000000..4aeff28 --- /dev/null +++ b/src/api/SIGCM2.Application/Auth/Refresh/RefreshResponseDto.cs @@ -0,0 +1,6 @@ +namespace SIGCM2.Application.Auth.Refresh; + +public sealed record RefreshResponseDto( + string AccessToken, + string RefreshToken, + int ExpiresIn);