UDT-001: Login (scaffolding + JWT RS256 end-to-end) #1

Merged
dmolinari merged 14 commits from feature/UDT-001 into main 2026-04-14 14:44:28 +00:00
9 changed files with 347 additions and 0 deletions
Showing only changes of commit ca57ce33b5 - Show all commits

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