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,18 @@
using SGU.AuthBroker.Core.Directory;
using SGU.AuthBroker.Core.Identity;
namespace SGU.AuthBroker.Core.Authentication;
public enum AuthenticationFlowOutcome
{
Authorized,
InvalidCredentials,
InvalidUserName,
Unavailable
}
public sealed record AuthenticationFlowResult(
AuthenticationFlowOutcome Outcome,
string? ErrorCode = null,
UserIdentity? Identity = null,
DirectorySyncResult? Directory = null);
@@ -0,0 +1,80 @@
using SGU.AuthBroker.Core.Directory;
using SGU.AuthBroker.Core.Identity;
namespace SGU.AuthBroker.Core.Authentication;
public sealed class AuthenticationWorkflow(
INtlmCredentialValidator ntlmValidator,
IActiveDirectorySynchronizer directorySynchronizer)
{
public async Task<AuthenticationFlowResult> AuthenticateAsync(
string userName,
string password,
CancellationToken cancellationToken)
{
if (!UserIdentityClassifier.TryParse(userName, out UserIdentity? identity) || identity is null)
{
return new AuthenticationFlowResult(
AuthenticationFlowOutcome.InvalidUserName,
"INVALID_USERNAME_FORMAT");
}
NtlmValidationResult validation;
try
{
validation = await ntlmValidator
.ValidateAsync(identity.UserName, password, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return new AuthenticationFlowResult(
AuthenticationFlowOutcome.Unavailable,
"INSTITUTIONAL_AUTHORITY_UNAVAILABLE",
identity);
}
if (validation.Status == NtlmValidationStatus.Invalid)
{
return new AuthenticationFlowResult(
AuthenticationFlowOutcome.InvalidCredentials,
validation.ErrorCode,
identity);
}
if (validation.Status == NtlmValidationStatus.Unavailable)
{
return new AuthenticationFlowResult(
AuthenticationFlowOutcome.Unavailable,
validation.ErrorCode,
identity);
}
try
{
DirectorySyncResult directory = await directorySynchronizer
.SynchronizeAsync(identity, password, cancellationToken)
.ConfigureAwait(false);
return new AuthenticationFlowResult(
AuthenticationFlowOutcome.Authorized,
Identity: identity,
Directory: directory);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return new AuthenticationFlowResult(
AuthenticationFlowOutcome.Unavailable,
"DIRECTORY_SYNCHRONIZATION_FAILED",
identity);
}
}
}
@@ -0,0 +1,9 @@
namespace SGU.AuthBroker.Core.Authentication;
public interface INtlmCredentialValidator
{
Task<NtlmValidationResult> ValidateAsync(
string userName,
string password,
CancellationToken cancellationToken);
}
@@ -0,0 +1,18 @@
namespace SGU.AuthBroker.Core.Authentication;
public enum NtlmValidationStatus
{
Valid,
Invalid,
Unavailable
}
public sealed record NtlmValidationResult(NtlmValidationStatus Status, string? ErrorCode = null)
{
public static NtlmValidationResult Valid() => new(NtlmValidationStatus.Valid);
public static NtlmValidationResult Invalid() => new(NtlmValidationStatus.Invalid, "INVALID_INSTITUTIONAL_CREDENTIALS");
public static NtlmValidationResult Unavailable(string errorCode = "INSTITUTIONAL_AUTHORITY_UNAVAILABLE") =>
new(NtlmValidationStatus.Unavailable, errorCode);
}
@@ -0,0 +1,8 @@
namespace SGU.AuthBroker.Core.Directory;
public sealed record DirectorySyncResult(
string DomainNetbios,
string UserName,
string UserPrincipalName,
bool Created,
bool Moved);
@@ -0,0 +1,11 @@
using SGU.AuthBroker.Core.Identity;
namespace SGU.AuthBroker.Core.Directory;
public interface IActiveDirectorySynchronizer
{
Task<DirectorySyncResult> SynchronizeAsync(
UserIdentity identity,
string password,
CancellationToken cancellationToken);
}
@@ -0,0 +1,8 @@
namespace SGU.AuthBroker.Core.Identity;
public enum InstitutionalRole
{
Professor,
Student,
Administrative
}
@@ -0,0 +1,7 @@
namespace SGU.AuthBroker.Core.Identity;
public sealed record UserIdentity(
string UserName,
string Prefix,
string NumericId,
InstitutionalRole Role);
@@ -0,0 +1,50 @@
using System.Text.RegularExpressions;
namespace SGU.AuthBroker.Core.Identity;
public static partial class UserIdentityClassifier
{
public static bool TryParse(string? value, out UserIdentity? identity)
{
identity = null;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
string candidate = value.Trim();
int slash = candidate.LastIndexOf('\\');
if (slash >= 0)
{
candidate = candidate[(slash + 1)..];
}
int at = candidate.IndexOf('@');
if (at >= 0)
{
candidate = candidate[..at];
}
Match match = InstitutionalUserName().Match(candidate);
if (!match.Success)
{
return false;
}
string prefix = match.Groups["prefix"].Value.ToUpperInvariant();
InstitutionalRole role = prefix switch
{
"DO" => InstitutionalRole.Professor,
"AL" => InstitutionalRole.Student,
"AD" => InstitutionalRole.Administrative,
_ => throw new InvalidOperationException("Validated prefix was not mapped.")
};
string numericId = match.Groups["id"].Value;
identity = new UserIdentity(prefix + numericId, prefix, numericId, role);
return true;
}
[GeneratedRegex("^(?<prefix>DO|AL|AD)(?<id>[0-9]{6})$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex InstitutionalUserName();
}
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AssemblyName>SGU.AuthBroker.Core</AssemblyName>
<RootNamespace>SGU.AuthBroker.Core</RootNamespace>
</PropertyGroup>
</Project>
@@ -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
}
}
}
+165
View File
@@ -0,0 +1,165 @@
using System.Net;
using System.Net.Http.Json;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json.Serialization;
namespace SGU.CredentialProvider;
internal sealed class BrokerClient : IDisposable
{
private readonly ProviderSettings settings;
private readonly HttpClient client;
public BrokerClient(ProviderSettings settings)
: this(settings, CreateHandler(settings))
{
}
internal BrokerClient(ProviderSettings settings, HttpMessageHandler handler)
{
this.settings = settings;
client = new HttpClient(handler, disposeHandler: true)
{
Timeout = Timeout.InfiniteTimeSpan,
DefaultRequestVersion = HttpVersion.Version11,
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact
};
}
public async Task<BrokerDecision> AuthenticateAsync(
string userName,
string password,
CancellationToken cancellationToken)
{
BrokerRequest body = new() { Clave = userName, Password = password };
using HttpRequestMessage request = new(HttpMethod.Post, settings.BrokerEndpoint)
{
Content = JsonContent.Create(body)
};
request.Headers.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue { NoStore = true };
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(settings.TimeoutSeconds));
try
{
using HttpResponseMessage response = await client
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token)
.ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.BadRequest)
{
ErrorBody? error = await ReadJsonAsync<ErrorBody>(response, timeout.Token).ConfigureAwait(false);
return BrokerDecision.Invalid(error?.Code);
}
if (response.StatusCode == HttpStatusCode.OK)
{
AuthorizedBody? authorized = await ReadJsonAsync<AuthorizedBody>(response, timeout.Token).ConfigureAwait(false);
if (authorized is null ||
string.IsNullOrWhiteSpace(authorized.Domain) ||
string.IsNullOrWhiteSpace(authorized.UserName))
{
return BrokerDecision.Unavailable("INVALID_BROKER_RESPONSE");
}
return BrokerDecision.Authorized(authorized.Domain, authorized.UserName);
}
return BrokerDecision.Unavailable($"BROKER_HTTP_{(int)response.StatusCode}");
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return BrokerDecision.Unavailable("BROKER_TIMEOUT");
}
catch (HttpRequestException)
{
return BrokerDecision.Unavailable("BROKER_UNAVAILABLE");
}
finally
{
body.ReleasePasswordReference();
}
}
public void Dispose() => client.Dispose();
private static async Task<T?> ReadJsonAsync<T>(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.Content.Headers.ContentLength is > 16 * 1024)
{
return default;
}
try
{
return await response.Content.ReadFromJsonAsync<T>(cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (exception is System.Text.Json.JsonException or NotSupportedException)
{
return default;
}
}
private static HttpMessageHandler CreateHandler(ProviderSettings settings)
{
X509Certificate2 clientCertificate = LoadClientCertificate(settings.ClientCertificateThumbprint);
string expectedServerThumbprint = ProviderSettings.NormalizeThumbprint(settings.ServerCertificateThumbprint);
HttpClientHandler handler = new()
{
AllowAutoRedirect = false,
CheckCertificateRevocationList = true,
ClientCertificateOptions = ClientCertificateOption.Manual,
MaxConnectionsPerServer = 2,
MaxResponseHeadersLength = 32,
UseCookies = false,
UseDefaultCredentials = false,
UseProxy = false,
ServerCertificateCustomValidationCallback = (_, certificate, _, policyErrors) =>
policyErrors == SslPolicyErrors.None &&
certificate is not null &&
string.Equals(
ProviderSettings.NormalizeThumbprint(certificate.GetCertHashString()),
expectedServerThumbprint,
StringComparison.OrdinalIgnoreCase)
};
handler.ClientCertificates.Add(clientCertificate);
return handler;
}
private static X509Certificate2 LoadClientCertificate(string thumbprint)
{
using X509Store store = new(StoreName.My, StoreLocation.LocalMachine);
store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly);
X509Certificate2Collection matches = store.Certificates.Find(
X509FindType.FindByThumbprint,
ProviderSettings.NormalizeThumbprint(thumbprint),
validOnly: true);
X509Certificate2? certificate = matches
.OfType<X509Certificate2>()
.FirstOrDefault(item => item.HasPrivateKey);
return certificate is null
? throw new InvalidOperationException("The Credential Provider client certificate is unavailable.")
: new X509Certificate2(certificate);
}
private sealed class BrokerRequest
{
[JsonPropertyName("clave")]
public string Clave { get; init; } = string.Empty;
[JsonPropertyName("password")]
public string Password { get; set; } = string.Empty;
public void ReleasePasswordReference() => Password = string.Empty;
}
private sealed record AuthorizedBody(
[property: JsonPropertyName("domain")] string Domain,
[property: JsonPropertyName("username")] string UserName);
private sealed record ErrorBody([property: JsonPropertyName("code")] string? Code);
}
@@ -0,0 +1,24 @@
namespace SGU.CredentialProvider;
internal enum BrokerDecisionKind
{
Authorized,
InvalidCredentials,
Unavailable
}
internal sealed record BrokerDecision(
BrokerDecisionKind Kind,
string? Domain = null,
string? UserName = null,
string? ErrorCode = null)
{
public static BrokerDecision Authorized(string domain, string userName) =>
new(BrokerDecisionKind.Authorized, domain, userName);
public static BrokerDecision Invalid(string? errorCode = null) =>
new(BrokerDecisionKind.InvalidCredentials, ErrorCode: errorCode);
public static BrokerDecision Unavailable(string? errorCode = null) =>
new(BrokerDecisionKind.Unavailable, ErrorCode: errorCode);
}
+10
View File
@@ -0,0 +1,10 @@
namespace SGU.CredentialProvider;
internal static class ControlKeys
{
public const string ProviderLabel = "ProviderLabel";
public const string InformationLabel = "InformationLabel";
public const string UserName = "UserName";
public const string Password = "Password";
public const string Submit = "Submit";
}
@@ -0,0 +1,71 @@
using System.Text.Json;
namespace SGU.CredentialProvider;
internal sealed class ProviderSettings
{
private const int MaximumSettingsBytes = 16 * 1024;
public Uri BrokerEndpoint { get; init; } = new("https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate");
public string DomainNetbios { get; init; } = "LCI";
public int TimeoutSeconds { get; init; } = 6;
public string ClientCertificateThumbprint { get; init; } = string.Empty;
public string ServerCertificateThumbprint { get; init; } = string.Empty;
public static string DefaultPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"SGU",
"CredentialProvider",
"settings.json");
public static ProviderSettings Load(string? path = null)
{
path ??= Environment.GetEnvironmentVariable("SGU_CREDENTIAL_PROVIDER_CONFIG") ?? DefaultPath;
FileInfo file = new(path);
if (!file.Exists || file.Length is <= 0 or > MaximumSettingsBytes)
{
throw new InvalidOperationException("Credential Provider settings are missing or invalid.");
}
using FileStream stream = new(path, FileMode.Open, FileAccess.Read, FileShare.Read);
ProviderSettings settings = JsonSerializer.Deserialize<ProviderSettings>(stream, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
}) ?? throw new InvalidOperationException("Credential Provider settings could not be read.");
settings.Validate();
return settings;
}
public void Validate()
{
if (!BrokerEndpoint.IsAbsoluteUri ||
BrokerEndpoint.Scheme != Uri.UriSchemeHttps ||
!string.IsNullOrEmpty(BrokerEndpoint.UserInfo))
{
throw new InvalidOperationException("BrokerEndpoint must be an absolute HTTPS URL without user information.");
}
if (!string.Equals(BrokerEndpoint.AbsolutePath.TrimEnd('/'), "/v1/authenticate", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("BrokerEndpoint must target /v1/authenticate.");
}
if (string.IsNullOrWhiteSpace(DomainNetbios) || TimeoutSeconds is < 2 or > 30)
{
throw new InvalidOperationException("DomainNetbios or TimeoutSeconds is invalid.");
}
if (NormalizeThumbprint(ClientCertificateThumbprint).Length != 40 ||
NormalizeThumbprint(ServerCertificateThumbprint).Length != 40)
{
throw new InvalidOperationException("Client and server SHA-1 certificate thumbprints are required.");
}
}
public static string NormalizeThumbprint(string value) =>
value.Replace(" ", string.Empty, StringComparison.Ordinal).ToUpperInvariant();
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<Platform>x64</Platform>
<Platforms>x64</Platforms>
<OutputType>Library</OutputType>
<AssemblyName>SGU.CredentialProvider</AssemblyName>
<RootNamespace>SGU.CredentialProvider</RootNamespace>
<RegisterForComInterop>false</RegisterForComInterop>
<EnableComHosting>true</EnableComHosting>
<EnableDynamicLoading>true</EnableDynamicLoading>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
<SelfContained>false</SelfContained>
<PublishSingleFile>false</PublishSingleFile>
<PublishTrimmed>false</PublishTrimmed>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Lithnet.CredentialProvider\Lithnet.CredentialProvider.csproj">
<AdditionalProperties>Configuration=$(Configuration)</AdditionalProperties>
</ProjectReference>
<ProjectReference Include="..\SGU.AuthBroker.Core\SGU.AuthBroker.Core.csproj" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>SGU.CredentialProvider.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>
@@ -0,0 +1,38 @@
using System.Collections.Generic;
using System.Runtime.InteropServices;
using Lithnet.CredentialProvider;
namespace SGU.CredentialProvider;
[ComVisible(true)]
[ClassInterface(ClassInterfaceType.None)]
[ProgId("SGU.CredentialProvider")]
[Guid(ProviderClassId)]
public sealed class SguCredentialProvider : CredentialProviderBase
{
public const string ProviderClassId = "D789CFD8-5AD4-489F-9B83-7EB5D9D09335";
public override IEnumerable<ControlBase> GetControls(UsageScenario cpus)
{
yield return new CredentialProviderLabelControl(ControlKeys.ProviderLabel, "Acceso institucional SGU");
yield return new SmallLabelControl(
ControlKeys.InformationLabel,
"Usa tu clave institucional (DO, AL o AD + 6 dígitos) y contraseña.");
yield return new TextboxControl(ControlKeys.UserName, "Clave institucional");
SecurePasswordTextboxControl password = new(ControlKeys.Password, "Contraseña");
yield return password;
yield return new SubmitButtonControl(ControlKeys.Submit, "Iniciar sesión", password);
}
public override bool IsUsageScenarioSupported(UsageScenario cpus, CredUIWinFlags dwFlags) =>
cpus is UsageScenario.Logon or UsageScenario.UnlockWorkstation or UsageScenario.CredUI;
public override bool ShouldIncludeUserTile(CredentialProviderUser user) => false;
public override bool ShouldIncludeGenericTile() => true;
public override CredentialTile CreateGenericTile() => new SguCredentialTile(this);
public override CredentialTile2 CreateUserTile(CredentialProviderUser user) =>
new SguCredentialTile(this, user);
}
@@ -0,0 +1,132 @@
using System.Runtime.InteropServices;
using System.Security;
using Lithnet.CredentialProvider;
using SGU.AuthBroker.Core.Identity;
namespace SGU.CredentialProvider;
internal sealed class SguCredentialTile : CredentialTile2
{
private TextboxControl userNameControl = null!;
private SecurePasswordTextboxControl passwordControl = null!;
public SguCredentialTile(CredentialProviderBase credentialProvider)
: base(credentialProvider)
{
}
public SguCredentialTile(CredentialProviderBase credentialProvider, CredentialProviderUser user)
: base(credentialProvider, user)
{
}
public override void Initialize()
{
userNameControl = Controls.GetControl<TextboxControl>(ControlKeys.UserName);
passwordControl = Controls.GetControl<SecurePasswordTextboxControl>(ControlKeys.Password);
userNameControl.Text = User?.QualifiedUserName ?? string.Empty;
}
protected override CredentialResponseBase GetCredentials()
{
if (!UserIdentityClassifier.TryParse(userNameControl.Text, out UserIdentity? identity) || identity is null)
{
return Failure("La clave debe usar DO, AL o AD seguido de seis dígitos.");
}
SecureString securePassword = passwordControl.Password;
if (securePassword.Length == 0)
{
return Failure("La contraseña es requerida.");
}
string plainTextPassword = CopyToManagedString(securePassword);
try
{
ProviderSettings settings;
BrokerDecision decision;
try
{
settings = ProviderSettings.Load();
using BrokerClient broker = new(settings);
decision = broker
.AuthenticateAsync(identity.UserName, plainTextPassword, CancellationToken.None)
.GetAwaiter()
.GetResult();
}
catch
{
settings = TryLoadDomainOnlySettings();
decision = BrokerDecision.Unavailable("LOCAL_CONFIGURATION_OR_TLS_ERROR");
}
if (decision.Kind == BrokerDecisionKind.InvalidCredentials)
{
return Failure("Credenciales institucionales inválidas.");
}
string domain = decision.Kind == BrokerDecisionKind.Authorized
? decision.Domain!
: settings.DomainNetbios;
string userName = decision.Kind == BrokerDecisionKind.Authorized
? decision.UserName!
: identity.UserName;
return new CredentialResponseSecure
{
IsSuccess = true,
StatusIcon = decision.Kind == BrokerDecisionKind.Unavailable ? StatusIcon.Warning : StatusIcon.None,
StatusText = decision.Kind == BrokerDecisionKind.Unavailable
? "Servicio institucional no disponible; Windows validará la última contraseña de dominio registrada."
: null,
Domain = domain,
Username = userName,
Password = securePassword
};
}
finally
{
// The immutable managed string cannot be zeroed; release our reference immediately.
// The unmanaged copy used to create it is zeroed by CopyToManagedString.
plainTextPassword = string.Empty;
}
}
private static ProviderSettings TryLoadDomainOnlySettings()
{
try
{
return ProviderSettings.Load();
}
catch
{
// LCI is the configured lab domain. This fallback still delegates the actual
// password decision to Windows LSA/cached domain credentials.
return new ProviderSettings { DomainNetbios = "LCI" };
}
}
private static CredentialResponseSecure Failure(string message) => new()
{
IsSuccess = false,
StatusIcon = StatusIcon.Error,
StatusText = message
};
private static string CopyToManagedString(SecureString value)
{
IntPtr pointer = IntPtr.Zero;
try
{
pointer = Marshal.SecureStringToGlobalAllocUnicode(value);
return Marshal.PtrToStringUni(pointer, value.Length) ?? string.Empty;
}
finally
{
if (pointer != IntPtr.Zero)
{
Marshal.ZeroFreeGlobalAllocUnicode(pointer);
}
}
}
}
@@ -0,0 +1,7 @@
{
"BrokerEndpoint": "https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate",
"DomainNetbios": "LCI",
"TimeoutSeconds": 6,
"ClientCertificateThumbprint": "0000000000000000000000000000000000000000",
"ServerCertificateThumbprint": "0000000000000000000000000000000000000000"
}