Add SGU credential provider and authentication broker
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user