2026-04-15 17:39:48 -03:00
|
|
|
|
using Microsoft.Extensions.Logging;
|
2026-04-13 21:36:01 -03:00
|
|
|
|
using SIGCM2.Application.Abstractions;
|
|
|
|
|
|
using SIGCM2.Application.Abstractions.Persistence;
|
|
|
|
|
|
using SIGCM2.Application.Abstractions.Security;
|
2026-04-16 13:59:27 -03:00
|
|
|
|
using SIGCM2.Application.Audit;
|
2026-04-15 21:29:33 -03:00
|
|
|
|
using SIGCM2.Application.Common;
|
2026-04-14 13:28:16 -03:00
|
|
|
|
using SIGCM2.Domain.Entities;
|
2026-04-13 21:36:01 -03:00
|
|
|
|
using SIGCM2.Domain.Exceptions;
|
2026-04-14 13:28:16 -03:00
|
|
|
|
using SIGCM2.Domain.Security;
|
2026-04-13 21:36:01 -03:00
|
|
|
|
|
|
|
|
|
|
namespace SIGCM2.Application.Auth.Login;
|
|
|
|
|
|
|
|
|
|
|
|
public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginResponseDto>
|
|
|
|
|
|
{
|
|
|
|
|
|
private readonly IUsuarioRepository _repository;
|
|
|
|
|
|
private readonly IPasswordHasher _hasher;
|
|
|
|
|
|
private readonly IJwtService _jwtService;
|
2026-04-14 13:28:16 -03:00
|
|
|
|
private readonly IRefreshTokenRepository _refreshRepository;
|
|
|
|
|
|
private readonly IRefreshTokenGenerator _refreshGenerator;
|
|
|
|
|
|
private readonly IClientContext _clientContext;
|
|
|
|
|
|
private readonly AuthOptions _authOptions;
|
2026-04-15 16:24:21 -03:00
|
|
|
|
private readonly IRolPermisoRepository _rolPermisoRepository;
|
2026-04-16 13:59:27 -03:00
|
|
|
|
private readonly ISecurityEventLogger _security;
|
2026-04-15 17:39:48 -03:00
|
|
|
|
private readonly ILogger<LoginCommandHandler> _logger;
|
2026-04-18 10:12:17 -03:00
|
|
|
|
private readonly TimeProvider _timeProvider;
|
2026-04-13 21:36:01 -03:00
|
|
|
|
|
|
|
|
|
|
public LoginCommandHandler(
|
|
|
|
|
|
IUsuarioRepository repository,
|
|
|
|
|
|
IPasswordHasher hasher,
|
2026-04-14 13:28:16 -03:00
|
|
|
|
IJwtService jwtService,
|
|
|
|
|
|
IRefreshTokenRepository refreshRepository,
|
|
|
|
|
|
IRefreshTokenGenerator refreshGenerator,
|
|
|
|
|
|
IClientContext clientContext,
|
2026-04-15 16:24:21 -03:00
|
|
|
|
AuthOptions authOptions,
|
2026-04-15 17:39:48 -03:00
|
|
|
|
IRolPermisoRepository rolPermisoRepository,
|
2026-04-16 13:59:27 -03:00
|
|
|
|
ISecurityEventLogger security,
|
2026-04-18 10:12:17 -03:00
|
|
|
|
ILogger<LoginCommandHandler> logger,
|
|
|
|
|
|
TimeProvider timeProvider)
|
2026-04-13 21:36:01 -03:00
|
|
|
|
{
|
|
|
|
|
|
_repository = repository;
|
|
|
|
|
|
_hasher = hasher;
|
|
|
|
|
|
_jwtService = jwtService;
|
2026-04-14 13:28:16 -03:00
|
|
|
|
_refreshRepository = refreshRepository;
|
|
|
|
|
|
_refreshGenerator = refreshGenerator;
|
|
|
|
|
|
_clientContext = clientContext;
|
|
|
|
|
|
_authOptions = authOptions;
|
2026-04-15 16:24:21 -03:00
|
|
|
|
_rolPermisoRepository = rolPermisoRepository;
|
2026-04-16 13:59:27 -03:00
|
|
|
|
_security = security;
|
2026-04-15 17:39:48 -03:00
|
|
|
|
_logger = logger;
|
2026-04-18 10:12:17 -03:00
|
|
|
|
_timeProvider = timeProvider;
|
2026-04-13 21:36:01 -03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public async Task<LoginResponseDto> Handle(LoginCommand command)
|
|
|
|
|
|
{
|
|
|
|
|
|
var usuario = await _repository.GetByUsernameAsync(command.Username);
|
|
|
|
|
|
|
2026-04-16 13:59:27 -03:00
|
|
|
|
// Deliberately vague to the client — never reveal which check failed.
|
|
|
|
|
|
// Internally, SecurityEvent captures the precise FailureReason for ops.
|
|
|
|
|
|
if (usuario is null)
|
|
|
|
|
|
{
|
|
|
|
|
|
await _security.LogAsync("login", "failure",
|
|
|
|
|
|
actorUserId: null, attemptedUsername: command.Username,
|
|
|
|
|
|
failureReason: "user_not_found");
|
|
|
|
|
|
throw new InvalidCredentialsException();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!usuario.Activo)
|
|
|
|
|
|
{
|
|
|
|
|
|
await _security.LogAsync("login", "failure",
|
|
|
|
|
|
actorUserId: usuario.Id, attemptedUsername: command.Username,
|
|
|
|
|
|
failureReason: "user_inactive");
|
2026-04-13 21:36:01 -03:00
|
|
|
|
throw new InvalidCredentialsException();
|
2026-04-16 13:59:27 -03:00
|
|
|
|
}
|
2026-04-13 21:36:01 -03:00
|
|
|
|
|
|
|
|
|
|
if (!_hasher.Verify(command.Password, usuario.PasswordHash))
|
2026-04-16 13:59:27 -03:00
|
|
|
|
{
|
|
|
|
|
|
await _security.LogAsync("login", "failure",
|
|
|
|
|
|
actorUserId: usuario.Id, attemptedUsername: command.Username,
|
|
|
|
|
|
failureReason: "invalid_password");
|
2026-04-13 21:36:01 -03:00
|
|
|
|
throw new InvalidCredentialsException();
|
2026-04-16 13:59:27 -03:00
|
|
|
|
}
|
2026-04-13 21:36:01 -03:00
|
|
|
|
|
|
|
|
|
|
var accessToken = _jwtService.GenerateAccessToken(usuario);
|
2026-04-14 13:28:16 -03:00
|
|
|
|
|
|
|
|
|
|
// Generate and persist refresh token — only the hash hits the DB
|
|
|
|
|
|
var rawRefresh = _refreshGenerator.Generate();
|
|
|
|
|
|
var hash = TokenHasher.Sha256Base64Url(rawRefresh);
|
2026-04-18 10:12:17 -03:00
|
|
|
|
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
2026-04-14 13:28:16 -03:00
|
|
|
|
var ttl = TimeSpan.FromDays(_authOptions.RefreshTokenDays);
|
|
|
|
|
|
var entity = RefreshToken.IssueForNewFamily(
|
|
|
|
|
|
usuario.Id, hash, now, ttl,
|
|
|
|
|
|
_clientContext.Ip, _clientContext.UserAgent);
|
|
|
|
|
|
await _refreshRepository.AddAsync(entity);
|
2026-04-13 21:36:01 -03:00
|
|
|
|
|
2026-04-15 17:39:48 -03:00
|
|
|
|
// UDT-008: update UltimoLogin best-effort — never block login on this
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
await _repository.UpdateUltimoLoginAsync(usuario.Id, now);
|
|
|
|
|
|
}
|
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
|
{
|
|
|
|
|
|
_logger.LogWarning(ex, "Failed to update UltimoLogin for usuario {Id} — login proceeds", usuario.Id);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 21:29:33 -03:00
|
|
|
|
// UDT-009: permisos efectivos = (rol ∪ grant) \ deny via PermisoResolver
|
|
|
|
|
|
var rolPermisoEntities = await _rolPermisoRepository.GetByRolCodigoAsync(usuario.Rol);
|
|
|
|
|
|
var rolPermisos = rolPermisoEntities.Select(p => p.Codigo);
|
|
|
|
|
|
var overrides = PermisosOverride.FromJson(usuario.PermisosJson);
|
|
|
|
|
|
var effective = PermisoResolver.Resolve(rolPermisos, overrides);
|
|
|
|
|
|
var permisos = effective.OrderBy(p => p, StringComparer.Ordinal).ToArray();
|
2026-04-13 21:36:01 -03:00
|
|
|
|
|
2026-04-16 13:59:27 -03:00
|
|
|
|
await _security.LogAsync("login", "success", actorUserId: usuario.Id);
|
|
|
|
|
|
|
2026-04-13 21:36:01 -03:00
|
|
|
|
return new LoginResponseDto(
|
|
|
|
|
|
AccessToken: accessToken,
|
2026-04-14 13:28:16 -03:00
|
|
|
|
RefreshToken: rawRefresh, // raw to client — never stored
|
|
|
|
|
|
ExpiresIn: _authOptions.AccessTokenMinutes * 60,
|
2026-04-13 21:36:01 -03:00
|
|
|
|
Usuario: new UsuarioDto(
|
|
|
|
|
|
Id: usuario.Id,
|
2026-04-15 17:39:48 -03:00
|
|
|
|
Username: usuario.Username,
|
2026-04-13 21:36:01 -03:00
|
|
|
|
Nombre: $"{usuario.Nombre} {usuario.Apellido}".Trim(),
|
|
|
|
|
|
Rol: usuario.Rol,
|
2026-04-15 17:39:48 -03:00
|
|
|
|
Permisos: permisos,
|
|
|
|
|
|
MustChangePassword: usuario.MustChangePassword
|
2026-04-13 21:36:01 -03:00
|
|
|
|
)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|