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