196 lines
8.1 KiB
C#
196 lines
8.1 KiB
C#
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<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();
|
|
ILogger auditLogger = app.Services.GetRequiredService<ILoggerFactory>()
|
|
.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
|
|
: "<invalid-format>";
|
|
}
|