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,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);
}