feat(udt-001): infrastructure (Dapper, BCrypt, JWT RS256, dispatcher)

This commit is contained in:
2026-04-13 21:36:02 -03:00
parent 8c26cd3ac5
commit ca57ce33b5
9 changed files with 347 additions and 0 deletions

View File

@@ -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<IUsuarioRepository, UsuarioRepository>();
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
// Also expose as JwtOptions directly for convenience (resolves via IOptions<JwtOptions>)
services.AddSingleton<JwtOptions>(sp => sp.GetRequiredService<IOptions<JwtOptions>>().Value);
// RSA key pair — loaded lazily as singletons from the fully-resolved JwtOptions
services.AddSingleton<RSA>(sp =>
{
var opts = sp.GetRequiredService<JwtOptions>();
return RsaKeyLoader.LoadPrivateKey(opts);
});
services.AddSingleton<RsaSecurityKey>(sp =>
{
var opts = sp.GetRequiredService<JwtOptions>();
return new RsaSecurityKey(RsaKeyLoader.LoadPublicKey(opts));
});
services.AddScoped<IJwtService>(sp =>
new JwtService(sp.GetRequiredService<RSA>(), sp.GetRequiredService<JwtOptions>()));
services.AddScoped<IPasswordHasher, BcryptPasswordHasher>();
// Dispatcher
services.AddScoped<IDispatcher, Dispatcher>();
// JWT Bearer authentication
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer();
// Post-configure JWT Bearer — wire RSA public key + validation params from resolved options
services.AddOptions<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme)
.PostConfigure<RsaSecurityKey, JwtOptions>((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;
}
}

View File

@@ -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<TResult> Send<TCommand, TResult>(TCommand command)
{
var handler = _serviceProvider.GetRequiredService<ICommandHandler<TCommand, TResult>>();
return handler.Handle(command!);
}
}

View File

@@ -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);
}

View File

@@ -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<Usuario?> 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<UsuarioRow>(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
);
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>SIGCM2.Infrastructure</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" />
<PackageReference Include="Microsoft.Data.SqlClient" />
<PackageReference Include="BCrypt.Net-Next" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SIGCM2.Application\SIGCM2.Application.csproj" />
<ProjectReference Include="..\SIGCM2.Domain\SIGCM2.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -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);
}

View File

@@ -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;
/// <summary>Path to private.pem file (dev). Used if PrivateKey is null.</summary>
public string? PrivateKeyPath { get; set; }
/// <summary>Path to public.pem file (dev). Used if PublicKey is null.</summary>
public string? PublicKeyPath { get; set; }
/// <summary>PEM content from env var (production). Takes precedence over file.</summary>
public string? PrivateKey { get; set; }
/// <summary>PEM content from env var (production). Takes precedence over file.</summary>
public string? PublicKey { get; set; }
}

View File

@@ -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<Claim>
{
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<string[]>(permisosJson) ?? [];
}
catch
{
return [];
}
}
}

View File

@@ -0,0 +1,49 @@
using System.Security.Cryptography;
namespace SIGCM2.Infrastructure.Security;
public static class RsaKeyLoader
{
/// <summary>
/// Loads the RSA private key from environment variable (production)
/// or from the PEM file on disk (development).
/// </summary>
public static RSA LoadPrivateKey(JwtOptions options)
{
var pem = options.PrivateKey ?? ReadPemFile(options.PrivateKeyPath, "private.pem");
var rsa = RSA.Create();
rsa.ImportFromPem(pem);
return rsa;
}
/// <summary>
/// Loads the RSA public key from environment variable (production)
/// or from the PEM file on disk (development).
/// </summary>
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);
}
}