2026-04-13 21:36:02 -03:00
|
|
|
using System.Security.Cryptography;
|
|
|
|
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
2026-04-14 13:28:36 -03:00
|
|
|
using Microsoft.AspNetCore.Http;
|
2026-04-13 21:36:02 -03:00
|
|
|
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;
|
2026-04-16 13:28:37 -03:00
|
|
|
using SIGCM2.Application.Audit;
|
2026-04-14 13:28:36 -03:00
|
|
|
using SIGCM2.Application.Auth;
|
|
|
|
|
using SIGCM2.Infrastructure.Http;
|
2026-04-13 21:36:02 -03:00
|
|
|
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>();
|
2026-04-14 13:28:36 -03:00
|
|
|
services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
|
2026-04-15 12:50:24 -03:00
|
|
|
services.AddScoped<IRolRepository, RolRepository>();
|
2026-04-15 15:39:25 -03:00
|
|
|
services.AddScoped<IPermisoRepository, PermisoRepository>();
|
|
|
|
|
services.AddScoped<IRolPermisoRepository, RolPermisoRepository>();
|
2026-04-13 21:36:02 -03:00
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
2026-04-14 13:28:36 -03:00
|
|
|
// AuthOptions (Application layer) — populated from the same Jwt config section
|
|
|
|
|
services.AddSingleton<AuthOptions>(sp =>
|
|
|
|
|
{
|
|
|
|
|
var opts = sp.GetRequiredService<JwtOptions>();
|
|
|
|
|
return new AuthOptions
|
|
|
|
|
{
|
|
|
|
|
AccessTokenMinutes = opts.AccessTokenMinutes,
|
|
|
|
|
RefreshTokenDays = opts.RefreshTokenDays,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-13 21:36:02 -03:00
|
|
|
// 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>();
|
2026-04-14 13:28:36 -03:00
|
|
|
services.AddSingleton<IRefreshTokenGenerator, RefreshTokenGenerator>();
|
|
|
|
|
services.AddHttpContextAccessor();
|
|
|
|
|
services.AddScoped<IClientContext, ClientContext>();
|
2026-04-13 21:36:02 -03:00
|
|
|
|
2026-04-16 13:28:37 -03:00
|
|
|
// UDT-010: Audit options (SanitizedKeys blacklist) — overridable via appsettings "Audit".
|
|
|
|
|
services.Configure<AuditOptions>(configuration.GetSection(AuditOptions.SectionName));
|
feat(api): audit context middleware + scoped impl (UDT-010 B4)
Wires the request-scoped audit context per design #D-2:
Middleware pipeline in Program.cs:
app.UseCors()
app.UseMiddleware<CorrelationIdMiddleware>() // PRE-AUTH
app.UseAuthentication()
app.UseMiddleware<AuditActorMiddleware>() // POST-AUTH
app.UseAuthorization()
app.MapControllers()
SIGCM2.Api/Middleware/CorrelationIdMiddleware.cs:
- Preserves client-sent X-Correlation-Id header when a valid GUID, otherwise
generates Guid.NewGuid(). Stores in HttpContext.Items (audit:correlationId).
- Captures Ip (Connection.RemoteIpAddress) + UserAgent header into Items.
- Echoes the correlation id back via response header (OnStarting + immediate
set — immediate set makes unit testing against DefaultHttpContext reliable).
SIGCM2.Api/Middleware/AuditActorMiddleware.cs:
- Reads JWT 'sub' claim from authenticated HttpContext.User, parses to int,
stores as audit:actorUserId. Anonymous / non-numeric sub leaves it unset.
SIGCM2.Infrastructure/Audit/AuditContext.cs (IAuditContext scoped impl):
- Reads Items entries via IHttpContextAccessor. Returns null / Guid.Empty
when no HttpContext is available (jobs, tests without middleware).
- ActorRoleId intentionally null for now — rol code → id resolution is
deferred; the logger may resolve it at persist time in a later batch.
DI registration (Infrastructure/DependencyInjection.cs):
- services.AddScoped<IAuditContext, AuditContext>()
Tests (Strict TDD):
- CorrelationIdMiddlewareTests (6): generates/preserves/handles-malformed
correlation id, sets response header, captures ip/ua, calls next.
- AuditActorMiddlewareTests (5): authenticated/anonymous/no-sub/non-numeric/
calls-next.
- AuditContextTests (7): reads from Items, null-http-context defaults,
ActorRoleId currently null.
Suite: 355/355 Application.Tests + 141/141 Api.Tests = 496/496 passing.
Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-3/9, design#D-2, tasks#B4}
2026-04-16 13:32:13 -03:00
|
|
|
services.AddScoped<IAuditContext, SIGCM2.Infrastructure.Audit.AuditContext>();
|
feat(infra): audit + security event repositories (UDT-010 B5)
Introduces persistence layer for audit and security events per design #D-6:
SIGCM2.Application/Audit/:
- IAuditEventRepository: InsertAsync + QueryAsync with cursor pagination
- ISecurityEventRepository: InsertAsync only (no query — SecurityEvent is
queried only from an admin dashboard deferred to ADM-004)
- AuditEventQueryResult: (Items, NextCursor) record
SIGCM2.Infrastructure/Audit/:
- AuditEventCursor (public): base64(OccurredAt:O|Id) opaque cursor for
DESC pagination. TryDecode is fail-open — malformed cursor returns null
and the query starts from the top.
- AuditEventRepository: Dapper INSERT via OUTPUT INSERTED.Id + dynamic
WHERE composition with parameterized filters (zero SQL injection risk).
LEFT JOIN to dbo.Usuario to populate ActorUsername in AuditEventDto.
Pagination fetches Limit+1 rows to detect "more pages"; emits cursor
from the Nth row when overflow observed.
- SecurityEventRepository: straight INSERT for login/logout/refresh/
permission.denied events.
DI: AddScoped for both repos in AddInfrastructure.
Integration tests (Strict TDD): 13 total, all against SIGCM2_Test.
- AuditEventRepositoryTests (10): insert-roundtrip, filter-by-actor,
filter-by-target, filter-by-date-range, cursor pagination across 3 pages
(no overlap/no gap), malformed-cursor fail-open, LEFT JOIN Usuario
populates username, cursor encode/decode roundtrip, cursor malformed
variants.
- SecurityEventRepositoryTests (3): insert success, insert failure with
null ActorUserId + AttemptedUsername, CK_SecurityEvent_Result rejection.
Suite: 368/368 Application.Tests + 141/141 Api.Tests = 509/509 passing.
Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-2,7 #REQ-SEC-1,
design#D-6, tasks#B5}
2026-04-16 13:38:05 -03:00
|
|
|
services.AddScoped<IAuditEventRepository, SIGCM2.Infrastructure.Audit.AuditEventRepository>();
|
|
|
|
|
services.AddScoped<ISecurityEventRepository, SIGCM2.Infrastructure.Audit.SecurityEventRepository>();
|
feat(infra): AuditLogger + SecurityEventLogger impl (UDT-010 B6)
Composes the audit emission layer per design #D-8:
SIGCM2.Infrastructure/Audit/AuditLogger.cs (IAuditLogger):
- Enriches from IAuditContext (ActorUserId/ActorRoleId/Ip/UserAgent/CorrelationId).
- Sanitizes metadata via JsonSanitizer + AuditOptions.SanitizedKeys.
- Persists via IAuditEventRepository.InsertAsync.
- Fail-closed: throws AuditContextMissingException when ActorUserId is null.
- Translates Guid.Empty correlation id to null (DB column is nullable; Empty
indicates 'no middleware ran').
- Uses System.DateTime.UtcNow for occurredAt.
SIGCM2.Infrastructure/Audit/SecurityEventLogger.cs (ISecurityEventLogger):
- NOT fail-closed: null ActorUserId is valid (login failures, anonymous
permission.denied events).
- Ip/UserAgent pulled from IAuditContext; metadata sanitized the same way.
- Persists via ISecurityEventRepository.
DI: AddScoped for both loggers in AddInfrastructure.
Tests (Strict TDD, mocks for IAuditContext/IAuditEventRepository/
ISecurityEventRepository):
- AuditLoggerTests (6): happy path with full context, fail-closed null actor,
metadata sanitization, null metadata pass-through, repo-throws-bubbles-up
(critical for TransactionScope rollback), custom SanitizedKeys from options.
- SecurityEventLoggerTests (4): login.success with context, login.failure
with null actor + attemptedUsername, metadata sanitization,
permission.denied with both actor and attemptedUsername null.
Two initial failures were fixed by replacing 'null' literal arguments in
NSubstitute Received(...) assertions with Arg.Is<T?>(x => x == null) —
NSubstitute does not always match null literals when mixed with Arg.Any<T>().
Suite: 378/378 Application.Tests + 141/141 Api.Tests = 519/519 passing.
Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-4 #REQ-SEC-2/3, design#D-8, tasks#B6}
2026-04-16 13:41:10 -03:00
|
|
|
services.AddScoped<IAuditLogger, SIGCM2.Infrastructure.Audit.AuditLogger>();
|
|
|
|
|
services.AddScoped<ISecurityEventLogger, SIGCM2.Infrastructure.Audit.SecurityEventLogger>();
|
2026-04-16 13:28:37 -03:00
|
|
|
|
2026-04-13 21:36:02 -03:00
|
|
|
// Dispatcher
|
|
|
|
|
services.AddScoped<IDispatcher, Dispatcher>();
|
|
|
|
|
|
|
|
|
|
// JWT Bearer authentication
|
|
|
|
|
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|
|
|
|
.AddJwtBearer();
|
|
|
|
|
|
2026-04-15 11:03:15 -03:00
|
|
|
// Post-configure JWT Bearer — wire RSA public key + validation params from resolved options.
|
|
|
|
|
// MapInboundClaims=false: preserve JWT claim names as-is ("sub", "rol", etc.).
|
|
|
|
|
// Without this, the middleware maps "sub" → ClaimTypes.NameIdentifier and breaks User.FindFirst("sub").
|
2026-04-13 21:36:02 -03:00
|
|
|
services.AddOptions<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme)
|
|
|
|
|
.PostConfigure<RsaSecurityKey, JwtOptions>((jwtBearerOpts, rsaKey, jwtOpts) =>
|
|
|
|
|
{
|
2026-04-15 11:03:15 -03:00
|
|
|
jwtBearerOpts.MapInboundClaims = false;
|
2026-04-13 21:36:02 -03:00
|
|
|
jwtBearerOpts.TokenValidationParameters = new TokenValidationParameters
|
|
|
|
|
{
|
|
|
|
|
ValidateIssuerSigningKey = true,
|
|
|
|
|
IssuerSigningKey = rsaKey,
|
|
|
|
|
ValidateIssuer = true,
|
|
|
|
|
ValidIssuer = jwtOpts.Issuer,
|
|
|
|
|
ValidateAudience = true,
|
|
|
|
|
ValidAudience = jwtOpts.Audience,
|
|
|
|
|
ValidateLifetime = true,
|
2026-04-15 10:47:48 -03:00
|
|
|
ClockSkew = TimeSpan.Zero,
|
2026-04-15 11:03:15 -03:00
|
|
|
RoleClaimType = "rol",
|
|
|
|
|
NameClaimType = "name"
|
2026-04-13 21:36:02 -03:00
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return services;
|
|
|
|
|
}
|
|
|
|
|
}
|