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
@@ -0,0 +1,14 @@
using System.Text.Json.Serialization;
namespace SGU.AuthBroker.Contracts;
public sealed class AuthenticationRequest
{
[JsonPropertyName("clave")]
public string Clave { get; set; } = string.Empty;
[JsonPropertyName("password")]
public string Password { get; set; } = string.Empty;
public void ReleasePasswordReference() => Password = string.Empty;
}
@@ -0,0 +1,14 @@
using System.Text.Json.Serialization;
namespace SGU.AuthBroker.Contracts;
public sealed record AuthenticationResponse(
[property: JsonPropertyName("domain")] string Domain,
[property: JsonPropertyName("username")] string UserName,
[property: JsonPropertyName("upn")] string UserPrincipalName,
[property: JsonPropertyName("created")] bool Created,
[property: JsonPropertyName("moved")] bool Moved);
public sealed record ErrorResponse(
[property: JsonPropertyName("code")] string Code,
[property: JsonPropertyName("message")] string Message);
+114
View File
@@ -0,0 +1,114 @@
using SGU.AuthBroker.Core.Identity;
namespace SGU.AuthBroker.Options;
public sealed class BrokerOptions
{
public const string SectionName = "Broker";
public TlsOptions Tls { get; init; } = new();
public NtlmOptions Ntlm { get; init; } = new();
public ActiveDirectoryOptions Directory { get; init; } = new();
public void Validate()
{
if (Tls.AllowedClientThumbprints.Length == 0 ||
Tls.AllowedClientThumbprints.Any(value => !IsCertificateThumbprint(value)))
{
throw new InvalidOperationException("At least one client certificate thumbprint is required.");
}
if (!Uri.TryCreate(Ntlm.Endpoint, UriKind.Absolute, out Uri? endpoint) || endpoint.Scheme != Uri.UriSchemeHttps)
{
throw new InvalidOperationException("The institutional NTLM endpoint must be an absolute HTTPS URL.");
}
if (Ntlm.AllowedRedirectHosts.Length == 0 ||
!Ntlm.AllowedRedirectHosts.Contains(endpoint.IdnHost, StringComparer.OrdinalIgnoreCase))
{
throw new InvalidOperationException("The NTLM endpoint host must be present in AllowedRedirectHosts.");
}
if (Ntlm.TimeoutSeconds is < 2 or > 60 || Ntlm.MaxRedirects is < 0 or > 10)
{
throw new InvalidOperationException("NTLM timeout or redirect limits are outside the supported range.");
}
if (string.IsNullOrWhiteSpace(Directory.LdapHost) ||
string.IsNullOrWhiteSpace(Directory.BaseDn) ||
string.IsNullOrWhiteSpace(Directory.DomainNetbios) ||
string.IsNullOrWhiteSpace(Directory.UpnSuffix))
{
throw new InvalidOperationException("Active Directory connection and domain settings are required.");
}
foreach (InstitutionalRole role in Enum.GetValues<InstitutionalRole>())
{
string ouDn = Directory.GetOuDn(role);
if (string.IsNullOrWhiteSpace(ouDn))
{
throw new InvalidOperationException($"An OU mapping is required for {role}.");
}
if (!ouDn.EndsWith($",{Directory.BaseDn}", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"The OU mapping for {role} must be beneath BaseDn.");
}
}
}
private static bool IsCertificateThumbprint(string value)
{
string normalized = value.Replace(" ", string.Empty, StringComparison.Ordinal);
return normalized.Length == 40 && normalized.All(Uri.IsHexDigit);
}
}
public sealed class TlsOptions
{
public string[] AllowedClientThumbprints { get; init; } = [];
public bool CheckCertificateRevocation { get; init; } = true;
}
public sealed class NtlmOptions
{
public string Endpoint { get; init; } = "https://sgu.ulsa.edu.mx/";
public string Domain { get; init; } = string.Empty;
public int TimeoutSeconds { get; init; } = 15;
public int MaxRedirects { get; init; } = 5;
public string[] AllowedRedirectHosts { get; init; } = ["sgu.ulsa.edu.mx"];
}
public sealed class ActiveDirectoryOptions
{
public string LdapHost { get; init; } = "localhost";
public string BaseDn { get; init; } = "DC=lci,DC=lasalle,DC=mx";
public string DomainNetbios { get; init; } = "LCI";
public string UpnSuffix { get; init; } = "lci.lasalle.mx";
public string ProfessorOuDn { get; init; } = "OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
public string StudentOuDn { get; init; } = "OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
public string AdministrativeOuDn { get; init; } = "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
public bool CreateMissingOus { get; init; }
public string GetOuDn(InstitutionalRole role) => role switch
{
InstitutionalRole.Professor => ProfessorOuDn,
InstitutionalRole.Student => StudentOuDn,
InstitutionalRole.Administrative => AdministrativeOuDn,
_ => throw new ArgumentOutOfRangeException(nameof(role), role, null)
};
}
+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();
+15
View File
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<AssemblyName>SGU.AuthBroker</AssemblyName>
<RootNamespace>SGU.AuthBroker</RootNamespace>
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\SGU.AuthBroker.Core\SGU.AuthBroker.Core.csproj" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.11" />
<PackageReference Include="System.DirectoryServices" Version="10.0.11" />
</ItemGroup>
</Project>
@@ -0,0 +1,198 @@
using System.Collections.Concurrent;
using System.DirectoryServices;
using SGU.AuthBroker.Core.Directory;
using SGU.AuthBroker.Core.Identity;
using SGU.AuthBroker.Options;
namespace SGU.AuthBroker.Services;
public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActiveDirectorySynchronizer
{
private const int AccountDisabled = 0x0002;
private const int NormalAccount = 0x0200;
private static readonly AuthenticationTypes BindFlags =
AuthenticationTypes.Secure | AuthenticationTypes.Signing | AuthenticationTypes.Sealing;
private readonly ActiveDirectoryOptions options = options.Directory;
private readonly ConcurrentDictionary<string, SemaphoreSlim> userLocks =
new(StringComparer.OrdinalIgnoreCase);
public async Task<DirectorySyncResult> SynchronizeAsync(
UserIdentity identity,
string password,
CancellationToken cancellationToken)
{
SemaphoreSlim gate = userLocks.GetOrAdd(identity.UserName, static _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return await Task.Run(
() => Synchronize(identity, password),
cancellationToken).ConfigureAwait(false);
}
finally
{
gate.Release();
if (gate.CurrentCount == 1)
{
userLocks.TryRemove(new KeyValuePair<string, SemaphoreSlim>(identity.UserName, gate));
}
}
}
private DirectorySyncResult Synchronize(UserIdentity identity, string password)
{
string targetOuDn = options.GetOuDn(identity.Role);
using DirectoryEntry root = Bind(options.BaseDn);
using DirectoryEntry targetOu = BindOrCreateOu(targetOuDn, root);
using DirectorySearcher searcher = new(root)
{
Filter = $"(&(objectCategory=person)(objectClass=user)(sAMAccountName={EscapeLdapFilter(identity.UserName)}))",
SearchScope = SearchScope.Subtree,
PageSize = 1,
SizeLimit = 1
};
searcher.PropertiesToLoad.Add("distinguishedName");
SearchResult? result = searcher.FindOne();
bool created = result is null;
bool moved = false;
DirectoryEntry? user = null;
try
{
if (created)
{
user = targetOu.Children.Add($"CN={EscapeRdn(identity.UserName)}", "user");
user.Properties["sAMAccountName"].Value = identity.UserName;
user.Properties["userPrincipalName"].Value = $"{identity.UserName}@{options.UpnSuffix}";
user.Properties["displayName"].Value = identity.UserName;
user.CommitChanges();
}
else
{
user = result!.GetDirectoryEntry();
string distinguishedName = Convert.ToString(user.Properties["distinguishedName"].Value) ?? string.Empty;
string parentDn = ParentDn(distinguishedName);
if (!string.Equals(parentDn, targetOuDn, StringComparison.OrdinalIgnoreCase))
{
user.MoveTo(targetOu);
moved = true;
}
user.Properties["userPrincipalName"].Value = $"{identity.UserName}@{options.UpnSuffix}";
user.CommitChanges();
}
// The exact institutional password received by the broker is passed to AD.
// It is not derived, transformed, written to disk, or included in logs.
user.Invoke("SetPassword", [password]);
int flags = user.Properties["userAccountControl"].Value is int currentFlags
? currentFlags
: NormalAccount;
user.Properties["userAccountControl"].Value = (flags | NormalAccount) & ~AccountDisabled;
user.Properties["pwdLastSet"].Value = -1;
user.CommitChanges();
return new DirectorySyncResult(
options.DomainNetbios,
identity.UserName,
$"{identity.UserName}@{options.UpnSuffix}",
created,
moved);
}
finally
{
user?.Dispose();
}
}
private DirectoryEntry BindOrCreateOu(string ouDn, DirectoryEntry root)
{
try
{
DirectoryEntry existing = Bind(ouDn);
_ = existing.NativeObject;
return existing;
}
catch (DirectoryServicesCOMException) when (options.CreateMissingOus)
{
string parent = ParentDn(ouDn);
if (!ouDn.EndsWith($",{options.BaseDn}", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Automatic OU creation is limited to descendants of BaseDn.");
}
string rdn = ouDn[..FirstUnescapedComma(ouDn)];
DirectoryEntry? parentEntry = null;
try
{
DirectoryEntry container = root;
if (!string.Equals(parent, options.BaseDn, StringComparison.OrdinalIgnoreCase))
{
parentEntry = BindOrCreateOu(parent, root);
container = parentEntry;
}
DirectoryEntry created = container.Children.Add(rdn, "organizationalUnit");
created.CommitChanges();
return created;
}
finally
{
parentEntry?.Dispose();
}
}
}
private DirectoryEntry Bind(string distinguishedName) =>
new($"LDAP://{options.LdapHost}/{distinguishedName}", null, null, BindFlags);
private static string ParentDn(string distinguishedName)
{
int comma = FirstUnescapedComma(distinguishedName);
return comma < 0 ? string.Empty : distinguishedName[(comma + 1)..];
}
private static int FirstUnescapedComma(string value)
{
bool escaped = false;
for (int i = 0; i < value.Length; i++)
{
if (escaped)
{
escaped = false;
continue;
}
if (value[i] == '\\')
{
escaped = true;
}
else if (value[i] == ',')
{
return i;
}
}
return -1;
}
private static string EscapeLdapFilter(string value) => value
.Replace("\\", "\\5c", StringComparison.Ordinal)
.Replace("*", "\\2a", StringComparison.Ordinal)
.Replace("(", "\\28", StringComparison.Ordinal)
.Replace(")", "\\29", StringComparison.Ordinal)
.Replace("\0", "\\00", StringComparison.Ordinal);
private static string EscapeRdn(string value) => value
.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace(",", "\\,", StringComparison.Ordinal)
.Replace("+", "\\+", StringComparison.Ordinal)
.Replace("\"", "\\\"", StringComparison.Ordinal)
.Replace("<", "\\<", StringComparison.Ordinal)
.Replace(">", "\\>", StringComparison.Ordinal)
.Replace(";", "\\;", StringComparison.Ordinal)
.Replace("=", "\\=", StringComparison.Ordinal);
}
@@ -0,0 +1,125 @@
using System.Net;
using SGU.AuthBroker.Core.Authentication;
using SGU.AuthBroker.Options;
namespace SGU.AuthBroker.Services;
public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCredentialValidator
{
private readonly NtlmOptions options = options.Ntlm;
public async Task<NtlmValidationResult> ValidateAsync(
string userName,
string password,
CancellationToken cancellationToken)
{
Uri current = new(this.options.Endpoint, UriKind.Absolute);
HashSet<string> allowedHosts = new(
this.options.AllowedRedirectHosts,
StringComparer.OrdinalIgnoreCase);
NetworkCredential credential = new(userName, password, this.options.Domain);
CredentialCache credentialCache = new();
HashSet<string> credentialedAuthorities = new(StringComparer.OrdinalIgnoreCase);
using HttpClientHandler handler = new()
{
AllowAutoRedirect = false,
AutomaticDecompression = DecompressionMethods.All,
CheckCertificateRevocationList = true,
Credentials = credentialCache,
MaxConnectionsPerServer = 4,
MaxResponseHeadersLength = 64,
PreAuthenticate = false,
UseCookies = false,
UseDefaultCredentials = false,
UseProxy = false
};
using HttpClient client = new(handler)
{
Timeout = Timeout.InfiniteTimeSpan,
DefaultRequestVersion = HttpVersion.Version11,
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact
};
client.DefaultRequestHeaders.UserAgent.ParseAdd("SGU-AuthBroker/1.0");
try
{
for (int hop = 0; hop <= this.options.MaxRedirects; hop++)
{
if (!IsAllowedHttpsUri(current, allowedHosts))
{
return NtlmValidationResult.Unavailable("NTLM_REDIRECT_REJECTED");
}
string authority = current.GetLeftPart(UriPartial.Authority);
if (credentialedAuthorities.Add(authority))
{
credentialCache.Add(new Uri(authority + "/"), "NTLM", credential);
}
using HttpRequestMessage request = new(HttpMethod.Get, current);
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(this.options.TimeoutSeconds));
HttpResponseMessage response;
try
{
response = await client
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return NtlmValidationResult.Unavailable("NTLM_TIMEOUT");
}
catch (HttpRequestException)
{
return NtlmValidationResult.Unavailable();
}
using (response)
{
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
return NtlmValidationResult.Invalid();
}
int statusCode = (int)response.StatusCode;
if (statusCode >= 500)
{
return NtlmValidationResult.Unavailable("NTLM_UPSTREAM_ERROR");
}
if (statusCode is >= 300 and < 400)
{
Uri? location = response.Headers.Location;
if (location is null)
{
return NtlmValidationResult.Unavailable("NTLM_INVALID_REDIRECT");
}
current = location.IsAbsoluteUri ? location : new Uri(current, location);
continue;
}
return statusCode is >= 200 and < 300
? NtlmValidationResult.Valid()
: NtlmValidationResult.Invalid();
}
}
return NtlmValidationResult.Unavailable("NTLM_REDIRECT_LIMIT");
}
finally
{
credential.Password = string.Empty;
}
}
private static bool IsAllowedHttpsUri(Uri uri, HashSet<string> allowedHosts) =>
uri.Scheme == Uri.UriSchemeHttps &&
string.IsNullOrEmpty(uri.UserInfo) &&
allowedHosts.Contains(uri.IdnHost);
}
+49
View File
@@ -0,0 +1,49 @@
{
"AllowedHosts": "*",
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Kestrel": {
"Endpoints": {
"Https": {
"Url": "https://0.0.0.0:8443",
"Certificate": {
"Subject": "sgu-auth.lci.lasalle.mx",
"Store": "My",
"Location": "LocalMachine",
"AllowInvalid": false
}
}
}
},
"Broker": {
"Tls": {
"AllowedClientThumbprints": [
"SET-BY-DEPLOYMENT"
],
"CheckCertificateRevocation": true
},
"Ntlm": {
"Endpoint": "https://sgu.ulsa.edu.mx/",
"Domain": "",
"TimeoutSeconds": 15,
"MaxRedirects": 5,
"AllowedRedirectHosts": [
"sgu.ulsa.edu.mx"
]
},
"Directory": {
"LdapHost": "localhost",
"BaseDn": "DC=lci,DC=lasalle,DC=mx",
"DomainNetbios": "LCI",
"UpnSuffix": "lci.lasalle.mx",
"ProfessorOuDn": "OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"StudentOuDn": "OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"AdministrativeOuDn": "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"CreateMissingOus": false
}
}
}