Add SGU credential provider and authentication broker

This commit is contained in:
2026-08-31 17:48:18 -06:00
parent 5e216f42a4
commit 1f43f200b4
50 changed files with 3226 additions and 236 deletions
+127
View File
@@ -0,0 +1,127 @@
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<BrokerOptions>() ?? throw new InvalidOperationException("Broker configuration is missing.");
brokerOptions.Validate();
HashSet<string> 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<INtlmCredentialValidator, NtlmCredentialValidator>();
builder.Services.AddSingleton<IActiveDirectorySynchronizer, ActiveDirectorySynchronizer>();
builder.Services.AddScoped<AuthenticationWorkflow>();
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();