408 lines
15 KiB
C#
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);
|
|
}
|