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