using Microsoft.Extensions.Logging; using SIGCM2.Application.Abstractions; using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Abstractions.Security; using SIGCM2.Application.Audit; using SIGCM2.Application.Common; using SIGCM2.Domain.Entities; using SIGCM2.Domain.Exceptions; using SIGCM2.Domain.Security; namespace SIGCM2.Application.Auth.Login; public sealed class LoginCommandHandler : ICommandHandler { private readonly IUsuarioRepository _repository; private readonly IPasswordHasher _hasher; private readonly IJwtService _jwtService; private readonly IRefreshTokenRepository _refreshRepository; private readonly IRefreshTokenGenerator _refreshGenerator; private readonly IClientContext _clientContext; private readonly AuthOptions _authOptions; private readonly IRolPermisoRepository _rolPermisoRepository; private readonly ISecurityEventLogger _security; private readonly ILogger _logger; public LoginCommandHandler( IUsuarioRepository repository, IPasswordHasher hasher, IJwtService jwtService, IRefreshTokenRepository refreshRepository, IRefreshTokenGenerator refreshGenerator, IClientContext clientContext, AuthOptions authOptions, IRolPermisoRepository rolPermisoRepository, ISecurityEventLogger security, ILogger logger) { _repository = repository; _hasher = hasher; _jwtService = jwtService; _refreshRepository = refreshRepository; _refreshGenerator = refreshGenerator; _clientContext = clientContext; _authOptions = authOptions; _rolPermisoRepository = rolPermisoRepository; _security = security; _logger = logger; } public async Task Handle(LoginCommand command) { var usuario = await _repository.GetByUsernameAsync(command.Username); // 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"); throw new InvalidCredentialsException(); } if (!_hasher.Verify(command.Password, usuario.PasswordHash)) { await _security.LogAsync("login", "failure", actorUserId: usuario.Id, attemptedUsername: command.Username, failureReason: "invalid_password"); throw new InvalidCredentialsException(); } var accessToken = _jwtService.GenerateAccessToken(usuario); // 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); // 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); } // 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(); await _security.LogAsync("login", "success", actorUserId: usuario.Id); return new LoginResponseDto( AccessToken: accessToken, RefreshToken: rawRefresh, // raw to client — never stored ExpiresIn: _authOptions.AccessTokenMinutes * 60, Usuario: new UsuarioDto( Id: usuario.Id, Username: usuario.Username, Nombre: $"{usuario.Nombre} {usuario.Apellido}".Trim(), Rol: usuario.Rol, Permisos: permisos, MustChangePassword: usuario.MustChangePassword ) ); } }