using System.Diagnostics; using System.Net.Security; using System.Security.Cryptography.X509Certificates; using System.Threading.RateLimiting; using Microsoft.AspNetCore.Server.Kestrel.Https; using SGU.AuthBroker; using SGU.AuthBroker.Contracts; using SGU.AuthBroker.Core.Authentication; using SGU.AuthBroker.Core.Directory; using SGU.AuthBroker.Options; using SGU.AuthBroker.Services; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); builder.Host.UseWindowsService(options => options.ServiceName = "SGU Authentication Broker"); if (builder.Configuration.GetValue("Broker:Diagnostics:UseDedicatedEventLog", false)) { builder.Logging.ClearProviders(); builder.Logging.AddEventLog(settings => { settings.LogName = "SGU Auth Broker"; settings.SourceName = "SGU.AuthBroker.Operational"; settings.Filter = (_, level) => level >= LogLevel.Information; }); } BrokerOptions brokerOptions = builder.Configuration .GetSection(BrokerOptions.SectionName) .Get() ?? throw new InvalidOperationException("Broker configuration is missing."); brokerOptions.Validate(); HashSet allowedClientThumbprints = brokerOptions.Tls.AllowedClientThumbprints .Select(NormalizeThumbprint) .ToHashSet(StringComparer.OrdinalIgnoreCase); builder.WebHost.ConfigureKestrel(kestrel => { kestrel.AddServerHeader = false; kestrel.Limits.MaxRequestBodySize = 4096; kestrel.ConfigureHttpsDefaults(https => { https.ClientCertificateMode = ClientCertificateMode.RequireCertificate; https.CheckCertificateRevocation = brokerOptions.Tls.CheckCertificateRevocation; https.ClientCertificateValidation = (certificate, _, policyErrors) => policyErrors == SslPolicyErrors.None && allowedClientThumbprints.Contains(NormalizeThumbprint(certificate.Thumbprint)); }); }); builder.Logging.AddFilter("Microsoft.AspNetCore", LogLevel.Warning); builder.Services.AddSingleton(brokerOptions); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.AddPolicy("credential-auth", context => { string partition = NormalizeThumbprint(context.Connection.ClientCertificate?.Thumbprint ?? "none"); return RateLimitPartition.GetFixedWindowLimiter(partition, _ => new FixedWindowRateLimiterOptions { PermitLimit = 12, QueueLimit = 0, Window = TimeSpan.FromMinutes(1), AutoReplenishment = true }); }); }); WebApplication app = builder.Build(); ILogger auditLogger = app.Services.GetRequiredService() .CreateLogger("SGU.AuthBroker.Audit"); auditLogger.LogInformation( BrokerEventIds.BrokerStarted, "SGU Authentication Broker started with dedicated operational diagnostics enabled={DedicatedDiagnosticsEnabled}.", builder.Configuration.GetValue("Broker:Diagnostics:UseDedicatedEventLog", false)); app.UseRateLimiter(); app.Use(async (context, next) => { context.Response.Headers.CacheControl = "no-store"; context.Response.Headers.Pragma = "no-cache"; context.Response.Headers["X-Content-Type-Options"] = "nosniff"; await next(context).ConfigureAwait(false); }); app.MapGet("/health/live", () => Results.Ok(new { status = "ok" })); app.MapPost("/v1/authenticate", async ( AuthenticationRequest request, AuthenticationWorkflow workflow, HttpContext context, CancellationToken cancellationToken) => { if (string.IsNullOrWhiteSpace(request.Password) || request.Password.Length > 256) { auditLogger.LogInformation( BrokerEventIds.AuthenticationInvalidRequest, "Authentication request rejected before validation for {InstitutionalUser}: password was missing or outside the supported length.", SafeUserName(request.Clave)); request.ReleasePasswordReference(); return Results.BadRequest(new ErrorResponse("MISSING_PASSWORD", "La contraseña es requerida.")); } Stopwatch elapsed = Stopwatch.StartNew(); try { AuthenticationFlowResult result = await workflow .AuthenticateAsync(request.Clave, request.Password, cancellationToken) .ConfigureAwait(false); string institutionalUser = result.Identity?.UserName ?? SafeUserName(request.Clave); switch (result.Outcome) { case AuthenticationFlowOutcome.Authorized: auditLogger.LogInformation( BrokerEventIds.AuthenticationAuthorized, "Authentication completed for {InstitutionalUser} with role {Role} in {ElapsedMilliseconds} ms. AD created={Created}; moved={Moved}.", institutionalUser, result.Identity!.Role, elapsed.ElapsedMilliseconds, result.Directory!.Created, result.Directory.Moved); break; case AuthenticationFlowOutcome.InvalidCredentials: auditLogger.LogInformation( BrokerEventIds.AuthenticationRejected, "Authentication was rejected for {InstitutionalUser} with code {ErrorCode} after {ElapsedMilliseconds} ms.", institutionalUser, result.ErrorCode, elapsed.ElapsedMilliseconds); break; case AuthenticationFlowOutcome.Unavailable: auditLogger.LogWarning( BrokerEventIds.AuthenticationUnavailable, "Authentication was unavailable for {InstitutionalUser} with code {ErrorCode} after {ElapsedMilliseconds} ms.", institutionalUser, result.ErrorCode, elapsed.ElapsedMilliseconds); break; default: auditLogger.LogInformation( BrokerEventIds.AuthenticationInvalidRequest, "Authentication request had an invalid institutional user format after {ElapsedMilliseconds} ms.", elapsed.ElapsedMilliseconds); break; } return result.Outcome switch { AuthenticationFlowOutcome.Authorized => Results.Ok(new AuthenticationResponse( result.Directory!.DomainNetbios, result.Directory.UserName, result.Directory.UserPrincipalName, result.Directory.Created, result.Directory.Moved)), AuthenticationFlowOutcome.InvalidUserName => Results.BadRequest(new ErrorResponse( result.ErrorCode ?? "INVALID_USERNAME_FORMAT", "La clave debe usar DO, AL o AD seguido de seis dígitos.")), AuthenticationFlowOutcome.InvalidCredentials => Results.Json( new ErrorResponse( result.ErrorCode ?? "INVALID_INSTITUTIONAL_CREDENTIALS", "Credenciales institucionales inválidas."), statusCode: StatusCodes.Status401Unauthorized), _ => Unavailable(context, result.ErrorCode) }; } finally { request.ReleasePasswordReference(); } }).RequireRateLimiting("credential-auth"); app.Run(); static IResult Unavailable(HttpContext context, string? errorCode) { context.Response.Headers.RetryAfter = "2"; return Results.Json( new ErrorResponse(errorCode ?? "AUTHENTICATION_SERVICE_UNAVAILABLE", "El servicio no está disponible."), statusCode: StatusCodes.Status503ServiceUnavailable); } static string NormalizeThumbprint(string value) => value.Replace(" ", string.Empty, StringComparison.Ordinal).ToUpperInvariant(); static string SafeUserName(string? value) { string candidate = value?.Trim().ToUpperInvariant() ?? string.Empty; return candidate.Length is > 0 and <= 16 && candidate.All(char.IsAsciiLetterOrDigit) ? candidate : ""; }