diff --git a/src/api/SIGCM2.Application/Abstractions/ICommandHandler.cs b/src/api/SIGCM2.Application/Abstractions/ICommandHandler.cs new file mode 100644 index 0000000..44bfc29 --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/ICommandHandler.cs @@ -0,0 +1,6 @@ +namespace SIGCM2.Application.Abstractions; + +public interface ICommandHandler +{ + Task Handle(TCommand command); +} diff --git a/src/api/SIGCM2.Application/Abstractions/IDispatcher.cs b/src/api/SIGCM2.Application/Abstractions/IDispatcher.cs new file mode 100644 index 0000000..a5a5c9b --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/IDispatcher.cs @@ -0,0 +1,6 @@ +namespace SIGCM2.Application.Abstractions; + +public interface IDispatcher +{ + Task Send(TCommand command); +} diff --git a/src/api/SIGCM2.Application/Abstractions/IQueryHandler.cs b/src/api/SIGCM2.Application/Abstractions/IQueryHandler.cs new file mode 100644 index 0000000..f62d571 --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/IQueryHandler.cs @@ -0,0 +1,6 @@ +namespace SIGCM2.Application.Abstractions; + +public interface IQueryHandler +{ + Task Handle(TQuery query); +} diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IUsuarioRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IUsuarioRepository.cs new file mode 100644 index 0000000..3167507 --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IUsuarioRepository.cs @@ -0,0 +1,8 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Abstractions.Persistence; + +public interface IUsuarioRepository +{ + Task GetByUsernameAsync(string username); +} diff --git a/src/api/SIGCM2.Application/Abstractions/Security/IJwtService.cs b/src/api/SIGCM2.Application/Abstractions/Security/IJwtService.cs new file mode 100644 index 0000000..0a60224 --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/Security/IJwtService.cs @@ -0,0 +1,8 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Abstractions.Security; + +public interface IJwtService +{ + string GenerateAccessToken(Usuario usuario); +} diff --git a/src/api/SIGCM2.Application/Abstractions/Security/IPasswordHasher.cs b/src/api/SIGCM2.Application/Abstractions/Security/IPasswordHasher.cs new file mode 100644 index 0000000..c978a0c --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/Security/IPasswordHasher.cs @@ -0,0 +1,7 @@ +namespace SIGCM2.Application.Abstractions.Security; + +public interface IPasswordHasher +{ + bool Verify(string plainPassword, string hash); + string Hash(string plainPassword); +} diff --git a/src/api/SIGCM2.Application/Auth/Login/LoginCommand.cs b/src/api/SIGCM2.Application/Auth/Login/LoginCommand.cs new file mode 100644 index 0000000..f921c04 --- /dev/null +++ b/src/api/SIGCM2.Application/Auth/Login/LoginCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Auth.Login; + +public sealed record LoginCommand(string Username, string Password); diff --git a/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs b/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs new file mode 100644 index 0000000..f584cea --- /dev/null +++ b/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs @@ -0,0 +1,54 @@ +using System.Text.Json; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Abstractions.Security; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Auth.Login; + +public sealed class LoginCommandHandler : ICommandHandler +{ + private readonly IUsuarioRepository _repository; + private readonly IPasswordHasher _hasher; + private readonly IJwtService _jwtService; + + public LoginCommandHandler( + IUsuarioRepository repository, + IPasswordHasher hasher, + IJwtService jwtService) + { + _repository = repository; + _hasher = hasher; + _jwtService = jwtService; + } + + public async Task Handle(LoginCommand command) + { + var usuario = await _repository.GetByUsernameAsync(command.Username); + + // Deliberately vague — never reveal which check failed + if (usuario is null || !usuario.Activo) + throw new InvalidCredentialsException(); + + if (!_hasher.Verify(command.Password, usuario.PasswordHash)) + throw new InvalidCredentialsException(); + + var accessToken = _jwtService.GenerateAccessToken(usuario); + var refreshToken = Guid.NewGuid().ToString("N"); // opaque, not persisted in UDT-001 + + var permisos = JsonSerializer.Deserialize(usuario.PermisosJson) + ?? Array.Empty(); + + return new LoginResponseDto( + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpiresIn: 3600, + Usuario: new UsuarioDto( + Id: usuario.Id, + Nombre: $"{usuario.Nombre} {usuario.Apellido}".Trim(), + Rol: usuario.Rol, + Permisos: permisos + ) + ); + } +} diff --git a/src/api/SIGCM2.Application/Auth/Login/LoginCommandValidator.cs b/src/api/SIGCM2.Application/Auth/Login/LoginCommandValidator.cs new file mode 100644 index 0000000..d8d8192 --- /dev/null +++ b/src/api/SIGCM2.Application/Auth/Login/LoginCommandValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; + +namespace SIGCM2.Application.Auth.Login; + +public sealed class LoginCommandValidator : AbstractValidator +{ + public LoginCommandValidator() + { + RuleFor(x => x.Username) + .NotEmpty() + .WithMessage("El nombre de usuario es requerido."); + + RuleFor(x => x.Password) + .NotEmpty() + .WithMessage("La contraseña es requerida."); + } +} diff --git a/src/api/SIGCM2.Application/Auth/Login/LoginResponseDto.cs b/src/api/SIGCM2.Application/Auth/Login/LoginResponseDto.cs new file mode 100644 index 0000000..0f473b3 --- /dev/null +++ b/src/api/SIGCM2.Application/Auth/Login/LoginResponseDto.cs @@ -0,0 +1,15 @@ +namespace SIGCM2.Application.Auth.Login; + +public sealed record LoginResponseDto( + string AccessToken, + string RefreshToken, + int ExpiresIn, + UsuarioDto Usuario +); + +public sealed record UsuarioDto( + int Id, + string Nombre, + string Rol, + string[] Permisos +); diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs new file mode 100644 index 0000000..b529db7 --- /dev/null +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using Microsoft.Extensions.DependencyInjection; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Auth.Login; + +namespace SIGCM2.Application; + +public static class DependencyInjection +{ + public static IServiceCollection AddApplication(this IServiceCollection services) + { + // Register command handlers + services.AddScoped, LoginCommandHandler>(); + + // Register FluentValidation validators from this assembly + services.AddValidatorsFromAssemblyContaining(); + + return services; + } +} diff --git a/src/api/SIGCM2.Application/SIGCM2.Application.csproj b/src/api/SIGCM2.Application/SIGCM2.Application.csproj new file mode 100644 index 0000000..23e8419 --- /dev/null +++ b/src/api/SIGCM2.Application/SIGCM2.Application.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + SIGCM2.Application + + + + + + + + + + +