Add SGU credential provider and authentication broker
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
using SGU.AuthBroker.Core.Authentication;
|
||||
using SGU.AuthBroker.Core.Directory;
|
||||
using SGU.AuthBroker.Core.Identity;
|
||||
using Xunit;
|
||||
|
||||
namespace SGU.AuthBroker.Core.Tests;
|
||||
|
||||
public sealed class AuthenticationWorkflowTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PassesTheExactOriginalPasswordToNtlmAndActiveDirectory()
|
||||
{
|
||||
const string original = "Árbol-Exacto-🔐-NoDerivar-27!";
|
||||
CapturingNtlmValidator ntlm = new(NtlmValidationResult.Valid());
|
||||
CapturingDirectorySynchronizer directory = new();
|
||||
AuthenticationWorkflow workflow = new(ntlm, directory);
|
||||
|
||||
AuthenticationFlowResult result = await workflow.AuthenticateAsync(
|
||||
"do123456",
|
||||
original,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(AuthenticationFlowOutcome.Authorized, result.Outcome);
|
||||
Assert.Same(original, ntlm.Password);
|
||||
Assert.Same(original, directory.Password);
|
||||
Assert.Equal("DO123456", ntlm.UserName);
|
||||
Assert.Equal(InstitutionalRole.Professor, directory.Identity?.Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidNtlmCredentialsNeverReachActiveDirectory()
|
||||
{
|
||||
CapturingDirectorySynchronizer directory = new();
|
||||
AuthenticationWorkflow workflow = new(
|
||||
new CapturingNtlmValidator(NtlmValidationResult.Invalid()),
|
||||
directory);
|
||||
|
||||
AuthenticationFlowResult result = await workflow.AuthenticateAsync(
|
||||
"AL123456",
|
||||
"Wrong",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(AuthenticationFlowOutcome.InvalidCredentials, result.Outcome);
|
||||
Assert.Null(directory.Password);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NtlmOutageIsReportedAsUnavailableForProviderFallback()
|
||||
{
|
||||
CapturingDirectorySynchronizer directory = new();
|
||||
AuthenticationWorkflow workflow = new(
|
||||
new CapturingNtlmValidator(NtlmValidationResult.Unavailable()),
|
||||
directory);
|
||||
|
||||
AuthenticationFlowResult result = await workflow.AuthenticateAsync(
|
||||
"AD123456",
|
||||
"LastKnownPassword",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(AuthenticationFlowOutcome.Unavailable, result.Outcome);
|
||||
Assert.Null(directory.Password);
|
||||
}
|
||||
|
||||
private sealed class CapturingNtlmValidator(NtlmValidationResult result) : INtlmCredentialValidator
|
||||
{
|
||||
public string? UserName { get; private set; }
|
||||
|
||||
public string? Password { get; private set; }
|
||||
|
||||
public Task<NtlmValidationResult> ValidateAsync(string userName, string password, CancellationToken cancellationToken)
|
||||
{
|
||||
UserName = userName;
|
||||
Password = password;
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingDirectorySynchronizer : IActiveDirectorySynchronizer
|
||||
{
|
||||
public UserIdentity? Identity { get; private set; }
|
||||
|
||||
public string? Password { get; private set; }
|
||||
|
||||
public Task<DirectorySyncResult> SynchronizeAsync(
|
||||
UserIdentity identity,
|
||||
string password,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Identity = identity;
|
||||
Password = password;
|
||||
return Task.FromResult(new DirectorySyncResult(
|
||||
"LCI",
|
||||
identity.UserName,
|
||||
$"{identity.UserName}@lci.lasalle.mx",
|
||||
true,
|
||||
false));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\SGU.AuthBroker.Core\SGU.AuthBroker.Core.csproj" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
|
||||
<PackageReference Include="xunit.v3" Version="4.0.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,31 @@
|
||||
using SGU.AuthBroker.Core.Identity;
|
||||
using Xunit;
|
||||
|
||||
namespace SGU.AuthBroker.Core.Tests;
|
||||
|
||||
public sealed class UserIdentityClassifierTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("DO123456", "DO123456", InstitutionalRole.Professor)]
|
||||
[InlineData("al000001", "AL000001", InstitutionalRole.Student)]
|
||||
[InlineData("LCI\\AD654321", "AD654321", InstitutionalRole.Administrative)]
|
||||
[InlineData("do123456@lci.lasalle.mx", "DO123456", InstitutionalRole.Professor)]
|
||||
public void MapsPrefixesToExpectedRoles(string input, string expectedUserName, InstitutionalRole expectedRole)
|
||||
{
|
||||
Assert.True(UserIdentityClassifier.TryParse(input, out UserIdentity? identity));
|
||||
Assert.NotNull(identity);
|
||||
Assert.Equal(expectedUserName, identity.UserName);
|
||||
Assert.Equal(expectedRole, identity.Role);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("XX123456")]
|
||||
[InlineData("DO12345")]
|
||||
[InlineData("AL1234567")]
|
||||
[InlineData("AD12A456")]
|
||||
public void RejectsUnknownOrMalformedUserNames(string input)
|
||||
{
|
||||
Assert.False(UserIdentityClassifier.TryParse(input, out _));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SGU.CredentialProvider.SmokeProbe;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private static readonly Guid ProviderClassId = new("D789CFD8-5AD4-489F-9B83-7EB5D9D09335");
|
||||
|
||||
private static readonly string[] ExpectedLabels =
|
||||
[
|
||||
"Acceso institucional SGU",
|
||||
"Usa tu clave institucional (DO, AL o AD + 6 dígitos) y contraseña.",
|
||||
"Clave institucional",
|
||||
"Contraseña",
|
||||
"Iniciar sesión"
|
||||
];
|
||||
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
string mode = args.Length == 0 ? "enumeration" : args.Single();
|
||||
if (mode is not ("enumeration" or "direct-broker" or "online-rejection" or "offline-fallback"))
|
||||
{
|
||||
Console.Error.WriteLine("Usage: SGU.CredentialProvider.SmokeProbe.exe [enumeration|direct-broker|online-rejection|offline-fallback]");
|
||||
return 64;
|
||||
}
|
||||
|
||||
if (mode == "direct-broker")
|
||||
{
|
||||
return RunDirectBrokerProbe();
|
||||
}
|
||||
|
||||
object? instance = null;
|
||||
IntPtr credential = IntPtr.Zero;
|
||||
NativeEmptyUserArray? users = null;
|
||||
try
|
||||
{
|
||||
Type providerType = Type.GetTypeFromCLSID(ProviderClassId, throwOnError: true)
|
||||
?? throw new InvalidOperationException("The SGU Credential Provider CLSID is not registered.");
|
||||
instance = Activator.CreateInstance(providerType)
|
||||
?? throw new InvalidOperationException("COM activation returned no provider instance.");
|
||||
ICredentialProvider provider = (ICredentialProvider)instance;
|
||||
ICredentialProviderSetUserArray setUserArray = (ICredentialProviderSetUserArray)instance;
|
||||
users = new NativeEmptyUserArray();
|
||||
|
||||
ThrowIfFailed(provider.SetUsageScenario(UsageScenario.Logon, 0), "SetUsageScenario");
|
||||
ThrowIfFailed(setUserArray.SetUserArray(users.Pointer), "SetUserArray");
|
||||
ThrowIfFailed(provider.GetFieldDescriptorCount(out uint fieldCount), "GetFieldDescriptorCount");
|
||||
|
||||
List<string> labels = [];
|
||||
for (uint index = 0; index < fieldCount; index++)
|
||||
{
|
||||
ThrowIfFailed(provider.GetFieldDescriptorAt(index, out IntPtr descriptorPointer), "GetFieldDescriptorAt");
|
||||
if (descriptorPointer == IntPtr.Zero)
|
||||
{
|
||||
throw new InvalidOperationException($"Field descriptor {index} was null.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
FieldDescriptor descriptor = Marshal.PtrToStructure<FieldDescriptor>(descriptorPointer);
|
||||
labels.Add(descriptor.Label ?? string.Empty);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.DestroyStructure<FieldDescriptor>(descriptorPointer);
|
||||
Marshal.FreeCoTaskMem(descriptorPointer);
|
||||
}
|
||||
}
|
||||
|
||||
ThrowIfFailed(provider.GetCredentialCount(out uint credentialCount, out uint defaultIndex, out int autoLogon), "GetCredentialCount");
|
||||
if (credentialCount > 0)
|
||||
{
|
||||
ThrowIfFailed(provider.GetCredentialAt(0, out credential), "GetCredentialAt");
|
||||
}
|
||||
|
||||
bool passed = fieldCount == ExpectedLabels.Length &&
|
||||
credentialCount == 1 &&
|
||||
credential != IntPtr.Zero &&
|
||||
labels.SequenceEqual(ExpectedLabels, StringComparer.Ordinal);
|
||||
|
||||
if (mode != "enumeration" && passed)
|
||||
{
|
||||
return RunSerializationProbe(mode, credential, labels);
|
||||
}
|
||||
|
||||
Console.WriteLine(JsonSerializer.Serialize(new
|
||||
{
|
||||
passed,
|
||||
mode,
|
||||
providerClassId = ProviderClassId,
|
||||
usageScenario = "Logon",
|
||||
fieldCount,
|
||||
labels,
|
||||
credentialCount,
|
||||
defaultIndex,
|
||||
autoLogon = autoLogon != 0
|
||||
}));
|
||||
return passed ? 0 : 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine(ex);
|
||||
return 2;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (credential != IntPtr.Zero)
|
||||
{
|
||||
Marshal.Release(credential);
|
||||
}
|
||||
if (instance is not null && Marshal.IsComObject(instance))
|
||||
{
|
||||
Marshal.FinalReleaseComObject(instance);
|
||||
}
|
||||
users?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static void ThrowIfFailed(int hresult, string operation)
|
||||
{
|
||||
if (hresult < 0)
|
||||
{
|
||||
Marshal.ThrowExceptionForHR(hresult);
|
||||
throw new COMException($"{operation} failed.", hresult);
|
||||
}
|
||||
}
|
||||
|
||||
private static int RunSerializationProbe(string mode, IntPtr credential, IReadOnlyList<string> labels)
|
||||
{
|
||||
string[] labelArray = labels.ToArray();
|
||||
uint userNameFieldId = checked((uint)Array.IndexOf(labelArray, "Clave institucional"));
|
||||
uint passwordFieldId = checked((uint)Array.IndexOf(labelArray, "Contraseña"));
|
||||
string userName = "DO000000";
|
||||
string password = mode == "offline-fallback"
|
||||
? $"Probe-{Guid.NewGuid():N}-áΩ"
|
||||
: $"Probe-{Guid.NewGuid():N}";
|
||||
|
||||
IntPtr vtable = Marshal.ReadIntPtr(credential);
|
||||
SetStringValueDelegate setStringValue = Marshal.GetDelegateForFunctionPointer<SetStringValueDelegate>(
|
||||
Marshal.ReadIntPtr(vtable, 14 * IntPtr.Size));
|
||||
GetSerializationDelegate getSerialization = Marshal.GetDelegateForFunctionPointer<GetSerializationDelegate>(
|
||||
Marshal.ReadIntPtr(vtable, 18 * IntPtr.Size));
|
||||
|
||||
SetCredentialString(setStringValue, credential, userNameFieldId, userName, "SetStringValue(username)");
|
||||
SetCredentialString(setStringValue, credential, passwordFieldId, password, "SetStringValue(password)");
|
||||
|
||||
CredentialSerialization serialization = default;
|
||||
IntPtr statusTextPointer = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
ThrowIfFailed(
|
||||
getSerialization(credential, out int response, out serialization, out statusTextPointer, out int statusIcon),
|
||||
"GetSerialization");
|
||||
string statusText = statusTextPointer == IntPtr.Zero
|
||||
? string.Empty
|
||||
: Marshal.PtrToStringUni(statusTextPointer) ?? string.Empty;
|
||||
|
||||
if (mode == "online-rejection")
|
||||
{
|
||||
bool passed = response == (int)SerializationResponse.NoCredentialNotFinished &&
|
||||
serialization.SerializationData == IntPtr.Zero &&
|
||||
statusIcon == (int)StatusIcon.Error &&
|
||||
statusText == "Credenciales institucionales inválidas.";
|
||||
Console.WriteLine(JsonSerializer.Serialize(new
|
||||
{
|
||||
passed,
|
||||
mode,
|
||||
response = (SerializationResponse)response,
|
||||
statusIcon = (StatusIcon)statusIcon,
|
||||
statusText,
|
||||
credentialReturned = serialization.SerializationData != IntPtr.Zero
|
||||
}));
|
||||
return passed ? 0 : 1;
|
||||
}
|
||||
|
||||
KerberosInteractiveUnlockLogon logon = serialization.SerializationData == IntPtr.Zero
|
||||
? default
|
||||
: Marshal.PtrToStructure<KerberosInteractiveUnlockLogon>(serialization.SerializationData);
|
||||
string packedDomain = ReadPackedString(serialization.SerializationData, logon.LogonDomainName);
|
||||
string packedUserName = ReadPackedString(serialization.SerializationData, logon.Username);
|
||||
string packedPassword = ReadPackedString(serialization.SerializationData, logon.Password);
|
||||
bool passwordPreserved = string.Equals(packedPassword, password, StringComparison.Ordinal);
|
||||
packedPassword = string.Empty;
|
||||
|
||||
bool fallbackPassed = response == (int)SerializationResponse.ReturnCredentialFinished &&
|
||||
serialization.SerializationData != IntPtr.Zero &&
|
||||
serialization.SerializationSize > 0 &&
|
||||
statusIcon == (int)StatusIcon.Warning &&
|
||||
statusText == "Servicio institucional no disponible; Windows validará la última contraseña de dominio registrada." &&
|
||||
string.Equals(packedDomain, "LCI", StringComparison.Ordinal) &&
|
||||
string.Equals(packedUserName, userName, StringComparison.Ordinal) &&
|
||||
passwordPreserved;
|
||||
Console.WriteLine(JsonSerializer.Serialize(new
|
||||
{
|
||||
passed = fallbackPassed,
|
||||
mode,
|
||||
response = (SerializationResponse)response,
|
||||
statusIcon = (StatusIcon)statusIcon,
|
||||
statusText,
|
||||
packedDomain,
|
||||
packedUserName,
|
||||
passwordPreserved,
|
||||
serializationSize = serialization.SerializationSize
|
||||
}));
|
||||
return fallbackPassed ? 0 : 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
password = string.Empty;
|
||||
if (statusTextPointer != IntPtr.Zero)
|
||||
{
|
||||
Marshal.FreeCoTaskMem(statusTextPointer);
|
||||
}
|
||||
if (serialization.SerializationData != IntPtr.Zero)
|
||||
{
|
||||
byte[] zeroes = new byte[serialization.SerializationSize];
|
||||
Marshal.Copy(zeroes, 0, serialization.SerializationData, zeroes.Length);
|
||||
Marshal.FreeCoTaskMem(serialization.SerializationData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int RunDirectBrokerProbe()
|
||||
{
|
||||
string settingsPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"SGU",
|
||||
"CredentialProvider",
|
||||
"settings.json");
|
||||
ProbeSettings settings = JsonSerializer.Deserialize<ProbeSettings>(
|
||||
File.ReadAllText(settingsPath),
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
||||
?? throw new InvalidOperationException("Provider settings could not be read.");
|
||||
|
||||
using X509Store store = new(StoreName.My, StoreLocation.LocalMachine);
|
||||
store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly);
|
||||
using X509Certificate2 certificate = store.Certificates
|
||||
.Find(X509FindType.FindByThumbprint, settings.ClientCertificateThumbprint, validOnly: true)
|
||||
.OfType<X509Certificate2>()
|
||||
.First(item => item.HasPrivateKey);
|
||||
|
||||
string expectedThumbprint = NormalizeThumbprint(settings.ServerCertificateThumbprint);
|
||||
using HttpClientHandler handler = new()
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
CheckCertificateRevocationList = true,
|
||||
ClientCertificateOptions = ClientCertificateOption.Manual,
|
||||
MaxConnectionsPerServer = 2,
|
||||
MaxResponseHeadersLength = 32,
|
||||
UseCookies = false,
|
||||
UseDefaultCredentials = false,
|
||||
UseProxy = false,
|
||||
ServerCertificateCustomValidationCallback = (_, serverCertificate, _, policyErrors) =>
|
||||
policyErrors == SslPolicyErrors.None &&
|
||||
serverCertificate is not null &&
|
||||
string.Equals(
|
||||
NormalizeThumbprint(serverCertificate.GetCertHashString()),
|
||||
expectedThumbprint,
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
};
|
||||
handler.ClientCertificates.Add(certificate);
|
||||
using HttpClient client = new(handler)
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(settings.TimeoutSeconds),
|
||||
DefaultRequestVersion = HttpVersion.Version11,
|
||||
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact
|
||||
};
|
||||
|
||||
string password = $"Probe-{Guid.NewGuid():N}";
|
||||
try
|
||||
{
|
||||
string json = JsonSerializer.Serialize(new { clave = "DO000000", password });
|
||||
using StringContent content = new(json, Encoding.UTF8, "application/json");
|
||||
using HttpResponseMessage response = client.PostAsync(settings.BrokerEndpoint, content).GetAwaiter().GetResult();
|
||||
string responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
string? responseCode = null;
|
||||
try
|
||||
{
|
||||
responseCode = JsonDocument.Parse(responseBody).RootElement.GetProperty("code").GetString();
|
||||
}
|
||||
catch (Exception exception) when (exception is JsonException or InvalidOperationException or KeyNotFoundException)
|
||||
{
|
||||
}
|
||||
|
||||
bool passed = response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Unauthorized;
|
||||
Console.WriteLine(JsonSerializer.Serialize(new
|
||||
{
|
||||
passed,
|
||||
mode = "direct-broker",
|
||||
statusCode = (int)response.StatusCode,
|
||||
responseCode
|
||||
}));
|
||||
return passed ? 0 : 1;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(new
|
||||
{
|
||||
passed = false,
|
||||
mode = "direct-broker",
|
||||
exception = exception.GetType().FullName,
|
||||
innerException = exception.InnerException?.GetType().FullName,
|
||||
hresult = exception.HResult
|
||||
}));
|
||||
return 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
password = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeThumbprint(string value) =>
|
||||
value.Replace(" ", string.Empty, StringComparison.Ordinal).ToUpperInvariant();
|
||||
|
||||
private static void SetCredentialString(
|
||||
SetStringValueDelegate setStringValue,
|
||||
IntPtr credential,
|
||||
uint fieldId,
|
||||
string value,
|
||||
string operation)
|
||||
{
|
||||
IntPtr valuePointer = Marshal.StringToCoTaskMemUni(value);
|
||||
try
|
||||
{
|
||||
ThrowIfFailed(setStringValue(credential, fieldId, valuePointer), operation);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.ZeroFreeCoTaskMemUnicode(valuePointer);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadPackedString(IntPtr buffer, PackedUnicodeString value)
|
||||
{
|
||||
if (buffer == IntPtr.Zero || value.Length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return Marshal.PtrToStringUni(
|
||||
IntPtr.Add(buffer, checked((int)value.Buffer.ToInt64())),
|
||||
value.Length / sizeof(char)) ?? string.Empty;
|
||||
}
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
|
||||
private delegate int SetStringValueDelegate(IntPtr instance, uint fieldId, IntPtr value);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
|
||||
private delegate int GetSerializationDelegate(
|
||||
IntPtr instance,
|
||||
out int response,
|
||||
out CredentialSerialization serialization,
|
||||
out IntPtr statusText,
|
||||
out int statusIcon);
|
||||
}
|
||||
|
||||
internal sealed class ProbeSettings
|
||||
{
|
||||
public Uri BrokerEndpoint { get; init; } = null!;
|
||||
public int TimeoutSeconds { get; init; }
|
||||
public string ClientCertificateThumbprint { get; init; } = string.Empty;
|
||||
public string ServerCertificateThumbprint { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal enum SerializationResponse
|
||||
{
|
||||
NoCredentialNotFinished = 0,
|
||||
NoCredentialFinished = 1,
|
||||
ReturnCredentialFinished = 2,
|
||||
ReturnNoCredentialFinished = 3
|
||||
}
|
||||
|
||||
internal enum StatusIcon
|
||||
{
|
||||
None = 0,
|
||||
Error = 1,
|
||||
Warning = 2,
|
||||
Success = 3
|
||||
}
|
||||
|
||||
internal enum UsageScenario
|
||||
{
|
||||
Invalid = 0,
|
||||
Logon = 1
|
||||
}
|
||||
|
||||
internal enum FieldType
|
||||
{
|
||||
Invalid = 0,
|
||||
LargeText,
|
||||
SmallText,
|
||||
CommandLink,
|
||||
EditText,
|
||||
PasswordText,
|
||||
TileImage,
|
||||
CheckBox,
|
||||
ComboBox,
|
||||
SubmitButton
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 4, CharSet = CharSet.Unicode)]
|
||||
internal struct FieldDescriptor
|
||||
{
|
||||
public uint FieldId;
|
||||
public FieldType FieldType;
|
||||
|
||||
[MarshalAs(UnmanagedType.LPWStr)]
|
||||
public string? Label;
|
||||
|
||||
public Guid FieldTypeGuid;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 4)]
|
||||
internal struct CredentialSerialization
|
||||
{
|
||||
public uint AuthenticationPackage;
|
||||
public Guid ProviderClassGuid;
|
||||
public uint SerializationSize;
|
||||
public IntPtr SerializationData;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct PackedUnicodeString
|
||||
{
|
||||
public ushort Length;
|
||||
public ushort MaxLength;
|
||||
public IntPtr Buffer;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct KerberosInteractiveUnlockLogon
|
||||
{
|
||||
public int SubmitType;
|
||||
public PackedUnicodeString LogonDomainName;
|
||||
public PackedUnicodeString Username;
|
||||
public PackedUnicodeString Password;
|
||||
public long LoginId;
|
||||
}
|
||||
|
||||
[ComImport]
|
||||
[Guid("D27C3481-5A1C-45B2-8AAA-C20EBBE8229E")]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface ICredentialProvider
|
||||
{
|
||||
[PreserveSig]
|
||||
int SetUsageScenario(UsageScenario usageScenario, uint flags);
|
||||
|
||||
[PreserveSig]
|
||||
int SetSerialization(IntPtr serialization);
|
||||
|
||||
[PreserveSig]
|
||||
int Advise(IntPtr events, IntPtr adviseContext);
|
||||
|
||||
[PreserveSig]
|
||||
int UnAdvise();
|
||||
|
||||
[PreserveSig]
|
||||
int GetFieldDescriptorCount(out uint count);
|
||||
|
||||
[PreserveSig]
|
||||
int GetFieldDescriptorAt(uint index, out IntPtr descriptor);
|
||||
|
||||
[PreserveSig]
|
||||
int GetCredentialCount(out uint count, out uint defaultIndex, out int autoLogonWithDefault);
|
||||
|
||||
[PreserveSig]
|
||||
int GetCredentialAt(
|
||||
uint index,
|
||||
out IntPtr credential);
|
||||
}
|
||||
|
||||
[ComImport]
|
||||
[Guid("095C1484-1C0C-4388-9C6D-500E61BF84BD")]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface ICredentialProviderSetUserArray
|
||||
{
|
||||
[PreserveSig]
|
||||
int SetUserArray(IntPtr users);
|
||||
}
|
||||
|
||||
internal sealed class NativeEmptyUserArray : IDisposable
|
||||
{
|
||||
private static readonly Guid UserArrayInterfaceId = new("90C119AE-0F18-4520-A1F1-114366A40FE8");
|
||||
private static readonly Guid UnknownInterfaceId = new("00000000-0000-0000-C000-000000000046");
|
||||
|
||||
private readonly QueryInterfaceDelegate queryInterface;
|
||||
private readonly AddRefDelegate addRef;
|
||||
private readonly ReleaseDelegate release;
|
||||
private readonly SetProviderFilterDelegate setProviderFilter;
|
||||
private readonly GetAccountOptionsDelegate getAccountOptions;
|
||||
private readonly GetCountDelegate getCount;
|
||||
private readonly GetAtDelegate getAt;
|
||||
private IntPtr instance;
|
||||
private IntPtr vtable;
|
||||
private int referenceCount = 1;
|
||||
|
||||
public NativeEmptyUserArray()
|
||||
{
|
||||
queryInterface = QueryInterface;
|
||||
addRef = AddRef;
|
||||
release = Release;
|
||||
setProviderFilter = SetProviderFilter;
|
||||
getAccountOptions = GetAccountOptions;
|
||||
getCount = GetCount;
|
||||
getAt = GetAt;
|
||||
|
||||
vtable = Marshal.AllocHGlobal(IntPtr.Size * 7);
|
||||
Marshal.WriteIntPtr(vtable, IntPtr.Size * 0, Marshal.GetFunctionPointerForDelegate(queryInterface));
|
||||
Marshal.WriteIntPtr(vtable, IntPtr.Size * 1, Marshal.GetFunctionPointerForDelegate(addRef));
|
||||
Marshal.WriteIntPtr(vtable, IntPtr.Size * 2, Marshal.GetFunctionPointerForDelegate(release));
|
||||
Marshal.WriteIntPtr(vtable, IntPtr.Size * 3, Marshal.GetFunctionPointerForDelegate(setProviderFilter));
|
||||
Marshal.WriteIntPtr(vtable, IntPtr.Size * 4, Marshal.GetFunctionPointerForDelegate(getAccountOptions));
|
||||
Marshal.WriteIntPtr(vtable, IntPtr.Size * 5, Marshal.GetFunctionPointerForDelegate(getCount));
|
||||
Marshal.WriteIntPtr(vtable, IntPtr.Size * 6, Marshal.GetFunctionPointerForDelegate(getAt));
|
||||
|
||||
instance = Marshal.AllocHGlobal(IntPtr.Size);
|
||||
Marshal.WriteIntPtr(instance, vtable);
|
||||
}
|
||||
|
||||
public IntPtr Pointer => instance != IntPtr.Zero
|
||||
? instance
|
||||
: throw new ObjectDisposedException(nameof(NativeEmptyUserArray));
|
||||
|
||||
private int QueryInterface(IntPtr self, ref Guid interfaceId, out IntPtr result)
|
||||
{
|
||||
if (interfaceId == UnknownInterfaceId || interfaceId == UserArrayInterfaceId)
|
||||
{
|
||||
result = self;
|
||||
AddRef(self);
|
||||
return 0;
|
||||
}
|
||||
|
||||
result = IntPtr.Zero;
|
||||
return unchecked((int)0x80004002);
|
||||
}
|
||||
|
||||
private uint AddRef(IntPtr self) => unchecked((uint)Interlocked.Increment(ref referenceCount));
|
||||
|
||||
private uint Release(IntPtr self) => unchecked((uint)Math.Max(0, Interlocked.Decrement(ref referenceCount)));
|
||||
|
||||
private static int SetProviderFilter(IntPtr self, ref Guid providerToFilterTo) => 0;
|
||||
|
||||
private static int GetAccountOptions(IntPtr self, out uint accountOptions)
|
||||
{
|
||||
accountOptions = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int GetCount(IntPtr self, out uint userCount)
|
||||
{
|
||||
userCount = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int GetAt(IntPtr self, uint userIndex, out IntPtr user)
|
||||
{
|
||||
user = IntPtr.Zero;
|
||||
return unchecked((int)0x80070057);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (instance != IntPtr.Zero)
|
||||
{
|
||||
Marshal.FreeHGlobal(instance);
|
||||
instance = IntPtr.Zero;
|
||||
}
|
||||
|
||||
if (vtable != IntPtr.Zero)
|
||||
{
|
||||
Marshal.FreeHGlobal(vtable);
|
||||
vtable = IntPtr.Zero;
|
||||
}
|
||||
|
||||
GC.KeepAlive(queryInterface);
|
||||
GC.KeepAlive(addRef);
|
||||
GC.KeepAlive(release);
|
||||
GC.KeepAlive(setProviderFilter);
|
||||
GC.KeepAlive(getAccountOptions);
|
||||
GC.KeepAlive(getCount);
|
||||
GC.KeepAlive(getAt);
|
||||
}
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
|
||||
private delegate int QueryInterfaceDelegate(IntPtr self, ref Guid interfaceId, out IntPtr result);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
|
||||
private delegate uint AddRefDelegate(IntPtr self);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
|
||||
private delegate uint ReleaseDelegate(IntPtr self);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
|
||||
private delegate int SetProviderFilterDelegate(IntPtr self, ref Guid providerToFilterTo);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
|
||||
private delegate int GetAccountOptionsDelegate(IntPtr self, out uint accountOptions);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
|
||||
private delegate int GetCountDelegate(IntPtr self, out uint userCount);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
|
||||
private delegate int GetAtDelegate(IntPtr self, uint userIndex, out IntPtr user);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace SGU.CredentialProvider.Tests;
|
||||
|
||||
public sealed class BrokerClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SendsTheOriginalPasswordWithoutDerivation()
|
||||
{
|
||||
const string original = "Exacta-Árbol-🔐-27!";
|
||||
CapturingHandler handler = new(HttpStatusCode.OK, """
|
||||
{"domain":"LCI","username":"DO123456","upn":"DO123456@lci.lasalle.mx","created":true,"moved":false}
|
||||
""");
|
||||
using BrokerClient client = new(CreateSettings(), handler);
|
||||
|
||||
BrokerDecision decision = await client.AuthenticateAsync(
|
||||
"DO123456",
|
||||
original,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(BrokerDecisionKind.Authorized, decision.Kind);
|
||||
using JsonDocument requestJson = JsonDocument.Parse(handler.RequestBody);
|
||||
Assert.Equal(original, requestJson.RootElement.GetProperty("password").GetString());
|
||||
Assert.DoesNotContain("derived", handler.RequestBody, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExplicitUnauthorizedResponseStopsTheLogin()
|
||||
{
|
||||
using BrokerClient client = new(
|
||||
CreateSettings(),
|
||||
new CapturingHandler(HttpStatusCode.Unauthorized, "{\"code\":\"INVALID_INSTITUTIONAL_CREDENTIALS\"}"));
|
||||
|
||||
BrokerDecision decision = await client.AuthenticateAsync(
|
||||
"AL123456",
|
||||
"Wrong",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(BrokerDecisionKind.InvalidCredentials, decision.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BrokerOutageRequestsWindowsCachedCredentialFallback()
|
||||
{
|
||||
using BrokerClient client = new(
|
||||
CreateSettings(),
|
||||
new CapturingHandler(HttpStatusCode.ServiceUnavailable, "{\"code\":\"NTLM_UPSTREAM_ERROR\"}"));
|
||||
|
||||
BrokerDecision decision = await client.AuthenticateAsync(
|
||||
"AD123456",
|
||||
"LastKnown",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(BrokerDecisionKind.Unavailable, decision.Kind);
|
||||
}
|
||||
|
||||
private static ProviderSettings CreateSettings() => new()
|
||||
{
|
||||
BrokerEndpoint = new Uri("https://broker.example.test/v1/authenticate"),
|
||||
DomainNetbios = "LCI",
|
||||
TimeoutSeconds = 5,
|
||||
ClientCertificateThumbprint = new string('A', 40),
|
||||
ServerCertificateThumbprint = new string('B', 40)
|
||||
};
|
||||
|
||||
private sealed class CapturingHandler(HttpStatusCode statusCode, string responseBody) : HttpMessageHandler
|
||||
{
|
||||
public string RequestBody { get; private set; } = string.Empty;
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
RequestBody = request.Content is null
|
||||
? string.Empty
|
||||
: await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
return new HttpResponseMessage(statusCode)
|
||||
{
|
||||
Content = new StringContent(responseBody, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\SGU.CredentialProvider\SGU.CredentialProvider.csproj" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
|
||||
<PackageReference Include="xunit.v3" Version="4.0.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user