UDT-002: Logout + Refresh Token con rotación y chain revocation #3
@@ -5,4 +5,5 @@ namespace SIGCM2.Application.Abstractions.Persistence;
|
|||||||
public interface IUsuarioRepository
|
public interface IUsuarioRepository
|
||||||
{
|
{
|
||||||
Task<Usuario?> GetByUsernameAsync(string username);
|
Task<Usuario?> GetByUsernameAsync(string username);
|
||||||
|
Task<Usuario?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
12
src/api/SIGCM2.Application/Auth/AuthOptions.cs
Normal file
12
src/api/SIGCM2.Application/Auth/AuthOptions.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace SIGCM2.Application.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration values for authentication token generation.
|
||||||
|
/// Populated from the "Jwt" configuration section via IOptions in the Infrastructure layer.
|
||||||
|
/// Lives in Application to avoid circular dependency with Infrastructure.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AuthOptions
|
||||||
|
{
|
||||||
|
public int AccessTokenMinutes { get; set; } = 60;
|
||||||
|
public int RefreshTokenDays { get; set; } = 7;
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ using System.Text.Json;
|
|||||||
using SIGCM2.Application.Abstractions;
|
using SIGCM2.Application.Abstractions;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
using SIGCM2.Application.Abstractions.Security;
|
using SIGCM2.Application.Abstractions.Security;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
using SIGCM2.Domain.Exceptions;
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
using SIGCM2.Domain.Security;
|
||||||
|
|
||||||
namespace SIGCM2.Application.Auth.Login;
|
namespace SIGCM2.Application.Auth.Login;
|
||||||
|
|
||||||
@@ -11,15 +13,27 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
|
|||||||
private readonly IUsuarioRepository _repository;
|
private readonly IUsuarioRepository _repository;
|
||||||
private readonly IPasswordHasher _hasher;
|
private readonly IPasswordHasher _hasher;
|
||||||
private readonly IJwtService _jwtService;
|
private readonly IJwtService _jwtService;
|
||||||
|
private readonly IRefreshTokenRepository _refreshRepository;
|
||||||
|
private readonly IRefreshTokenGenerator _refreshGenerator;
|
||||||
|
private readonly IClientContext _clientContext;
|
||||||
|
private readonly AuthOptions _authOptions;
|
||||||
|
|
||||||
public LoginCommandHandler(
|
public LoginCommandHandler(
|
||||||
IUsuarioRepository repository,
|
IUsuarioRepository repository,
|
||||||
IPasswordHasher hasher,
|
IPasswordHasher hasher,
|
||||||
IJwtService jwtService)
|
IJwtService jwtService,
|
||||||
|
IRefreshTokenRepository refreshRepository,
|
||||||
|
IRefreshTokenGenerator refreshGenerator,
|
||||||
|
IClientContext clientContext,
|
||||||
|
AuthOptions authOptions)
|
||||||
{
|
{
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
_hasher = hasher;
|
_hasher = hasher;
|
||||||
_jwtService = jwtService;
|
_jwtService = jwtService;
|
||||||
|
_refreshRepository = refreshRepository;
|
||||||
|
_refreshGenerator = refreshGenerator;
|
||||||
|
_clientContext = clientContext;
|
||||||
|
_authOptions = authOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<LoginResponseDto> Handle(LoginCommand command)
|
public async Task<LoginResponseDto> Handle(LoginCommand command)
|
||||||
@@ -34,15 +48,24 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
|
|||||||
throw new InvalidCredentialsException();
|
throw new InvalidCredentialsException();
|
||||||
|
|
||||||
var accessToken = _jwtService.GenerateAccessToken(usuario);
|
var accessToken = _jwtService.GenerateAccessToken(usuario);
|
||||||
var refreshToken = Guid.NewGuid().ToString("N"); // opaque, not persisted in UDT-001
|
|
||||||
|
// Generate and persist refresh token — only the hash hits the DB
|
||||||
|
var rawRefresh = _refreshGenerator.Generate();
|
||||||
|
var hash = TokenHasher.Sha256Base64Url(rawRefresh);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var ttl = TimeSpan.FromDays(_authOptions.RefreshTokenDays);
|
||||||
|
var entity = RefreshToken.IssueForNewFamily(
|
||||||
|
usuario.Id, hash, now, ttl,
|
||||||
|
_clientContext.Ip, _clientContext.UserAgent);
|
||||||
|
await _refreshRepository.AddAsync(entity);
|
||||||
|
|
||||||
var permisos = JsonSerializer.Deserialize<string[]>(usuario.PermisosJson)
|
var permisos = JsonSerializer.Deserialize<string[]>(usuario.PermisosJson)
|
||||||
?? Array.Empty<string>();
|
?? Array.Empty<string>();
|
||||||
|
|
||||||
return new LoginResponseDto(
|
return new LoginResponseDto(
|
||||||
AccessToken: accessToken,
|
AccessToken: accessToken,
|
||||||
RefreshToken: refreshToken,
|
RefreshToken: rawRefresh, // raw to client — never stored
|
||||||
ExpiresIn: 3600,
|
ExpiresIn: _authOptions.AccessTokenMinutes * 60,
|
||||||
Usuario: new UsuarioDto(
|
Usuario: new UsuarioDto(
|
||||||
Id: usuario.Id,
|
Id: usuario.Id,
|
||||||
Nombre: $"{usuario.Nombre} {usuario.Apellido}".Trim(),
|
Nombre: $"{usuario.Nombre} {usuario.Apellido}".Trim(),
|
||||||
|
|||||||
Reference in New Issue
Block a user