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
+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"
}