UDT-002: Logout + Refresh Token con rotación y chain revocation #3
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.Auth.Refresh;
|
||||||
|
|
||||||
|
public sealed record RefreshCommand(string AccessToken, string RefreshToken);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace SIGCM2.Application.Auth.Refresh;
|
||||||
|
|
||||||
|
public sealed record RefreshResponseDto(
|
||||||
|
string AccessToken,
|
||||||
|
string RefreshToken,
|
||||||
|
int ExpiresIn);
|
||||||
Reference in New Issue
Block a user