From 99be87ed9c949f14ce642dfcb6314673f62fbe34 Mon Sep 17 00:00:00 2001 From: Gary Sharp Date: Sun, 4 Dec 2022 13:26:58 +1100 Subject: [PATCH] maintenance: AD integration refactoring --- .../ActiveDirectory/ADDomainController.cs | 11 ++++ .../Interop/ActiveDirectory/ADGroup.cs | 53 +++++++++++++-- .../ActiveDirectory/ADMachineAccount.cs | 51 ++++++++++++++ .../Interop/ActiveDirectory/ADUserAccount.cs | 66 +++++++++++++++++++ .../ActiveDirectory/ActiveDirectory.cs | 5 ++ .../ActiveDirectoryExtensions.cs | 22 +++++++ Disco.Services/Users/Cache.cs | 30 +++++++++ Disco.Services/Users/UserService.cs | 55 +++++++++++----- 8 files changed, 272 insertions(+), 21 deletions(-) diff --git a/Disco.Services/Interop/ActiveDirectory/ADDomainController.cs b/Disco.Services/Interop/ActiveDirectory/ADDomainController.cs index e1c73bfc..deacfa8e 100644 --- a/Disco.Services/Interop/ActiveDirectory/ADDomainController.cs +++ b/Disco.Services/Interop/ActiveDirectory/ADDomainController.cs @@ -270,6 +270,17 @@ namespace Disco.Services.Interop.ActiveDirectory } } } + + public IADObject RetrieveADObjectByDistinguishedName(string distinguishedName, bool quick, string[] additionalProperties = null) + { + using (var entry = RetrieveDirectoryEntry(distinguishedName, additionalProperties)) + { + if (entry == null) + return null; + else + return entry.AsADObject(quick, additionalProperties); + } + } #endregion #region Organisational Units diff --git a/Disco.Services/Interop/ActiveDirectory/ADGroup.cs b/Disco.Services/Interop/ActiveDirectory/ADGroup.cs index fb637a9c..160f7bba 100644 --- a/Disco.Services/Interop/ActiveDirectory/ADGroup.cs +++ b/Disco.Services/Interop/ActiveDirectory/ADGroup.cs @@ -7,7 +7,7 @@ namespace Disco.Services.Interop.ActiveDirectory { public class ADGroup : IADObject { - internal static readonly string[] LoadProperties = { "name", "distinguishedName", "sAMAccountName", "objectSid", "memberOf" }; + internal static readonly string[] LoadProperties = { "name", "distinguishedName", "sAMAccountName", "objectSid", "memberOf", "member" }; internal static string LdapSearchFilterTemplate = "(&(objectCategory=Group)(|(sAMAccountName={0}*)(name={0}*)(cn={0}*)))"; internal const string LdapSamAccountNameFilterTemplate = "(&(objectCategory=Group)(sAMAccountName={0}))"; internal const string LdapSecurityIdentifierFilterTemplate = "(&(objectCategory=Group)(objectSid={0}))"; @@ -23,11 +23,13 @@ namespace Disco.Services.Interop.ActiveDirectory public string Name { get; private set; } public string DisplayName { get { return Name; } } - public List MemberOf { get; private set; } + public List MemberOf { get; } + + public List Members { get; } public Dictionary LoadedProperties { get; private set; } - private ADGroup(ADDomain Domain, string DistinguishedName, SecurityIdentifier SecurityIdentifier, string SamAccountName, string Name, List MemberOf, Dictionary LoadedProperties) + private ADGroup(ADDomain Domain, string DistinguishedName, SecurityIdentifier SecurityIdentifier, string SamAccountName, string Name, List MemberOf, List Members, Dictionary LoadedProperties) { this.Domain = Domain; this.DistinguishedName = DistinguishedName; @@ -35,6 +37,7 @@ namespace Disco.Services.Interop.ActiveDirectory this.SamAccountName = SamAccountName; this.Name = Name; this.MemberOf = MemberOf; + this.Members = Members; this.LoadedProperties = LoadedProperties; } @@ -48,6 +51,7 @@ namespace Disco.Services.Interop.ActiveDirectory var sAMAccountName = SearchResult.Value("sAMAccountName"); var objectSid = new SecurityIdentifier(SearchResult.Value("objectSid"), 0); var memberOf = SearchResult.Values("memberOf").ToList(); + var members = SearchResult.Values("member").ToList(); // Additional Properties Dictionary additionalProperties; @@ -60,7 +64,7 @@ namespace Disco.Services.Interop.ActiveDirectory additionalProperties = new Dictionary(); } - return new ADGroup(SearchResult.Domain, distinguishedName, objectSid, sAMAccountName, name, memberOf, additionalProperties); + return new ADGroup(SearchResult.Domain, distinguishedName, objectSid, sAMAccountName, name, memberOf, members, additionalProperties); } public static ADGroup FromDirectoryEntry(ADDirectoryEntry DirectoryEntry, string[] AdditionalProperties) @@ -75,6 +79,7 @@ namespace Disco.Services.Interop.ActiveDirectory var sAMAccountName = properties.Value("sAMAccountName"); var objectSid = new SecurityIdentifier(properties.Value("objectSid"), 0); var memberOf = properties.Values("memberOf").ToList(); + var members = properties.Values("member").ToList(); Dictionary additionalProperties; if (AdditionalProperties != null) @@ -86,7 +91,7 @@ namespace Disco.Services.Interop.ActiveDirectory additionalProperties = new Dictionary(); } - return new ADGroup(DirectoryEntry.Domain, distinguishedName, objectSid, sAMAccountName, name, memberOf, additionalProperties); + return new ADGroup(DirectoryEntry.Domain, distinguishedName, objectSid, sAMAccountName, name, memberOf, members, additionalProperties); } [Obsolete("Use generic equivalents: GetPropertyValue(string PropertyName)")] @@ -113,6 +118,8 @@ namespace Disco.Services.Interop.ActiveDirectory return new SecurityIdentifier[] { SecurityIdentifier }.OfType(); case "memberof": return MemberOf.OfType(); + case "member": + return Members.OfType(); default: object[] adProperty; if (LoadedProperties.TryGetValue(PropertyName, out adProperty)) @@ -122,6 +129,42 @@ namespace Disco.Services.Interop.ActiveDirectory } } + public IEnumerable GetUserMembersRecursive() + { + var foundGroups = new HashSet(StringComparer.OrdinalIgnoreCase); + return GetUserMembersRecursive(foundGroups); + } + + private IEnumerable GetUserMembersRecursive(HashSet foundGroups) + { + if (!foundGroups.Add(DistinguishedName)) + yield break; + + var memberGroups = new List(); + foreach (var memberDn in Members) + { + if (foundGroups.Contains(memberDn)) + continue; + + var adObject = ActiveDirectory.RetrieveADObjectByDistinguishedName(memberDn, true); + + if (adObject == null) + continue; + else if (adObject is ADGroup group) + memberGroups.Add(group); + else if (adObject is ADUserAccount adUser) + yield return adUser; + } + foreach (var group in memberGroups) + { + if (foundGroups.Contains(group.DistinguishedName)) + continue; + + foreach (var adUser in group.GetUserMembersRecursive(foundGroups)) + yield return adUser; + } + } + public override string ToString() { return Id; diff --git a/Disco.Services/Interop/ActiveDirectory/ADMachineAccount.cs b/Disco.Services/Interop/ActiveDirectory/ADMachineAccount.cs index 70cb7051..d15dfea9 100644 --- a/Disco.Services/Interop/ActiveDirectory/ADMachineAccount.cs +++ b/Disco.Services/Interop/ActiveDirectory/ADMachineAccount.cs @@ -1,6 +1,7 @@ using Disco.Models.Repository; using System; using System.Collections.Generic; +using System.DirectoryServices; using System.Linq; using System.Security.Principal; using System.Text; @@ -108,6 +109,56 @@ namespace Disco.Services.Interop.ActiveDirectory additionalProperties); } + public static ADMachineAccount FromDirectoryEntry(ADDirectoryEntry directoryEntry, string[] additionalProperties) + { + if (directoryEntry == null) + throw new ArgumentNullException(nameof(directoryEntry)); + + var properties = directoryEntry.Entry.Properties; + + var name = properties.Value("name"); + var description = properties.Value("description"); + var sAMAccountName = properties.Value("sAMAccountName"); + var distinguishedName = properties.Value("distinguishedName"); + var objectSid = new SecurityIdentifier(properties.Value("objectSid"), 0); + + var dNSName = properties.Value("dNSHostName"); + if (dNSName == null) + dNSName = string.Format("{0}.{1}", sAMAccountName.TrimEnd('$'), directoryEntry.Domain.Name); + + var userAccountControl = (ADUserAccountControlFlags)properties.Value("userAccountControl"); + var isCriticalSystemObject = properties.Value("isCriticalSystemObject"); + + var netbootGUID = default(Guid); + var netbootGuidBytes = properties.Value("netbootGUID"); + if (netbootGuidBytes != null) + netbootGUID = new Guid(netbootGuidBytes); + + // Additional Properties + Dictionary additionalProps; + if (additionalProperties != null) + additionalProps = additionalProperties + .Select(p => Tuple.Create(p, properties.Values(p).ToArray())) + .ToDictionary(t => t.Item1, t => t.Item2); + else + { + additionalProps = new Dictionary(); + } + + return new ADMachineAccount( + directoryEntry.Domain, + distinguishedName, + objectSid, + sAMAccountName, + name, + description, + dNSName, + netbootGUID, + userAccountControl, + isCriticalSystemObject, + additionalProps); + } + public User ToRepositoryUser() { return new User diff --git a/Disco.Services/Interop/ActiveDirectory/ADUserAccount.cs b/Disco.Services/Interop/ActiveDirectory/ADUserAccount.cs index bad34e75..a3261e69 100644 --- a/Disco.Services/Interop/ActiveDirectory/ADUserAccount.cs +++ b/Disco.Services/Interop/ActiveDirectory/ADUserAccount.cs @@ -126,6 +126,72 @@ namespace Disco.Services.Interop.ActiveDirectory additionalProperties); } + public static ADUserAccount FromDirectoryEntry(ADDirectoryEntry directoryEntry, bool quick, string[] additionalProperties) + { + if (directoryEntry == null) + throw new ArgumentNullException(nameof(directoryEntry)); + + var properties = directoryEntry.Entry.Properties; + + var name = properties.Value("name"); + var sAMAccountName = properties.Value("sAMAccountName"); + var distinguishedName = properties.Value("distinguishedName"); + var objectSid = new SecurityIdentifier(properties.Value("objectSid"), 0); + + var displayName = properties.Value("displayName") ?? sAMAccountName; + var surname = properties.Value("sn"); + var givenName = properties.Value("givenName"); + var email = properties.Value("mail"); + var phone = properties.Value("telephoneNumber"); + + var userAccountControl = (ADUserAccountControlFlags)properties.Value("userAccountControl"); + var isCriticalSystemObject = properties.Value("isCriticalSystemObject"); + + List groups = null; + // Don't load Groups when doing a quick search + if (!quick) + { + var primaryGroupID = properties.Value("primaryGroupID"); + var primaryGroupSid = ADHelpers.BuildPrimaryGroupSid(objectSid, primaryGroupID); + var memberGroups = properties.Values("memberOf"); + + var primaryGroup = ActiveDirectory.GroupCache.GetGroup(primaryGroupSid); + + var groupDistinguishedNames = + new string[] { primaryGroup.DistinguishedName } + .Concat(memberGroups); + + groups = ActiveDirectory.GroupCache.GetRecursiveGroups(groupDistinguishedNames).ToList(); + } + + // Additional Properties + Dictionary additionalProps; + if (additionalProperties != null) + additionalProps = additionalProperties + .Select(p => Tuple.Create(p, properties.Values(p).ToArray())) + .ToDictionary(t => t.Item1, t => t.Item2); + else + { + additionalProps = new Dictionary(); + } + + return new ADUserAccount( + directoryEntry.Domain, + distinguishedName, + objectSid, + sAMAccountName, + name, + displayName, + surname, + givenName, + email, + phone, + userAccountControl, + isCriticalSystemObject, + groups, + additionalProps); + } + [Obsolete("Use generic equivalents: GetPropertyValue(string PropertyName)")] public object GetPropertyValue(string PropertyName, int Index = 0) { diff --git a/Disco.Services/Interop/ActiveDirectory/ActiveDirectory.cs b/Disco.Services/Interop/ActiveDirectory/ActiveDirectory.cs index 9c851e66..7c08a670 100644 --- a/Disco.Services/Interop/ActiveDirectory/ActiveDirectory.cs +++ b/Disco.Services/Interop/ActiveDirectory/ActiveDirectory.cs @@ -173,6 +173,11 @@ namespace Disco.Services.Interop.ActiveDirectory var domain = Context.GetDomainFromId(Id); return domain.GetAvailableDomainController().RetrieveADObject(Id, Quick); } + public static IADObject RetrieveADObjectByDistinguishedName(string distinguishedName, bool quick) + { + var domain = Context.GetDomainFromDistinguishedName(distinguishedName); + return domain.GetAvailableDomainController().RetrieveADObjectByDistinguishedName(distinguishedName, quick); + } #endregion #region Actions diff --git a/Disco.Services/Interop/ActiveDirectory/ActiveDirectoryExtensions.cs b/Disco.Services/Interop/ActiveDirectory/ActiveDirectoryExtensions.cs index 7ca461f3..cd0ba636 100644 --- a/Disco.Services/Interop/ActiveDirectory/ActiveDirectoryExtensions.cs +++ b/Disco.Services/Interop/ActiveDirectory/ActiveDirectoryExtensions.cs @@ -1,10 +1,14 @@ using Disco.Models.Repository; using Disco.Services.Interop.ActiveDirectory; +using DocumentFormat.OpenXml.Vml.Office; +using System; using System.Collections.Generic; using System.DirectoryServices; using System.DirectoryServices.ActiveDirectory; +using System.IO; using System.Linq; using System.Net.NetworkInformation; +using ZXing; namespace Disco.Services { @@ -95,6 +99,24 @@ namespace Disco.Services return SearchResults.Select(sr => ADOrganisationalUnit.FromSearchResult(sr)); } + public static IADObject AsADObject(this ADDirectoryEntry directoryEntry, bool quick, string[] additionalProperties) + { + var properties = directoryEntry.Entry.Properties; + var objectCategory = properties.Value("objectCategory"); + objectCategory = objectCategory.Substring(0, objectCategory.IndexOf(',')).ToLower(); + switch (objectCategory) + { + case "cn=person": + return ADUserAccount.FromDirectoryEntry(directoryEntry, quick, additionalProperties); + case "cn=computer": + return ADMachineAccount.FromDirectoryEntry(directoryEntry, additionalProperties); + case "cn=group": + return ADGroup.FromDirectoryEntry(directoryEntry, additionalProperties); + default: + throw new InvalidOperationException("Unexpected objectCategory"); + } + } + #endregion public static ADUserAccount ActiveDirectoryAccount(this User User, params string[] AdditionalProperties) diff --git a/Disco.Services/Users/Cache.cs b/Disco.Services/Users/Cache.cs index 0da0f346..b55c982b 100644 --- a/Disco.Services/Users/Cache.cs +++ b/Disco.Services/Users/Cache.cs @@ -39,6 +39,11 @@ namespace Disco.Services.Users return GetAuthorization(UserId, false); } + internal static bool TryGetUser(string UserId, DiscoDataContext Database, bool ForceRefresh, out User User) + { + return TryGet(UserId, Database, ForceRefresh, out User, out _, out _); + } + internal static User GetUser(string UserId, DiscoDataContext Database, bool ForceRefresh) { Tuple record = Get(UserId, Database, ForceRefresh); @@ -66,6 +71,31 @@ namespace Disco.Services.Users return GetUser(UserId, false); } + internal static bool TryGet(string UserId, DiscoDataContext Database, bool ForceRefresh, out User User, out AuthorizationToken AuthToken, out DateTime CacheExpirationTimestamp) + { + Tuple record = null; + + // Check Cache + if (!ForceRefresh) + record = TryUserCache(UserId); + + if (record == null && UserService.TryImportUser(Database, UserId, out var user, out var authorizationToken)) + record = SetValue(UserId, Tuple.Create(user, authorizationToken)); + + if (record != null) + { + User = record.Item1; + AuthToken = record.Item2; + CacheExpirationTimestamp = record.Item3; + return true; + } + + User = default; + AuthToken = default; + CacheExpirationTimestamp = default; + return false; + } + internal static Tuple Get(string UserId, DiscoDataContext Database, bool ForceRefresh) { Tuple record = null; diff --git a/Disco.Services/Users/UserService.cs b/Disco.Services/Users/UserService.cs index ad48c29c..6a995ec7 100644 --- a/Disco.Services/Users/UserService.cs +++ b/Disco.Services/Users/UserService.cs @@ -115,6 +115,11 @@ namespace Disco.Services.Users return Cache.GetUser(UserId, Database, ForceRefresh); } + public static bool TryGetUser(string UserId, DiscoDataContext Database, bool ForceRefresh, out User User) + { + return Cache.TryGetUser(UserId, Database, ForceRefresh, out User); + } + public static AuthorizationToken GetAuthorization(string UserId) { return Cache.GetAuthorization(UserId); @@ -223,10 +228,13 @@ namespace Disco.Services.Users return adImportedUsers; } - internal static Tuple ImportUser(DiscoDataContext Database, string UserId) + internal static bool TryImportUser(DiscoDataContext Database, string UserId, out User User, out AuthorizationToken AuthorizationToken) { + User = null; + AuthorizationToken = null; + if (string.IsNullOrEmpty(UserId)) - throw new ArgumentNullException("UserId is required", "UserId"); + return false; if (UserId.EndsWith("$")) { @@ -234,12 +242,12 @@ namespace Disco.Services.Users var adAccount = ActiveDirectory.RetrieveADMachineAccount(UserId); if (adAccount == null) - return null; + return false; - var user = adAccount.ToRepositoryUser(); - var token = AuthorizationToken.BuildComputerAccountToken(user); + User = adAccount.ToRepositoryUser(); + AuthorizationToken = AuthorizationToken.BuildComputerAccountToken(User); - return new Tuple(user, token); + return true; } else { @@ -251,7 +259,7 @@ namespace Disco.Services.Users adAccount = ActiveDirectory.RetrieveADUserAccount(UserId); if (adAccount == null) - throw new ArgumentException(string.Format("Invalid Username: '{0}'; User not found in Active Directory", UserId), "Username"); + return false; } catch (COMException ex) { @@ -259,36 +267,51 @@ namespace Disco.Services.Users if (ex.ErrorCode == -2147016646) SystemLog.LogException("Server is not operational; Primary Domain Controller Down?", ex); - throw ex; + return false; } catch (ActiveDirectoryOperationException ex) { // Try From Cache... SystemLog.LogException("Primary Domain Controller Down?", ex); - throw ex; + return false; } - var user = adAccount.ToRepositoryUser(); + User = adAccount.ToRepositoryUser(); // Update Repository - User existingUser = Database.Users.Find(user.UserId); + User existingUser = Database.Users.Find(User.UserId); if (existingUser == null) { - Database.Users.Add(user); + Database.Users.Add(User); Database.SaveChanges(); } else { - if (existingUser.UpdateSelf(user)) + if (existingUser.UpdateSelf(User)) { Database.SaveChanges(); } - user = existingUser; + User = existingUser; } - var token = AuthorizationToken.BuildToken(user, adAccount.Groups.Select(g => g.Id)); + AuthorizationToken = AuthorizationToken.BuildToken(User, adAccount.Groups.Select(g => g.Id)); - return new Tuple(user, token); + return true; + } + } + + internal static Tuple ImportUser(DiscoDataContext Database, string UserId) + { + if (string.IsNullOrEmpty(UserId)) + throw new ArgumentNullException(nameof(UserId)); + + if (TryImportUser(Database, UserId, out var user, out var authorization)) + { + return Tuple.Create(user, authorization); + } + else + { + throw new ArgumentException($"Unable to import Active Directory user '{UserId}'", nameof(UserId)); } } }