All command handlers that call domain mutators now inject TimeProvider via constructor and use _timeProvider.GetUtcNow().UtcDateTime as the explicit 'now' argument. Replaces previous direct DateTime.UtcNow usage.
128 lines
5.1 KiB
C#
128 lines
5.1 KiB
C#
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<LoginCommand, LoginResponseDto>
|
||
{
|
||
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<LoginCommandHandler> _logger;
|
||
private readonly TimeProvider _timeProvider;
|
||
|
||
public LoginCommandHandler(
|
||
IUsuarioRepository repository,
|
||
IPasswordHasher hasher,
|
||
IJwtService jwtService,
|
||
IRefreshTokenRepository refreshRepository,
|
||
IRefreshTokenGenerator refreshGenerator,
|
||
IClientContext clientContext,
|
||
AuthOptions authOptions,
|
||
IRolPermisoRepository rolPermisoRepository,
|
||
ISecurityEventLogger security,
|
||
ILogger<LoginCommandHandler> logger,
|
||
TimeProvider timeProvider)
|
||
{
|
||
_repository = repository;
|
||
_hasher = hasher;
|
||
_jwtService = jwtService;
|
||
_refreshRepository = refreshRepository;
|
||
_refreshGenerator = refreshGenerator;
|
||
_clientContext = clientContext;
|
||
_authOptions = authOptions;
|
||
_rolPermisoRepository = rolPermisoRepository;
|
||
_security = security;
|
||
_logger = logger;
|
||
_timeProvider = timeProvider;
|
||
}
|
||
|
||
public async Task<LoginResponseDto> 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 = _timeProvider.GetUtcNow().UtcDateTime;
|
||
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
|
||
)
|
||
);
|
||
}
|
||
}
|