Files
SGU-CredentialProvider/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs
T

408 lines
15 KiB
C#

using System.Collections.Concurrent;
using System.DirectoryServices;
using SGU.AuthBroker.Core.Directory;
using SGU.AuthBroker.Core.Identity;
using SGU.AuthBroker.Core.Profiles;
using SGU.AuthBroker.Options;
namespace SGU.AuthBroker.Services;
public sealed class ActiveDirectorySynchronizer(
BrokerOptions options,
ILogger<ActiveDirectorySynchronizer> logger) : IActiveDirectorySynchronizer
{
private const string GenderMetadataPrefix = "SGU-Gender:";
private const int InfoAttributeMaximumLength = 1024;
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,
InstitutionalProfile? profile,
string password,
CancellationToken cancellationToken)
{
SemaphoreSlim gate = userLocks.GetOrAdd(identity.UserName, static _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
try
{
return await Task.Run(
() => Synchronize(identity, profile, password),
cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogError(
BrokerEventIds.DirectorySynchronizationFailure,
exception,
"Active Directory synchronization failed for {InstitutionalUser} with role {Role}.",
identity.UserName,
identity.Role);
throw;
}
}
finally
{
gate.Release();
if (gate.CurrentCount == 1)
{
userLocks.TryRemove(new KeyValuePair<string, SemaphoreSlim>(identity.UserName, gate));
}
}
}
private DirectorySyncResult Synchronize(
UserIdentity identity,
InstitutionalProfile? profile,
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();
}
// Role membership is part of account provisioning, not optional
// enrichment. Do it before changing the password so a missing or
// inaccessible authorization group cannot leave a newly usable
// account without its required classification.
EnsureRoleGroupMembership(user, identity);
// 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();
TryApplyProfile(user, identity, profile, options.DefaultCompany, logger);
TryEnsureRemoteDesktopGroupMembership(user, identity.UserName);
return new DirectorySyncResult(
options.DomainNetbios,
identity.UserName,
$"{identity.UserName}@{options.UpnSuffix}",
created,
moved);
}
finally
{
user?.Dispose();
}
}
private static void TryApplyProfile(
DirectoryEntry user,
UserIdentity identity,
InstitutionalProfile? profile,
string defaultCompany,
ILogger logger)
{
try
{
SetOptionalProperty(user, "company", defaultCompany);
if (profile is not null)
{
SetOptionalProperty(user, "displayName", profile.DisplayName);
SetOptionalProperty(user, "givenName", profile.GivenName);
SetOptionalProperty(user, "sn", profile.Surname);
SetOptionalProperty(user, "mail", profile.Email);
SetOptionalProperty(user, "title", profile.JobTitle);
SetOptionalProperty(user, "department", profile.Department);
SetOptionalProperty(user, "employeeType", profile.EmployeeType);
SetOptionalProperty(user, "streetAddress", profile.StreetAddress);
SetOptionalProperty(user, "l", profile.City);
SetOptionalProperty(user, "st", profile.State);
SetOptionalProperty(user, "postalCode", profile.PostalCode);
SetGenderMetadata(user, profile.Gender, identity.UserName, logger);
if (string.Equals(profile.EmployeeNumber, identity.NumericId, StringComparison.Ordinal))
{
SetOptionalProperty(user, "employeeID", profile.EmployeeNumber);
}
}
user.CommitChanges();
}
catch (Exception exception)
{
// Metadata is intentionally best-effort. User creation, password sync,
// and account enablement have already committed successfully.
logger.LogWarning(
BrokerEventIds.DirectoryOptionalMetadataFailure,
exception,
"Optional Active Directory profile metadata could not be committed for {InstitutionalUser}; password synchronization remains completed.",
identity.UserName);
try
{
user.RefreshCache();
}
catch
{
// Discarding the optional property cache must not alter the
// already committed password synchronization result.
}
}
}
private static void SetOptionalProperty(DirectoryEntry entry, string propertyName, string? value)
{
if (!string.IsNullOrWhiteSpace(value))
{
entry.Properties[propertyName].Value = value;
}
}
private static void SetGenderMetadata(
DirectoryEntry entry,
InstitutionalGender? gender,
string institutionalUser,
ILogger logger)
{
if (gender is null)
{
return;
}
string existing = Convert.ToString(entry.Properties["info"].Value) ?? string.Empty;
string? updated = MergeGenderMetadata(existing, gender);
if (updated is null)
{
logger.LogWarning(
BrokerEventIds.DirectoryOptionalMetadataFailure,
"Gender metadata was not written for {InstitutionalUser} because the Active Directory info attribute has no remaining capacity.",
institutionalUser);
return;
}
entry.Properties["info"].Value = updated;
}
internal static string? MergeGenderMetadata(
string? existing,
InstitutionalGender? gender)
{
if (gender is null)
{
return null;
}
string managedLine = $"{GenderMetadataPrefix} {gender}";
string normalizedExisting = (existing ?? string.Empty)
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n');
string[] preservedLines = string.IsNullOrEmpty(normalizedExisting)
? []
: normalizedExisting
.Split('\n')
.Where(line => !line.TrimStart().StartsWith(
GenderMetadataPrefix,
StringComparison.OrdinalIgnoreCase))
.ToArray();
string updated = string.Join("\r\n", preservedLines.Append(managedLine));
return updated.Length <= InfoAttributeMaximumLength ? updated : null;
}
private void EnsureRoleGroupMembership(DirectoryEntry user, UserIdentity identity)
{
user.RefreshCache(["distinguishedName"]);
string? userDn = Convert.ToString(user.Properties["distinguishedName"].Value);
if (string.IsNullOrWhiteSpace(userDn))
{
throw new InvalidOperationException($"Active Directory did not return a distinguished name for {identity.UserName}.");
}
string groupDn = options.GetGroupDn(identity.Role);
using DirectoryEntry group = Bind(groupDn);
_ = group.NativeObject;
if (group.Properties["member"].Contains(userDn))
{
return;
}
group.Properties["member"].Add(userDn);
group.CommitChanges();
logger.LogInformation(
BrokerEventIds.DirectoryRoleGroupMembershipAdded,
"Added {InstitutionalUser} with role {Role} to Active Directory security group {GroupDn}.",
identity.UserName,
identity.Role,
groupDn);
}
private void TryEnsureRemoteDesktopGroupMembership(DirectoryEntry user, string institutionalUser)
{
if (string.IsNullOrWhiteSpace(options.RemoteDesktopGroupDn))
{
return;
}
try
{
user.RefreshCache(["distinguishedName"]);
string? userDn = Convert.ToString(user.Properties["distinguishedName"].Value);
if (string.IsNullOrWhiteSpace(userDn))
{
return;
}
using DirectoryEntry group = Bind(options.RemoteDesktopGroupDn);
_ = group.NativeObject;
if (!group.Properties["member"].Contains(userDn))
{
group.Properties["member"].Add(userDn);
group.CommitChanges();
}
}
catch (Exception exception)
{
// Remote access is lab policy and must not invalidate a completed
// password synchronization if the optional group is unavailable.
logger.LogWarning(
BrokerEventIds.DirectoryGroupMembershipFailure,
exception,
"Optional remote-desktop group membership could not be updated for {InstitutionalUser}; password synchronization remains completed.",
institutionalUser);
}
}
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);
}