using System.Net.Security; using System.Security.Cryptography.X509Certificates; using System.Threading.RateLimiting; using Microsoft.AspNetCore.Server.Kestrel.Https; 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"); 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(); 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) { request.ReleasePasswordReference(); return Results.BadRequest(new ErrorResponse("MISSING_PASSWORD", "La contraseña es requerida.")); } try { AuthenticationFlowResult result = await workflow .AuthenticateAsync(request.Clave, request.Password, cancellationToken) .ConfigureAwait(false); 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();