From ca57ce33b59e73afe25a2edb7109535d3006646f Mon Sep 17 00:00:00 2001 From: dmolinari Date: Mon, 13 Apr 2026 21:36:02 -0300 Subject: [PATCH] feat(udt-001): infrastructure (Dapper, BCrypt, JWT RS256, dispatcher) --- .../DependencyInjection.cs | 76 +++++++++++++++++++ .../Messaging/Dispatcher.cs | 20 +++++ .../Persistence/SqlConnectionFactory.cs | 15 ++++ .../Persistence/UsuarioRepository.cs | 60 +++++++++++++++ .../SIGCM2.Infrastructure.csproj | 24 ++++++ .../Security/BcryptPasswordHasher.cs | 15 ++++ .../Security/JwtOptions.cs | 20 +++++ .../Security/JwtService.cs | 68 +++++++++++++++++ .../Security/RsaKeyLoader.cs | 49 ++++++++++++ 9 files changed, 347 insertions(+) create mode 100644 src/api/SIGCM2.Infrastructure/DependencyInjection.cs create mode 100644 src/api/SIGCM2.Infrastructure/Messaging/Dispatcher.cs create mode 100644 src/api/SIGCM2.Infrastructure/Persistence/SqlConnectionFactory.cs create mode 100644 src/api/SIGCM2.Infrastructure/Persistence/UsuarioRepository.cs create mode 100644 src/api/SIGCM2.Infrastructure/SIGCM2.Infrastructure.csproj create mode 100644 src/api/SIGCM2.Infrastructure/Security/BcryptPasswordHasher.cs create mode 100644 src/api/SIGCM2.Infrastructure/Security/JwtOptions.cs create mode 100644 src/api/SIGCM2.Infrastructure/Security/JwtService.cs create mode 100644 src/api/SIGCM2.Infrastructure/Security/RsaKeyLoader.cs diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..858e6bd --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs @@ -0,0 +1,76 @@ +using System.Security.Cryptography; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Abstractions.Security; +using SIGCM2.Infrastructure.Messaging; +using SIGCM2.Infrastructure.Persistence; +using SIGCM2.Infrastructure.Security; + +namespace SIGCM2.Infrastructure; + +public static class DependencyInjection +{ + public static IServiceCollection AddInfrastructure( + this IServiceCollection services, + IConfiguration configuration) + { + // Database + var connectionString = configuration.GetConnectionString("SqlServer") + ?? throw new InvalidOperationException("Missing ConnectionStrings:SqlServer"); + services.AddSingleton(new SqlConnectionFactory(connectionString)); + services.AddScoped(); + + // JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost + services.Configure(configuration.GetSection("Jwt")); + // Also expose as JwtOptions directly for convenience (resolves via IOptions) + services.AddSingleton(sp => sp.GetRequiredService>().Value); + + // RSA key pair — loaded lazily as singletons from the fully-resolved JwtOptions + services.AddSingleton(sp => + { + var opts = sp.GetRequiredService(); + return RsaKeyLoader.LoadPrivateKey(opts); + }); + + services.AddSingleton(sp => + { + var opts = sp.GetRequiredService(); + return new RsaSecurityKey(RsaKeyLoader.LoadPublicKey(opts)); + }); + + services.AddScoped(sp => + new JwtService(sp.GetRequiredService(), sp.GetRequiredService())); + services.AddScoped(); + + // Dispatcher + services.AddScoped(); + + // JWT Bearer authentication + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(); + + // Post-configure JWT Bearer — wire RSA public key + validation params from resolved options + services.AddOptions(JwtBearerDefaults.AuthenticationScheme) + .PostConfigure((jwtBearerOpts, rsaKey, jwtOpts) => + { + jwtBearerOpts.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = rsaKey, + ValidateIssuer = true, + ValidIssuer = jwtOpts.Issuer, + ValidateAudience = true, + ValidAudience = jwtOpts.Audience, + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero + }; + }); + + return services; + } +} diff --git a/src/api/SIGCM2.Infrastructure/Messaging/Dispatcher.cs b/src/api/SIGCM2.Infrastructure/Messaging/Dispatcher.cs new file mode 100644 index 0000000..fabed48 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Messaging/Dispatcher.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; +using SIGCM2.Application.Abstractions; + +namespace SIGCM2.Infrastructure.Messaging; + +public sealed class Dispatcher : IDispatcher +{ + private readonly IServiceProvider _serviceProvider; + + public Dispatcher(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public Task Send(TCommand command) + { + var handler = _serviceProvider.GetRequiredService>(); + return handler.Handle(command!); + } +} diff --git a/src/api/SIGCM2.Infrastructure/Persistence/SqlConnectionFactory.cs b/src/api/SIGCM2.Infrastructure/Persistence/SqlConnectionFactory.cs new file mode 100644 index 0000000..f4863cf --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/SqlConnectionFactory.cs @@ -0,0 +1,15 @@ +using Microsoft.Data.SqlClient; + +namespace SIGCM2.Infrastructure.Persistence; + +public sealed class SqlConnectionFactory +{ + private readonly string _connectionString; + + public SqlConnectionFactory(string connectionString) + { + _connectionString = connectionString; + } + + public SqlConnection CreateConnection() => new(_connectionString); +} diff --git a/src/api/SIGCM2.Infrastructure/Persistence/UsuarioRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/UsuarioRepository.cs new file mode 100644 index 0000000..00d0275 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/UsuarioRepository.cs @@ -0,0 +1,60 @@ +using Dapper; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Infrastructure.Persistence; + +public sealed class UsuarioRepository : IUsuarioRepository +{ + private readonly SqlConnectionFactory _connectionFactory; + + public UsuarioRepository(SqlConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task GetByUsernameAsync(string username) + { + const string sql = """ + SELECT + Id, Username, PasswordHash, + Nombre, Apellido, Email, + Rol, PermisosJson, Activo + FROM dbo.Usuario + WHERE Username = @Username + AND Activo = 1 + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(); + + var row = await connection.QuerySingleOrDefaultAsync(sql, new { Username = username }); + + if (row is null) return null; + + return new Usuario( + id: row.Id, + username: row.Username, + passwordHash: row.PasswordHash, + nombre: row.Nombre, + apellido: row.Apellido, + email: row.Email, + rol: row.Rol, + permisosJson: row.PermisosJson, + activo: row.Activo + ); + } + + // Flat DTO for Dapper mapping (avoids polluting domain entity with Dapper attributes) + private sealed record UsuarioRow( + int Id, + string Username, + string PasswordHash, + string Nombre, + string Apellido, + string? Email, + string Rol, + string PermisosJson, + bool Activo + ); +} diff --git a/src/api/SIGCM2.Infrastructure/SIGCM2.Infrastructure.csproj b/src/api/SIGCM2.Infrastructure/SIGCM2.Infrastructure.csproj new file mode 100644 index 0000000..cf325e1 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/SIGCM2.Infrastructure.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + SIGCM2.Infrastructure + + + + + + + + + + + + + + + + + diff --git a/src/api/SIGCM2.Infrastructure/Security/BcryptPasswordHasher.cs b/src/api/SIGCM2.Infrastructure/Security/BcryptPasswordHasher.cs new file mode 100644 index 0000000..660e588 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Security/BcryptPasswordHasher.cs @@ -0,0 +1,15 @@ +using SIGCM2.Application.Abstractions.Security; +using BCryptNet = BCrypt.Net.BCrypt; + +namespace SIGCM2.Infrastructure.Security; + +public sealed class BcryptPasswordHasher : IPasswordHasher +{ + private const int WorkFactor = 12; + + public bool Verify(string plainPassword, string hash) + => BCryptNet.Verify(plainPassword, hash); + + public string Hash(string plainPassword) + => BCryptNet.HashPassword(plainPassword, WorkFactor); +} diff --git a/src/api/SIGCM2.Infrastructure/Security/JwtOptions.cs b/src/api/SIGCM2.Infrastructure/Security/JwtOptions.cs new file mode 100644 index 0000000..f9dffcb --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Security/JwtOptions.cs @@ -0,0 +1,20 @@ +namespace SIGCM2.Infrastructure.Security; + +public sealed class JwtOptions +{ + public string Issuer { get; set; } = "sigcm2.api"; + public string Audience { get; set; } = "sigcm2.web"; + public int AccessTokenMinutes { get; set; } = 60; + + /// Path to private.pem file (dev). Used if PrivateKey is null. + public string? PrivateKeyPath { get; set; } + + /// Path to public.pem file (dev). Used if PublicKey is null. + public string? PublicKeyPath { get; set; } + + /// PEM content from env var (production). Takes precedence over file. + public string? PrivateKey { get; set; } + + /// PEM content from env var (production). Takes precedence over file. + public string? PublicKey { get; set; } +} diff --git a/src/api/SIGCM2.Infrastructure/Security/JwtService.cs b/src/api/SIGCM2.Infrastructure/Security/JwtService.cs new file mode 100644 index 0000000..6af7842 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Security/JwtService.cs @@ -0,0 +1,68 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using SIGCM2.Application.Abstractions.Security; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Infrastructure.Security; + +public sealed class JwtService : IJwtService +{ + private readonly RSA _rsa; + private readonly JwtOptions _options; + + public JwtService(RSA rsa, JwtOptions options) + { + _rsa = rsa; + _options = options; + } + + public string GenerateAccessToken(Usuario usuario) + { + var signingKey = new RsaSecurityKey(_rsa); + var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256); + + var permisos = DeserializePermisos(usuario.PermisosJson); + + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, usuario.Id.ToString()), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new("name", usuario.Username), + new("rol", usuario.Rol), + }; + + // Add each permission as a separate claim + foreach (var permiso in permisos) + claims.Add(new Claim("permisos", permiso)); + + var now = DateTime.UtcNow; + var descriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Issuer = _options.Issuer, + Audience = _options.Audience, + IssuedAt = now, + Expires = now.AddMinutes(_options.AccessTokenMinutes), + SigningCredentials = credentials + }; + + var handler = new JwtSecurityTokenHandler(); + var token = handler.CreateToken(descriptor); + return handler.WriteToken(token); + } + + private static string[] DeserializePermisos(string permisosJson) + { + try + { + return JsonSerializer.Deserialize(permisosJson) ?? []; + } + catch + { + return []; + } + } +} diff --git a/src/api/SIGCM2.Infrastructure/Security/RsaKeyLoader.cs b/src/api/SIGCM2.Infrastructure/Security/RsaKeyLoader.cs new file mode 100644 index 0000000..adb4584 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Security/RsaKeyLoader.cs @@ -0,0 +1,49 @@ +using System.Security.Cryptography; + +namespace SIGCM2.Infrastructure.Security; + +public static class RsaKeyLoader +{ + /// + /// Loads the RSA private key from environment variable (production) + /// or from the PEM file on disk (development). + /// + public static RSA LoadPrivateKey(JwtOptions options) + { + var pem = options.PrivateKey ?? ReadPemFile(options.PrivateKeyPath, "private.pem"); + var rsa = RSA.Create(); + rsa.ImportFromPem(pem); + return rsa; + } + + /// + /// Loads the RSA public key from environment variable (production) + /// or from the PEM file on disk (development). + /// + public static RSA LoadPublicKey(JwtOptions options) + { + var pem = options.PublicKey ?? ReadPemFile(options.PublicKeyPath, "public.pem"); + var rsa = RSA.Create(); + rsa.ImportFromPem(pem); + return rsa; + } + + private static string ReadPemFile(string? path, string fallbackName) + { + if (string.IsNullOrWhiteSpace(path)) + { + throw new InvalidOperationException( + $"JWT key not configured. Set the env var or provide a path for {fallbackName}. " + + $"Run: pwsh -File scripts/generate-keys.ps1"); + } + + if (!File.Exists(path)) + { + throw new FileNotFoundException( + $"JWT key file not found at '{path}'. " + + $"Run: pwsh -File scripts/generate-keys.ps1", path); + } + + return File.ReadAllText(path); + } +}