From dfc5a5c94bb005335993a7af16b5a536337dcebd Mon Sep 17 00:00:00 2001 From: Viko Bastidas Date: Mon, 8 Jun 2026 22:31:10 -0400 Subject: [PATCH 1/2] Fix exceptions & messages --- .../Bitai.LDAPHelper.Demo.csproj | 6 +- .../Program.DemoMethods.cs | 4 +- src/Bitai.LDAPHelper/AccountManager.cs | 207 ++++++++++++------ src/Bitai.LDAPHelper/Authenticator.cs | 6 +- src/Bitai.LDAPHelper/Bitai.LDAPHelper.csproj | 8 +- .../DataValidationException.cs | 20 ++ src/Bitai.LDAPHelper/Searcher.cs | 65 +++--- .../AccountManagerAdapterTests.cs | 16 +- .../Bitai.LDAPHelper.Tests.csproj | 6 +- .../GroupMembershipValidatorTests.cs | 6 +- .../SearcherAdapterTests.cs | 7 +- 11 files changed, 225 insertions(+), 126 deletions(-) create mode 100644 src/Bitai.LDAPHelper/DataValidationException.cs diff --git a/demo/Bitai.LDAPHelper.Demo/Bitai.LDAPHelper.Demo.csproj b/demo/Bitai.LDAPHelper.Demo/Bitai.LDAPHelper.Demo.csproj index c7bc7ce..02be180 100644 --- a/demo/Bitai.LDAPHelper.Demo/Bitai.LDAPHelper.Demo.csproj +++ b/demo/Bitai.LDAPHelper.Demo/Bitai.LDAPHelper.Demo.csproj @@ -10,9 +10,9 @@ © 2026 BITAI. All rights reserved. Bitai.Bitai.LDAPHelper.Demo hierarchy_32.png - 10.0.0 - 10.0.0 - 10.0.0 + 10.0.1 + 10.0.1 + 10.0.1 .NET 10 ready ldap-ad-identity-auth-openid-oauth-security diff --git a/demo/Bitai.LDAPHelper.Demo/Program.DemoMethods.cs b/demo/Bitai.LDAPHelper.Demo/Program.DemoMethods.cs index 168821c..99a45da 100644 --- a/demo/Bitai.LDAPHelper.Demo/Program.DemoMethods.cs +++ b/demo/Bitai.LDAPHelper.Demo/Program.DemoMethods.cs @@ -172,7 +172,7 @@ public static async Task Demo_AccountManager_DisableUserAccount(DemoContext cont var accountManager = new AccountManager(context.GetClientConfiguration(), context.ConnectionFactory); Log.Information("Disabling user account {dn}", distinguishedName); - var result = await accountManager.DisableUserAccountForMsAD(distinguishedName, context.RequestLabel); + var result = await accountManager.DisableUserAccountForMsAD(EntryAttribute.distinguishedName, distinguishedName, context.RequestLabel); if (result.IsSuccessfulOperation) { @@ -194,7 +194,7 @@ public static async Task Demo_AccountManager_RemoveUserAccount(DemoContext conte var accountManager = new AccountManager(context.GetClientConfiguration(), context.ConnectionFactory); Log.Information("Removing user account {dn}", distinguishedName); - var result = await accountManager.RemoveUserAccountForMsAD(distinguishedName, context.RequestLabel); + var result = await accountManager.RemoveUserAccountForMsAD(EntryAttribute.distinguishedName, distinguishedName, context.RequestLabel); if (result.IsSuccessfulOperation) { diff --git a/src/Bitai.LDAPHelper/AccountManager.cs b/src/Bitai.LDAPHelper/AccountManager.cs index 7657a0e..96a2b97 100644 --- a/src/Bitai.LDAPHelper/AccountManager.cs +++ b/src/Bitai.LDAPHelper/AccountManager.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Text; using System.Threading.Tasks; +using System.Data; namespace Bitai.LDAPHelper { @@ -29,37 +30,67 @@ public void InitializeMissingMsADUserAccountDN(LDAPMsADUserAccount userAccount) userAccount.DistinguishedName = $"CN={userAccount.Cn},{userAccount.DistinguishedNameOfContainer}"; } - /// - /// Create a username in MS Active Directory service - /// https://www.rlmueller.net/Name_Attributes.htm - /// - /// - /// DN of the container in which the username will be created. - /// Optional tag to mark the request and/or response. - /// A Task of - public async Task CreateUserAccountForMsAD(LDAPMsADUserAccount newUserAccount, string requestLabel = null) - { - try - { - #region Validate minimally required properties - if (string.IsNullOrEmpty(newUserAccount.DistinguishedNameOfContainer)) - throw new InvalidOperationException($"{nameof(newUserAccount.DistinguishedNameOfContainer)} is required."); - - if (string.IsNullOrEmpty(newUserAccount.Cn)) - throw new InvalidOperationException($"{nameof(newUserAccount.Cn)} is required."); - - if (string.IsNullOrEmpty(newUserAccount.DisplayName)) - throw new InvalidOperationException($"{nameof(newUserAccount.DisplayName)} is required."); - - if (string.IsNullOrEmpty(newUserAccount.SAMAccountName)) - throw new InvalidOperationException($"{nameof(newUserAccount.SAMAccountName)} is required."); - - if (newUserAccount.ObjectClass == null || newUserAccount.ObjectClass.Length == 0) - throw new InvalidOperationException($"{nameof(newUserAccount.ObjectClass)} is required."); - #endregion - - //Generate username DistinguishedName LDAP attribute - InitializeMissingMsADUserAccountDN(newUserAccount); + /// + /// Create a username in MS Active Directory service + /// https://www.rlmueller.net/Name_Attributes.htm + /// + /// + /// DN of the container in which the username will be created. + /// Optional tag to mark the request and/or response. + /// A Task of + public async Task CreateUserAccountForMsAD(LDAPMsADUserAccount newUserAccount, string requestLabel = null) + { + try + { + #region Validate minimally required properties + if (string.IsNullOrEmpty(newUserAccount.DistinguishedNameOfContainer)) + throw new DataValidationException($"{nameof(newUserAccount.DistinguishedNameOfContainer)} is required."); + + if (string.IsNullOrEmpty(newUserAccount.Cn)) + throw new DataValidationException($"{nameof(newUserAccount.Cn)} is required."); + + if (string.IsNullOrEmpty(newUserAccount.DisplayName)) + throw new DataValidationException($"{nameof(newUserAccount.DisplayName)} is required."); + + if (string.IsNullOrEmpty(newUserAccount.SAMAccountName)) + throw new DataValidationException($"{nameof(newUserAccount.SAMAccountName)} is required."); + + if (newUserAccount.ObjectClass == null || newUserAccount.ObjectClass.Length == 0) + throw new DataValidationException($"{nameof(newUserAccount.ObjectClass)} is required."); + #endregion + + //Generate username DistinguishedName LDAP attribute + InitializeMissingMsADUserAccountDN(newUserAccount); + + LDAPEntry checkUserAccount = null; + // Check whether an entry with the same distinguished name already exists in the directory before creating a new user. Since the distinguished name must be unique, a duplicate entry cannot be created. If one already exists, return an error or ask for a different name. + try + { + checkUserAccount = await verifyMsADEntryAccountAuthenticity(EntryAttribute.distinguishedName, newUserAccount.DistinguishedName, false, requestLabel); + } + catch (Exception) + { + // Do nothing + } + finally + { + if (checkUserAccount != null) + throw new DuplicateNameException($"The {EntryAttribute.displayName}: {newUserAccount.DistinguishedName} already exists in the directory."); + } + // Check whether an entry with the same sAMAccountName already exists in the directory before creating a new user. Since the sAMAccountName must be unique, a duplicate entry cannot be created. If one already exists, return an error or ask for a different name. + try + { + checkUserAccount = await verifyMsADEntryAccountAuthenticity(EntryAttribute.sAMAccountName, newUserAccount.SAMAccountName, false, requestLabel); + } + catch (Exception) + { + // Do nothing + } + finally + { + if (checkUserAccount != null) + throw new DuplicateNameException($"The {EntryAttribute.sAMAccountName}: {newUserAccount.SAMAccountName} already exists in the directory."); + } using (var ldapConnection = await GetLdapConnection(this.ConnectionInfo, this.DomainAccountCredential)) { #region Initialize and populate LDAP attribute set @@ -110,21 +141,28 @@ public async Task CreateUserAccountForMsAD(LDAP if (!string.IsNullOrEmpty(newUserAccount.Password)) { byte[] encodedNewPasswordBytes = Encoding.Unicode.GetBytes($"\"{newUserAccount.Password}\""); attributeSet.AddAttribute(EntryAttribute.unicodePwd.ToString(), encodedNewPasswordBytes); - } + } #endregion //Add new username entry to the directory - await ldapConnection.AddEntryAsync(newUserAccount.DistinguishedName, attributeSet); + await ldapConnection.AddEntryAsync(newUserAccount.DistinguishedName, attributeSet); } - return new LDAPCreateMsADUserAccountResult(newUserAccount.SecureClone(), requestLabel) - { - OperationMessage = $"MS AD user account created at {newUserAccount.DistinguishedName} with {EntryAttribute.sAMAccountName.ToString()}: {newUserAccount.SAMAccountName}" - }; - } - catch (Exception ex) + return new LDAPCreateMsADUserAccountResult(newUserAccount.SecureClone(), requestLabel) + { + OperationMessage = $"MS AD user account created at {newUserAccount.DistinguishedName} with {EntryAttribute.sAMAccountName.ToString()}: {newUserAccount.SAMAccountName}" + }; + } + catch (DataValidationException ex) + { + return new LDAPCreateMsADUserAccountResult("Unable to create user account.", ex, requestLabel) + { + UserAccount = newUserAccount.SecureClone() + }; + } + catch (Exception ex) { - return new LDAPCreateMsADUserAccountResult("Error creating username.", ex, requestLabel) + return new LDAPCreateMsADUserAccountResult("Unexpected error while attempting to create user account.", ex, requestLabel) { UserAccount = newUserAccount.SecureClone() }; @@ -138,7 +176,7 @@ public async Task CreateUserAccountForMsAD(LDAP /// Optional tag to mark the request and/or response. /// True if the MS AD user account will be tested to verify authentication with the new password. False if the password will simply be assigned and authentication will not be tested. /// - public async Task SetUserAccountPasswordForMsAD(DTO.LDAPDistinguishedNameCredential credential, string requestLabel = null, bool postUpdateTestAuthentication = true) + public async Task SetUserAccountPasswordForMsAD(LDAPDistinguishedNameCredential credential, string requestLabel = null, bool postUpdateTestAuthentication = true) { try { @@ -148,7 +186,7 @@ public async Task SetUserAccountPasswordForMsAD(DTO.LD if (string.IsNullOrEmpty(credential.Password)) throw new ArgumentNullException($"The password to be assigned is required."); - var entry = await verifyUserAccountAuthenticity(credential.DistinguishedName, requestLabel); + var entry = await verifyMsADEntryAccountAuthenticity(EntryAttribute.distinguishedName, credential.DistinguishedName, true, requestLabel); //Create password modification request string newPassword = $"\"{credential.Password}\""; @@ -163,7 +201,7 @@ public async Task SetUserAccountPasswordForMsAD(DTO.LD //Send modification request to the directory await ldapConnection.ModifyEntryAsync(entry.distinguishedName, new[] { modification }); - //await ldapConnection.ModifyAsync(entry.distinguishedName, pwdModification); + //await ldapConnection.ModifyAsync(entry.identifierValue, pwdModification); if (postUpdateTestAuthentication) { @@ -189,33 +227,40 @@ public async Task SetUserAccountPasswordForMsAD(DTO.LD return createSuccessfulResult(requestLabel, entry.distinguishedName); } } - catch (Bitai.LDAPHelper.EntryNotFoundException ex) { - return new DTO.LDAPPasswordUpdateResult(ex.Message, ex, requestLabel); + catch (EntryNotFoundException ex) { + return new LDAPPasswordUpdateResult("User account not found.", ex, requestLabel); } - catch (Exception ex) + catch (DataValidationException ex) + { + return new LDAPPasswordUpdateResult("Invalid data found.", ex, requestLabel); + } + catch (Exception ex) { - return new DTO.LDAPPasswordUpdateResult("Unexpected error trying to replace password.", ex, requestLabel); + return new LDAPPasswordUpdateResult("Unexpected error while attempting to replace password.", ex, requestLabel); } - DTO.LDAPPasswordUpdateResult createSuccessfulResult(string label, string name) { - return new DTO.LDAPPasswordUpdateResult(label, $"Password set successfully for {name}"); + LDAPPasswordUpdateResult createSuccessfulResult(string label, string name) { + return new LDAPPasswordUpdateResult(label, $"Password set successfully for {name}"); } } /// /// Remove a username from MS Active Directory service. This method will verify the authenticity of the username by its distinguished name before trying to remove it. If the username is not valid, the operation will not be attempted and an error will be returned. /// - /// Distinguished name of the username + /// Distinguished name of the username /// Optional tag to mark the request and/or response. /// - public async Task DisableUserAccountForMsAD(string distinguishedName, string requestLabel) + public async Task DisableUserAccountForMsAD(EntryAttribute identifierAttribute, string identifierValue, string requestLabel) { try { - if (string.IsNullOrEmpty(distinguishedName)) - throw new ArgumentNullException(nameof(distinguishedName)); + if (EntryAttribute.sAMAccountName != identifierAttribute && EntryAttribute.distinguishedName != identifierAttribute) + throw new ArgumentException($"The identifier attribute must be {EntryAttribute.sAMAccountName} or {EntryAttribute.distinguishedName} for disabling a user account."); - var entry = await verifyUserAccountAuthenticity(distinguishedName, requestLabel); + if (string.IsNullOrEmpty(identifierValue)) + throw new ArgumentNullException($"The user account's {identifierAttribute} value is required."); + + var entry = await verifyMsADEntryAccountAuthenticity(identifierAttribute, identifierValue, true, requestLabel); using (var ldapConnection = await GetLdapConnection(this.ConnectionInfo, this.DomainAccountCredential)) { //To disable a MS AD user account, the userAccountControl attribute needs to be set with the appropriate flags. The flag for disabling an account is ACCOUNTDISABLE (0x0002). However, when setting the userAccountControl attribute, it is important to preserve the existing flags that are set for the account, and only add the ACCOUNTDISABLE flag without removing any of the existing flags. This is because other flags may be set for the account that are necessary for its proper functioning, and removing them could cause unintended consequences. Therefore, when disabling a username, you should retrieve the current value of the userAccountControl attribute, add the ACCOUNTDISABLE flag to it, and then update the attribute with the new value that includes both the existing flags and the ACCOUNTDISABLE flag. @@ -229,7 +274,7 @@ public async Task DisableUserAccountForMs //Send modification request to the directory await ldapConnection.ModifyEntryAsync(entry.distinguishedName, new[] { modification }); - //await ldapConnection.ModifyAsync(entry.distinguishedName, userAccountControlModification); + //await ldapConnection.ModifyAsync(entry.identifierValue, userAccountControlModification); } return new DTO.LDAPDisableUserAccountOperationResult(requestLabel) @@ -237,35 +282,43 @@ public async Task DisableUserAccountForMs OperationMessage = $"Username {entry.samAccountName} has been disabled." }; } - catch (Bitai.LDAPHelper.EntryNotFoundException ex) { - return new DTO.LDAPDisableUserAccountOperationResult(ex.Message, ex, requestLabel); + catch (EntryNotFoundException ex) + { + return new LDAPDisableUserAccountOperationResult("User account not found.", ex, requestLabel); + } + catch (DataValidationException ex) + { + return new LDAPDisableUserAccountOperationResult("Invalid data found.", ex, requestLabel); } catch (Exception ex) { - return new LDAPDisableUserAccountOperationResult($"Error trying to disable username with DN: {distinguishedName}", ex, requestLabel); + return new LDAPDisableUserAccountOperationResult($"Error trying to disable username with DN: {identifierValue}", ex, requestLabel); } } /// /// Remove a username in MS Active Directory service. This operation will permanently delete the username entry from the directory, so it should be used with caution. /// - /// Distinguished name of the username + /// Distinguished name of the username /// Optional tag to mark the request and/or response. /// - public async Task RemoveUserAccountForMsAD(string distinguishedName, string requestLabel = null) + public async Task RemoveUserAccountForMsAD(EntryAttribute identifierAttribute, string identifierValue, string requestLabel = null) { try { - if (string.IsNullOrEmpty(distinguishedName)) - throw new ArgumentNullException("The distinguished name of the username to remove must be provided."); + if (identifierAttribute != EntryAttribute.sAMAccountName && identifierAttribute != EntryAttribute.distinguishedName) + throw new ArgumentException($"The identifier attribute must be {EntryAttribute.sAMAccountName} or {EntryAttribute.distinguishedName} for removing a user account."); - var entry = await verifyUserAccountAuthenticity(distinguishedName, requestLabel); + if (string.IsNullOrEmpty(identifierValue)) + throw new ArgumentNullException($"The user account's {identifierAttribute} value is required."); + + var entry = await verifyMsADEntryAccountAuthenticity(identifierAttribute, identifierValue, true, requestLabel); using (var ldapConnection = await GetLdapConnection(this.ConnectionInfo, this.DomainAccountCredential)) { //To remove a username from MS AD, the username entry needs to be deleted from the directory. This operation will permanently delete the username entry, so it should be used with caution. await ldapConnection.DeleteEntryAsync(entry.distinguishedName); - //await ldapConnection.DeleteAsync(entry.distinguishedName); + //await ldapConnection.DeleteAsync(entry.identifierValue); } return new LDAPRemoveMsADUserAccountResult(requestLabel) @@ -273,22 +326,30 @@ public async Task RemoveUserAccountForMsAD(stri OperationMessage = $"The username {entry.samAccountName} has been successfully removed." }; } - catch (Bitai.LDAPHelper.EntryNotFoundException ex) { - return new LDAPRemoveMsADUserAccountResult(ex.Message, ex, requestLabel); + catch (EntryNotFoundException ex) + { + return new LDAPRemoveMsADUserAccountResult("User account not found.", ex, requestLabel); + } + catch (DataValidationException ex) + { + return new LDAPRemoveMsADUserAccountResult("Invalid data found.", ex, requestLabel); } catch (Exception ex) { - return new LDAPRemoveMsADUserAccountResult($"Error trying to remove username with DN: {distinguishedName}", ex, requestLabel); + return new LDAPRemoveMsADUserAccountResult($"Error trying to remove username with DN: {identifierValue}", ex, requestLabel); } } - private async Task verifyUserAccountAuthenticity(string distinguishedName, string requestLabel = null) + private async Task verifyMsADEntryAccountAuthenticity(EntryAttribute identifierAttribute, string identifierValue, bool validateObjectClass, string requestLabel = null) { - var onlyUsersFilterCombiner = QueryFilters.AttributeFilterCombiner.CreateOnlyUsersFilterCombiner(); - var attributeFilter = new QueryFilters.AttributeFilter(EntryAttribute.distinguishedName, new QueryFilters.FilterValue(distinguishedName)); + if (identifierAttribute != EntryAttribute.sAMAccountName && identifierAttribute != EntryAttribute.distinguishedName) + throw new ArgumentException($"The identifier attribute must be {EntryAttribute.sAMAccountName} or {EntryAttribute.distinguishedName} for verifying the authenticity of a user account."); + + var onlyUsersFilterCombiner = QueryFilters.AttributeFilterCombiner.CreateOnlyUsersFilterCombiner(); + var attributeFilter = new QueryFilters.AttributeFilter(identifierAttribute, new QueryFilters.FilterValue(identifierValue)); var searchFilterCombiner = new QueryFilters.AttributeFilterCombiner(false, true, new List { onlyUsersFilterCombiner, attributeFilter }); var searcher = new Searcher(this.ConnectionInfo, this.SearchLimits, this.DomainAccountCredential, ConnectionFactory); @@ -302,12 +363,12 @@ private async Task verifyUserAccountAuthenticity(string distinguished } if (searchResult.Entries.Count() == 0) - throw new EntryNotFoundException($"DN {distinguishedName} does not exist."); + throw new EntryNotFoundException($"{identifierAttribute} {identifierValue} does not exist."); var entry = searchResult.Entries.Single(); - if (!entry.objectClass.Contains("user")) - throw new InvalidOperationException($"DN {distinguishedName} is not a username."); + if (validateObjectClass && !entry.objectClass.Contains("user")) + throw new DataValidationException($"{identifierAttribute} {identifierValue} is not a user entry."); return entry; } diff --git a/src/Bitai.LDAPHelper/Authenticator.cs b/src/Bitai.LDAPHelper/Authenticator.cs index 6d2e64c..ca92058 100644 --- a/src/Bitai.LDAPHelper/Authenticator.cs +++ b/src/Bitai.LDAPHelper/Authenticator.cs @@ -33,8 +33,10 @@ public Authenticator(ConnectionInfo connectionInfo, ILdapConnectionFactoryAdapte return new LDAPDomainAccountAuthenticationResult(credential, searchResult.OperationMessage, searchResult.ErrorObject, requestLabel); } else { - authenticationResult = new LDAPDomainAccountAuthenticationResult(credential, false, requestLabel, false); - authenticationResult.OperationMessage = searchResult.OperationMessage; + authenticationResult = new LDAPDomainAccountAuthenticationResult(credential, false, requestLabel, false) + { + OperationMessage = searchResult.OperationMessage + }; return authenticationResult; } diff --git a/src/Bitai.LDAPHelper/Bitai.LDAPHelper.csproj b/src/Bitai.LDAPHelper/Bitai.LDAPHelper.csproj index 785c96f..a9e3595 100644 --- a/src/Bitai.LDAPHelper/Bitai.LDAPHelper.csproj +++ b/src/Bitai.LDAPHelper/Bitai.LDAPHelper.csproj @@ -7,10 +7,10 @@ LDAP Services Wrappers Library to wrap Novell.Directory.Ldap.NETStandard functionality to make LDAP common queries to search accounts and objects in a Directory Service. © 2026 BITAI. All rights reserved. - 10.1.0 - 10.1.0 - hierarchy_32.png - 10.1.0 + 10.1.1 + 10.1.1 + 10.1.1 + hierarchy_32.png true Bitai.LDAPHelper Bitai.LDAPHelper diff --git a/src/Bitai.LDAPHelper/DataValidationException.cs b/src/Bitai.LDAPHelper/DataValidationException.cs new file mode 100644 index 0000000..39b3147 --- /dev/null +++ b/src/Bitai.LDAPHelper/DataValidationException.cs @@ -0,0 +1,20 @@ +using System; + +namespace Bitai.LDAPHelper +{ + [Serializable] + internal class DataValidationException : Exception + { + public DataValidationException() + { + } + + public DataValidationException(string message) : base(message) + { + } + + public DataValidationException(string message, Exception innerException) : base(message, innerException) + { + } + } +} diff --git a/src/Bitai.LDAPHelper/Searcher.cs b/src/Bitai.LDAPHelper/Searcher.cs index 5780f82..4578d9d 100644 --- a/src/Bitai.LDAPHelper/Searcher.cs +++ b/src/Bitai.LDAPHelper/Searcher.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Novell.Directory.Ldap; namespace Bitai.LDAPHelper { @@ -154,8 +155,6 @@ public Searcher(ConnectionInfo connectionInfo, SearchLimits searchLimits, DTO.LD private async Task getSearchResultAsync(DTO.RequiredEntryAttributes requiredEntryAttributes, string searchFilter, string requestLabel) { - DTO.LDAPSearchResult searchResult; - try { var attributesToLoad = this.GetRequiredAttributeNames(requiredEntryAttributes); var entries = new List(); @@ -176,20 +175,25 @@ public Searcher(ConnectionInfo connectionInfo, SearchLimits searchLimits, DTO.LD connection.Disconnect(); } - searchResult = new DTO.LDAPSearchResult(requestLabel, entries, $"The search returned {entries.Count} entries."); - - return searchResult; + return new DTO.LDAPSearchResult(requestLabel, entries, $"The search returned {entries.Count} entries."); } - catch (Exception ex) when (ex.GetType().Name == "LdapException") { - var ldapErrorMessageProp = ex.GetType().GetProperty("LdapErrorMessage"); - string ldapErrorMessage = ldapErrorMessageProp?.GetValue(ex) as string ?? ""; - string msg = string.IsNullOrEmpty(ldapErrorMessage) ? ex.Message : $"{ex.Message} ({ldapErrorMessage})"; - searchResult = new DTO.LDAPSearchResult(msg, ex, requestLabel); + catch (LdapException ex) + { + string msg = string.IsNullOrEmpty(ex.LdapErrorMessage) ? ex.Message : (string.IsNullOrEmpty(ex.Message) ? ex.LdapErrorMessage : $"{ex.Message} ({ex.LdapErrorMessage})"); + var searchResult = new DTO.LDAPSearchResult(msg, ex, requestLabel); - return searchResult; - } + return searchResult; + } + //// BITAI: Remain for future reference if we want to avoid direct dependency on Novell.Directory.Ldap in this class. The LdapException type is specific to the Novell library, so if we want to keep this class decoupled from that library, we can catch general Exception and check the type name as done in other parts of the code. However, if we are okay with referencing Novell.Directory.Ldap directly, catching LdapException is more straightforward and type-safe. + //catch (Exception ex) when (ex.GetType().Name == "LdapException") { + // var ldapErrorMessageProp = ex.GetType().GetProperty("LdapErrorMessage"); + // string ldapErrorMessage = ldapErrorMessageProp?.GetValue(ex) as string ?? ""; + // string msg = string.IsNullOrEmpty(ldapErrorMessage) ? ex.Message : $"{ex.Message} ({ldapErrorMessage})"; + // searchResult = new DTO.LDAPSearchResult(msg, ex, requestLabel); + // return searchResult; + //} catch (Exception ex) { - searchResult = new DTO.LDAPSearchResult($"Unexpected error performing search. {ex.Message}", ex, requestLabel); + var searchResult = new DTO.LDAPSearchResult($"Unexpected error encountered while performing search.", ex, requestLabel); return searchResult; } @@ -263,9 +267,7 @@ public Searcher(ConnectionInfo connectionInfo, SearchLimits searchLimits, DTO.LD return partialSearchResult; } else if (partialSearchResult.Entries.Count() == 0) { - partialSearchResult.SetUnsuccessfulOperation("No one entry was found according to the search filter."); - - return partialSearchResult; + throw new EntryNotFoundException("Unable to evaluate without an entry."); } var collectedEntries = partialSearchResult.Entries.SelectAllMemberOfEntriesRecursively(); @@ -284,15 +286,28 @@ public Searcher(ConnectionInfo connectionInfo, SearchLimits searchLimits, DTO.LD return new DTO.LDAPSearchResult(requestLabel, resultEntries); } - catch (Exception ex) when (ex.GetType().Name == "LdapException") { - var ldapErrorMessageProp = ex.GetType().GetProperty("LdapErrorMessage"); - string ldapErrorMessage = ldapErrorMessageProp?.GetValue(ex) as string ?? ""; - string msg = string.IsNullOrEmpty(ldapErrorMessage) ? ex.Message : $"{ex.Message} ({ldapErrorMessage})"; - var searchResult = new DTO.LDAPSearchResult(msg, ex, requestLabel); + catch (EntryNotFoundException ex) + { + var searchResult = new DTO.LDAPSearchResult("Nonexistent entry.", ex, requestLabel); - return searchResult; - } - catch (Exception ex) { + return searchResult; + } + catch (LdapException ex) + { + string msg = string.IsNullOrEmpty(ex.LdapErrorMessage) ? ex.Message : (string.IsNullOrEmpty(ex.Message) ? ex.LdapErrorMessage : $"{ex.Message} ({ex.LdapErrorMessage})"); + var searchResult = new DTO.LDAPSearchResult(msg, ex, requestLabel); + + return searchResult; + } + //// BITAI: Remain for future reference if we want to avoid direct dependency on Novell.Directory.Ldap in this class. The LdapException type is specific to the Novell library, so if we want to keep this class decoupled from that library, we can catch general Exception and check the type name as done in other parts of the code. However, if we are okay with referencing Novell.Directory.Ldap directly, catching LdapException is more straightforward and type-safe. + //catch (Exception ex) when (ex.GetType().Name == "LdapException") { + // var ldapErrorMessageProp = ex.GetType().GetProperty("LdapErrorMessage"); + // string ldapErrorMessage = ldapErrorMessageProp?.GetValue(ex) as string ?? ""; + // string msg = string.IsNullOrEmpty(ldapErrorMessage) ? ex.Message : $"{ex.Message} ({ldapErrorMessage})"; + // var searchResult = new DTO.LDAPSearchResult(msg, ex, requestLabel); + // return searchResult; + //} + catch (Exception ex) { var searchResult = new DTO.LDAPSearchResult($"Unexpected error performing search. {ex.Message}", ex, requestLabel); return searchResult; @@ -300,4 +315,4 @@ public Searcher(ConnectionInfo connectionInfo, SearchLimits searchLimits, DTO.LD } #endregion } -} \ No newline at end of file +} diff --git a/tests/Bitai.LDAPHelper.Tests/AccountManagerAdapterTests.cs b/tests/Bitai.LDAPHelper.Tests/AccountManagerAdapterTests.cs index fbe8f50..251dd78 100644 --- a/tests/Bitai.LDAPHelper.Tests/AccountManagerAdapterTests.cs +++ b/tests/Bitai.LDAPHelper.Tests/AccountManagerAdapterTests.cs @@ -67,7 +67,7 @@ public async Task CreateUserAccountForMsAD_MissingRequiredAttr_ReturnsError() { var result = await accountManager.CreateUserAccountForMsAD(newUser, "TestCreate"); Assert.False(result.IsSuccessfulOperation); - Assert.Contains("error creating user", result.OperationMessage.ToLower()); + Assert.StartsWith("unable to create", result.OperationMessage, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -136,7 +136,7 @@ public async Task SetUserAccountPasswordForMsAD_AccountNotFound_ReturnsFailed() var result = await accountManager.SetUserAccountPasswordForMsAD(userCredential, "TestPassword", postUpdateTestAuthentication: true); Assert.False(result.IsSuccessfulOperation); - Assert.Contains("does not exist", result.OperationMessage.ToLower()); + Assert.StartsWith("user account not found", result.OperationMessage, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -163,7 +163,7 @@ public async Task DisableUserAccountForMsAD_ValidAccount_ReturnsSuccess() { mockConnection.AddSearchResult(groupSearchFilter2.ToString(), new List { mockGroupEntry2 }); mockConnection.AddSearchResult(userSearchFilter.ToString(), new List { mockUserEntry }); - var result = await accountManager.DisableUserAccountForMsAD(mockUserEntry.DistinguishedName, "TestDisable"); + var result = await accountManager.DisableUserAccountForMsAD(EntryAttribute.distinguishedName, mockUserEntry.DistinguishedName, "TestDisable"); // Assert Assert.True(result.IsSuccessfulOperation); @@ -195,11 +195,11 @@ public async Task DisableUserAccountForMsAD_AccountNotFound_ReturnsSuccess() { //Do not add user account in order to trigger user not found validation. //mockConnection.AddSearchResult(userSearchFilter.ToString(), new List { mockUserEntry }); - var result = await accountManager.DisableUserAccountForMsAD(mockUserEntry.DistinguishedName, "TestDisable"); + var result = await accountManager.DisableUserAccountForMsAD(EntryAttribute.distinguishedName, mockUserEntry.DistinguishedName, "TestDisable"); // Assert Assert.False(result.IsSuccessfulOperation); - Assert.Contains("does not exist", result.OperationMessage.ToLower()); + Assert.StartsWith("user account not found", result.OperationMessage, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -226,7 +226,7 @@ public async Task RemoveUserAccountForMsAD_ValidAccount_ReturnsSuccess() { var accountManager = new AccountManager(connectionInfo, searchLimits, credential, mockConnectionFactory); - var result = await accountManager.RemoveUserAccountForMsAD(mockUserEntry.DistinguishedName, "TestDelete"); + var result = await accountManager.RemoveUserAccountForMsAD(EntryAttribute.distinguishedName, mockUserEntry.DistinguishedName, "TestDelete"); Assert.True(result.IsSuccessfulOperation); Assert.Contains("successfully removed", result.OperationMessage.ToLower()); @@ -257,10 +257,10 @@ public async Task RemoveUserAccountForMsAD_AccountNotFound_ReturnsSuccess() { var accountManager = new AccountManager(connectionInfo, searchLimits, credential, mockConnectionFactory); - var result = await accountManager.RemoveUserAccountForMsAD(mockUserEntry.DistinguishedName, "TestDelete"); + var result = await accountManager.RemoveUserAccountForMsAD(EntryAttribute.distinguishedName, mockUserEntry.DistinguishedName, "TestDelete"); Assert.False(result.IsSuccessfulOperation); - Assert.Contains("does not exist", result.OperationMessage.ToLower()); + Assert.Contains("user account not found", result.OperationMessage, StringComparison.OrdinalIgnoreCase); } } } diff --git a/tests/Bitai.LDAPHelper.Tests/Bitai.LDAPHelper.Tests.csproj b/tests/Bitai.LDAPHelper.Tests/Bitai.LDAPHelper.Tests.csproj index 0b9177b..6f92695 100644 --- a/tests/Bitai.LDAPHelper.Tests/Bitai.LDAPHelper.Tests.csproj +++ b/tests/Bitai.LDAPHelper.Tests/Bitai.LDAPHelper.Tests.csproj @@ -7,9 +7,9 @@ false true - 10.0.0 - 10.0.0 - 10.0.0 + 10.0.1 + 10.0.1 + 10.0.1 5981b6a0-6b9e-439d-8324-a0ef8bfd0f11 diff --git a/tests/Bitai.LDAPHelper.Tests/GroupMembershipValidatorTests.cs b/tests/Bitai.LDAPHelper.Tests/GroupMembershipValidatorTests.cs index 0b25aa6..daad1f7 100644 --- a/tests/Bitai.LDAPHelper.Tests/GroupMembershipValidatorTests.cs +++ b/tests/Bitai.LDAPHelper.Tests/GroupMembershipValidatorTests.cs @@ -1,4 +1,4 @@ -using Bitai.LDAPHelper.DTO; +using Bitai.LDAPHelper.DTO; using Bitai.LDAPHelper.Tests.Mocks.LdapAdapters; namespace Bitai.LDAPHelper.Tests @@ -700,7 +700,7 @@ public async Task GetAllGroupMembershipsAsync_WhenSearchFails_ThrowsException() var exception = await Assert.ThrowsAsync( () => validator.GetAllGroupMembershipsAsync("john.doe")); - Assert.Contains("no one entry was found", exception.Message.ToLower()); + Assert.StartsWith("unable to evaluate without an entry", exception.Message, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -734,4 +734,4 @@ public async Task CheckGroupMembershipAsync_WithWhitespaceInCN_HandlesCorrectly( #endregion } -} \ No newline at end of file +} diff --git a/tests/Bitai.LDAPHelper.Tests/SearcherAdapterTests.cs b/tests/Bitai.LDAPHelper.Tests/SearcherAdapterTests.cs index 7698e6b..a924af9 100644 --- a/tests/Bitai.LDAPHelper.Tests/SearcherAdapterTests.cs +++ b/tests/Bitai.LDAPHelper.Tests/SearcherAdapterTests.cs @@ -108,11 +108,12 @@ public async Task SearchParentEntries_ReturnsEmptyList() { var searcher = new Searcher(connectionInfo, searchLimits, credential, mockConnectionFactory); GenerateCommonUserSearchFilter("Dummiest", "User", searchLimits, out var expectedDummiestUserSearchFilter, out var _); + var result = await searcher.SearchParentEntriesAsync(expectedDummiestUserSearchFilter, RequiredEntryAttributes.Minimun, "TestRequest"); Assert.False(result.IsSuccessfulOperation); - Assert.Empty(result.Entries); - Assert.Contains("no one entry was found", result.OperationMessage.ToLower()); + Assert.Null(result.Entries); + Assert.StartsWith("nonexistent entry", result.OperationMessage, StringComparison.OrdinalIgnoreCase); } } -} \ No newline at end of file +} From 79dae515355a2a7a05247a1b35d3e6aa38187748 Mon Sep 17 00:00:00 2001 From: Viko Bastidas Date: Wed, 10 Jun 2026 02:35:50 -0400 Subject: [PATCH 2/2] Refactor AccountManager API, improve LDAP error handling Renamed AccountManager MSAD methods for clarity and flexibility, now accepting explicit identifier attributes. Enhanced LDAP exception handling and made DataValidationException public. Refactored Searcher for extensibility and updated tests. Bumped version to 10.1.2. --- .../Program.DemoMethods.cs | 8 +- src/Bitai.LDAPHelper/AccountManager.cs | 48 ++- src/Bitai.LDAPHelper/Authenticator.cs | 24 +- src/Bitai.LDAPHelper/BaseHelper.cs | 11 +- src/Bitai.LDAPHelper/Bitai.LDAPHelper.csproj | 6 +- .../DataValidationException.cs | 2 +- src/Bitai.LDAPHelper/Searcher.cs | 374 +++++++++--------- .../AccountManagerAdapterTests.cs | 14 +- .../Bitai.LDAPHelper.Tests.csproj | 6 +- 9 files changed, 254 insertions(+), 239 deletions(-) diff --git a/demo/Bitai.LDAPHelper.Demo/Program.DemoMethods.cs b/demo/Bitai.LDAPHelper.Demo/Program.DemoMethods.cs index 99a45da..562bd9b 100644 --- a/demo/Bitai.LDAPHelper.Demo/Program.DemoMethods.cs +++ b/demo/Bitai.LDAPHelper.Demo/Program.DemoMethods.cs @@ -143,11 +143,11 @@ public static async Task Demo_AccountManager_SetPassword(DemoContext context, st Log.Information($"Enter new password for {distinguishedName}"); var password = requestAccountPassword(distinguishedName); - var credential = new LDAPDistinguishedNameCredential(distinguishedName, password); + var accountManager = new AccountManager(context.GetClientConfiguration(), context.ConnectionFactory); Log.Information("Setting account password..."); - var result = await accountManager.SetUserAccountPasswordForMsAD(credential, context.RequestLabel); + var result = await accountManager.SetMsADUserAccountPassword(EntryAttribute.distinguishedName, distinguishedName, password, context.RequestLabel); if (result.IsSuccessfulOperation) { @@ -172,7 +172,7 @@ public static async Task Demo_AccountManager_DisableUserAccount(DemoContext cont var accountManager = new AccountManager(context.GetClientConfiguration(), context.ConnectionFactory); Log.Information("Disabling user account {dn}", distinguishedName); - var result = await accountManager.DisableUserAccountForMsAD(EntryAttribute.distinguishedName, distinguishedName, context.RequestLabel); + var result = await accountManager.DisableMsADUserAccount(EntryAttribute.distinguishedName, distinguishedName, context.RequestLabel); if (result.IsSuccessfulOperation) { @@ -194,7 +194,7 @@ public static async Task Demo_AccountManager_RemoveUserAccount(DemoContext conte var accountManager = new AccountManager(context.GetClientConfiguration(), context.ConnectionFactory); Log.Information("Removing user account {dn}", distinguishedName); - var result = await accountManager.RemoveUserAccountForMsAD(EntryAttribute.distinguishedName, distinguishedName, context.RequestLabel); + var result = await accountManager.RemoveMsADUserAccount(EntryAttribute.distinguishedName, distinguishedName, context.RequestLabel); if (result.IsSuccessfulOperation) { diff --git a/src/Bitai.LDAPHelper/AccountManager.cs b/src/Bitai.LDAPHelper/AccountManager.cs index 96a2b97..e93511c 100644 --- a/src/Bitai.LDAPHelper/AccountManager.cs +++ b/src/Bitai.LDAPHelper/AccountManager.cs @@ -6,6 +6,7 @@ using System.Text; using System.Threading.Tasks; using System.Data; +using Novell.Directory.Ldap; namespace Bitai.LDAPHelper { @@ -70,7 +71,7 @@ public async Task CreateUserAccountForMsAD(LDAP } catch (Exception) { - // Do nothing + // Do nothing. Any exception thrown from the verifyMsADEntryAccountAuthenticity method can be ignored because if an entry with the same distinguished name does not exist, an exception will be thrown and we can simply ignore it and proceed to create the new user account. The only time we need to pay attention is when no exception is thrown and an entry is returned, which means an entry with the same distinguished name already exists and we should not create a new user account with the same distinguished name. } finally { @@ -84,7 +85,7 @@ public async Task CreateUserAccountForMsAD(LDAP } catch (Exception) { - // Do nothing + // Do nothing. Any exception thrown from the verifyMsADEntryAccountAuthenticity method can be ignored because if an entry with the same sAMAccountName does not exist, an exception will be thrown and we can simply ignore it and proceed to create the new user account. The only time we need to pay attention is when no exception is thrown and an entry is returned, which means an entry with the same sAMAccountName already exists and we should not create a new user account with the same sAMAccountName. } finally { @@ -176,20 +177,23 @@ public async Task CreateUserAccountForMsAD(LDAP /// Optional tag to mark the request and/or response. /// True if the MS AD user account will be tested to verify authentication with the new password. False if the password will simply be assigned and authentication will not be tested. /// - public async Task SetUserAccountPasswordForMsAD(LDAPDistinguishedNameCredential credential, string requestLabel = null, bool postUpdateTestAuthentication = true) + public async Task SetMsADUserAccountPassword(EntryAttribute identifierAttribute, string identifierValue, string password, string requestLabel = null, bool postUpdateTestAuthentication = true) { try { - if (string.IsNullOrEmpty(credential.DistinguishedName)) - throw new ArgumentNullException("The distinguished name of the username is required."); + if (identifierAttribute != EntryAttribute.sAMAccountName && identifierAttribute != EntryAttribute.distinguishedName) + throw new ArgumentException($"The identifier attribute must be {EntryAttribute.sAMAccountName} or {EntryAttribute.distinguishedName} for setting a user account password."); + + if (string.IsNullOrEmpty(identifierValue)) + throw new DataValidationException("The user account identifier is required."); - if (string.IsNullOrEmpty(credential.Password)) - throw new ArgumentNullException($"The password to be assigned is required."); + if (string.IsNullOrEmpty(password)) + throw new DataValidationException("The user account password is required."); - var entry = await verifyMsADEntryAccountAuthenticity(EntryAttribute.distinguishedName, credential.DistinguishedName, true, requestLabel); + var entry = await verifyMsADEntryAccountAuthenticity(identifierAttribute, identifierValue, true, requestLabel); //Create password modification request - string newPassword = $"\"{credential.Password}\""; + string newPassword = $"\"{password}\""; byte[] encodedNewPasswordBytes = Encoding.Unicode.GetBytes(newPassword); //string newPasswordEncodedString = Convert.ToBase64String(encodedNewPasswordBytes); //var pwdAttribute = new LdapAttribute(DTO.EntryAttribute.unicodePwd.ToString(), encodedNewPasswordBytes); @@ -205,8 +209,9 @@ public async Task SetUserAccountPasswordForMsAD(LDAPDi if (postUpdateTestAuthentication) { + var postValidationCredential = new LDAPDistinguishedNameCredential(entry.distinguishedName, password); var authenticator = new Authenticator(ConnectionInfo, ConnectionFactory); - var authenticationResult = await authenticator.AuthenticateAsync(credential, requestLabel); + var authenticationResult = await authenticator.AuthenticateAsync(postValidationCredential, requestLabel); if (authenticationResult.IsSuccessfulOperation) { @@ -250,7 +255,7 @@ LDAPPasswordUpdateResult createSuccessfulResult(string label, string name) { /// Distinguished name of the username /// Optional tag to mark the request and/or response. /// - public async Task DisableUserAccountForMsAD(EntryAttribute identifierAttribute, string identifierValue, string requestLabel) + public async Task DisableMsADUserAccount(EntryAttribute identifierAttribute, string identifierValue, string requestLabel) { try { @@ -292,7 +297,7 @@ public async Task DisableUserAccountForMs } catch (Exception ex) { - return new LDAPDisableUserAccountOperationResult($"Error trying to disable username with DN: {identifierValue}", ex, requestLabel); + return new LDAPDisableUserAccountOperationResult($"Error trying to disable user account with {identifierAttribute}: {identifierValue}", ex, requestLabel); } } @@ -302,7 +307,7 @@ public async Task DisableUserAccountForMs /// Distinguished name of the username /// Optional tag to mark the request and/or response. /// - public async Task RemoveUserAccountForMsAD(EntryAttribute identifierAttribute, string identifierValue, string requestLabel = null) + public async Task RemoveMsADUserAccount(EntryAttribute identifierAttribute, string identifierValue, string requestLabel = null) { try { @@ -336,7 +341,7 @@ public async Task RemoveUserAccountForMsAD(Entr } catch (Exception ex) { - return new LDAPRemoveMsADUserAccountResult($"Error trying to remove username with DN: {identifierValue}", ex, requestLabel); + return new LDAPRemoveMsADUserAccountResult($"Error trying to remove username with {identifierAttribute}: {identifierValue}", ex, requestLabel); } } @@ -356,10 +361,17 @@ private async Task verifyMsADEntryAccountAuthenticity(EntryAttribute var searchResult = await searcher.SearchEntriesAsync(searchFilterCombiner, RequiredEntryAttributes.Few, requestLabel); if (!searchResult.IsSuccessfulOperation) { - if (searchResult.HasErrorObject) - throw searchResult.ErrorObject; - else - throw new Exception(searchResult.OperationMessage); + if (searchResult.HasErrorObject) { + if (searchResult.ErrorObject is LdapException) { + var unwrappedLdapException = (LdapException)searchResult.ErrorObject; + + throw new LdapException(searchResult.OperationMessage, unwrappedLdapException.ResultCode, unwrappedLdapException.LdapErrorMessage, unwrappedLdapException); + } + else + throw new Exception(searchResult.OperationMessage, searchResult.ErrorObject); + } + else + throw new Exception(searchResult.OperationMessage); } if (searchResult.Entries.Count() == 0) diff --git a/src/Bitai.LDAPHelper/Authenticator.cs b/src/Bitai.LDAPHelper/Authenticator.cs index ca92058..5264e08 100644 --- a/src/Bitai.LDAPHelper/Authenticator.cs +++ b/src/Bitai.LDAPHelper/Authenticator.cs @@ -16,7 +16,7 @@ public Authenticator(ConnectionInfo connectionInfo, ILdapConnectionFactoryAdapte #region Public methods - public async Task AuthenticateAsync(DTO.LDAPDomainAccountCredential credential, SearchLimits searchLimits, LDAPDomainAccountCredential credentialForSearching, string requestLabel = null) { + public async Task AuthenticateAsync(LDAPDomainAccountCredential credential, SearchLimits searchLimits, LDAPDomainAccountCredential credentialForSearching, string requestLabel = null) { LDAPDomainAccountAuthenticationResult authenticationResult; try { @@ -64,7 +64,7 @@ public Authenticator(ConnectionInfo connectionInfo, ILdapConnectionFactoryAdapte authenticated = false; } - authenticationResult = new DTO.LDAPDomainAccountAuthenticationResult(credential.SecureClone(), authenticated.Value, requestLabel); + authenticationResult = new LDAPDomainAccountAuthenticationResult(credential.SecureClone(), authenticated.Value, requestLabel); if (authenticated.Value) authenticationResult.SetSuccessfulOperation($"The domain username {credential.DomainName}\\{credential.AccountName} has been successfully authenticated."); else @@ -73,7 +73,7 @@ public Authenticator(ConnectionInfo connectionInfo, ILdapConnectionFactoryAdapte return authenticationResult; } catch (Exception ex) { - return new DTO.LDAPDomainAccountAuthenticationResult(credential.SecureClone(), $"Failed to authenticate {credential.DomainAccountName}", ex, requestLabel); + return new LDAPDomainAccountAuthenticationResult(credential.SecureClone(), $"Failed to authenticate {credential.DomainAccountName}", ex, requestLabel); } } @@ -82,7 +82,7 @@ public Authenticator(ConnectionInfo connectionInfo, ILdapConnectionFactoryAdapte /// /// to connect and authenticate on the LDAP Server. /// True or false, if authenticated or no. - public async Task AuthenticateAsync(DTO.LDAPDomainAccountCredential credential, string requestLabel = null) { + public async Task AuthenticateAsync(LDAPDomainAccountCredential credential, string requestLabel = null) { try { bool? authenticated; using (var connection = await GetLdapConnection(this.ConnectionInfo, credential, false)) { @@ -92,7 +92,7 @@ public Authenticator(ConnectionInfo connectionInfo, ILdapConnectionFactoryAdapte authenticated = false; } - var result = new DTO.LDAPDomainAccountAuthenticationResult(credential.SecureClone(), authenticated.Value, requestLabel); + var result = new LDAPDomainAccountAuthenticationResult(credential.SecureClone(), authenticated.Value, requestLabel); if (authenticated.Value) result.SetSuccessfulOperation($"The domain username {credential.DomainAccountName} has been successfully authenticated."); @@ -102,11 +102,11 @@ public Authenticator(ConnectionInfo connectionInfo, ILdapConnectionFactoryAdapte return result; } catch (Exception ex) { - return new DTO.LDAPDomainAccountAuthenticationResult(credential.SecureClone(), $"Failed to authenticate {credential.DomainAccountName}", ex, requestLabel); + return new LDAPDomainAccountAuthenticationResult(credential.SecureClone(), $"Failed to authenticate {credential.DomainAccountName}", ex, requestLabel); } } - public async Task AuthenticateAsync(DTO.LDAPDistinguishedNameCredential credential, SearchLimits searchLimits, LDAPDomainAccountCredential credentialForSearching, string requestLabel = null) { + public async Task AuthenticateAsync(LDAPDistinguishedNameCredential credential, SearchLimits searchLimits, LDAPDomainAccountCredential credentialForSearching, string requestLabel = null) { LDAPDistinguishedNameAuthenticationResult authenticationResult; try { @@ -152,7 +152,7 @@ public Authenticator(ConnectionInfo connectionInfo, ILdapConnectionFactoryAdapte authenticated = false; } - authenticationResult = new DTO.LDAPDistinguishedNameAuthenticationResult(credential.SecureClone(), authenticated.Value, requestLabel); + authenticationResult = new LDAPDistinguishedNameAuthenticationResult(credential.SecureClone(), authenticated.Value, requestLabel); if (authenticated.Value) authenticationResult.SetSuccessfulOperation($"The account with DN: {credential.DistinguishedName} has been successfully authenticated."); else @@ -161,11 +161,11 @@ public Authenticator(ConnectionInfo connectionInfo, ILdapConnectionFactoryAdapte return authenticationResult; } catch (Exception ex) { - return new DTO.LDAPDistinguishedNameAuthenticationResult(credential.SecureClone(), $"Failed to authenticate {credential.DistinguishedName}", ex, requestLabel); + return new LDAPDistinguishedNameAuthenticationResult(credential.SecureClone(), $"Failed to authenticate {credential.DistinguishedName}", ex, requestLabel); } } - public async Task AuthenticateAsync(DTO.LDAPDistinguishedNameCredential credential, string requestLabel = null) { + public async Task AuthenticateAsync(LDAPDistinguishedNameCredential credential, string requestLabel = null) { try { bool? authenticated; using (var connection = await GetLdapConnection(this.ConnectionInfo, credential, false)) { @@ -175,7 +175,7 @@ public Authenticator(ConnectionInfo connectionInfo, ILdapConnectionFactoryAdapte authenticated = false; } - var result = new DTO.LDAPDistinguishedNameAuthenticationResult(credential.SecureClone(), authenticated.Value, requestLabel); + var result = new LDAPDistinguishedNameAuthenticationResult(credential.SecureClone(), authenticated.Value, requestLabel); if (authenticated.Value) result.SetSuccessfulOperation($"The account with DN: {credential.DistinguishedName} has been successfully authenticated."); @@ -185,7 +185,7 @@ public Authenticator(ConnectionInfo connectionInfo, ILdapConnectionFactoryAdapte return result; } catch (Exception ex) { - return new DTO.LDAPDistinguishedNameAuthenticationResult(credential.SecureClone(), $"Failed to authenticate {credential.DistinguishedName}", ex, requestLabel); + return new LDAPDistinguishedNameAuthenticationResult(credential.SecureClone(), $"Failed to authenticate {credential.DistinguishedName}", ex, requestLabel); } } #endregion diff --git a/src/Bitai.LDAPHelper/BaseHelper.cs b/src/Bitai.LDAPHelper/BaseHelper.cs index 3a58b26..4374e62 100644 --- a/src/Bitai.LDAPHelper/BaseHelper.cs +++ b/src/Bitai.LDAPHelper/BaseHelper.cs @@ -1,8 +1,9 @@ -using Bitai.LDAPHelper.LdapAdapters; using System; using System.Collections.Generic; using System.Text; using System.Threading.Tasks; +using Bitai.LDAPHelper.Extensions; +using Bitai.LDAPHelper.LdapAdapters; namespace Bitai.LDAPHelper { @@ -195,7 +196,7 @@ protected IEnumerable GetRequiredAttributeNames(DTO.RequiredEntryAttribu default: throw new ArgumentOutOfRangeException("requiredEntryAttributes"); } - } - #endregion - } -} \ No newline at end of file + } + #endregion + } +} diff --git a/src/Bitai.LDAPHelper/Bitai.LDAPHelper.csproj b/src/Bitai.LDAPHelper/Bitai.LDAPHelper.csproj index a9e3595..d579dac 100644 --- a/src/Bitai.LDAPHelper/Bitai.LDAPHelper.csproj +++ b/src/Bitai.LDAPHelper/Bitai.LDAPHelper.csproj @@ -7,9 +7,9 @@ LDAP Services Wrappers Library to wrap Novell.Directory.Ldap.NETStandard functionality to make LDAP common queries to search accounts and objects in a Directory Service. © 2026 BITAI. All rights reserved. - 10.1.1 - 10.1.1 - 10.1.1 + 10.1.2 + 10.1.2 + 10.1.2 hierarchy_32.png true Bitai.LDAPHelper diff --git a/src/Bitai.LDAPHelper/DataValidationException.cs b/src/Bitai.LDAPHelper/DataValidationException.cs index 39b3147..fa68444 100644 --- a/src/Bitai.LDAPHelper/DataValidationException.cs +++ b/src/Bitai.LDAPHelper/DataValidationException.cs @@ -3,7 +3,7 @@ namespace Bitai.LDAPHelper { [Serializable] - internal class DataValidationException : Exception + public class DataValidationException : Exception { public DataValidationException() { diff --git a/src/Bitai.LDAPHelper/Searcher.cs b/src/Bitai.LDAPHelper/Searcher.cs index 4578d9d..107c590 100644 --- a/src/Bitai.LDAPHelper/Searcher.cs +++ b/src/Bitai.LDAPHelper/Searcher.cs @@ -22,185 +22,6 @@ public Searcher(ConnectionInfo connectionInfo, SearchLimits searchLimits, DTO.LD - #region Private methods - private async Task getEntryFromAttributeSet(ILdapAttributeSetAdapter attributeSet, DTO.RequiredEntryAttributes requiredEntryAttributes, string requestLabel) - { - var ldapEntry = new DTO.LDAPEntry(requestLabel); - - //Novell.Directory.Ldap.LdapAttribute attribute; - ILdapAttributeAdapter attribute; - byte[] bytes; - string tempValue; - - if (attributeSet.ContainsKey(DTO.EntryAttribute.objectSid.ToString())) - { - attribute = attributeSet.GetAttribute(DTO.EntryAttribute.objectSid.ToString()); - if (attribute != null) - { - bytes = (byte[])(Array)attribute.ByteValue; - ldapEntry.objectSidBytes = bytes; - ldapEntry.objectSid = ConvertByteToStringSid(bytes); - } - } - - if (attributeSet.ContainsKey(DTO.EntryAttribute.objectGuid.ToString())) - { - attribute = attributeSet.GetAttribute(DTO.EntryAttribute.objectGuid.ToString()); - if (attribute != null) - { - bytes = (byte[])(Array)attribute.ByteValue; - ldapEntry.objectGuidBytes = bytes; - ldapEntry.objectGuid = new Guid(bytes).ToString(); - } - } - - ldapEntry.objectCategory = attributeSet.ContainsKey(DTO.EntryAttribute.objectCategory.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.objectCategory.ToString()).StringValue : null; - - ldapEntry.objectClass = attributeSet.ContainsKey(DTO.EntryAttribute.objectClass.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.objectClass.ToString()).StringValueArray : null; - - ldapEntry.company = attributeSet.ContainsKey(DTO.EntryAttribute.company.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.company.ToString()).StringValue : null; - - ldapEntry.co = attributeSet.ContainsKey(DTO.EntryAttribute.co.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.co.ToString()).StringValue : null; - - ldapEntry.manager = attributeSet.ContainsKey(DTO.EntryAttribute.manager.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.manager.ToString()).StringValue : null; - - if (attributeSet.ContainsKey(DTO.EntryAttribute.whenCreated.ToString())) - { - attribute = attributeSet.GetAttribute(DTO.EntryAttribute.whenCreated.ToString()); - if (attribute == null) - ldapEntry.whenCreated = null; - else - ldapEntry.whenCreated = DateTime.ParseExact(attribute.StringValue, "yyyyMMddHHmmss.0Z", System.Globalization.CultureInfo.InvariantCulture); - } - - if (attributeSet.ContainsKey(DTO.EntryAttribute.lastLogonTimestamp.ToString())) - { - attribute = attributeSet.GetAttribute(DTO.EntryAttribute.lastLogonTimestamp.ToString()); - ldapEntry.lastLogon = (attribute == null) ? null : new DateTime?(DateTime.FromFileTime(Convert.ToInt64(attribute.StringValue))); - } - - ldapEntry.department = attributeSet.ContainsKey(DTO.EntryAttribute.department.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.department.ToString()).StringValue : null; - - ldapEntry.cn = attributeSet.ContainsKey(DTO.EntryAttribute.cn.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.cn.ToString()).StringValue : null; - - ldapEntry.name = attributeSet.ContainsKey(DTO.EntryAttribute.name.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.name.ToString()).StringValue : null; - - ldapEntry.samAccountName = attributeSet.ContainsKey(DTO.EntryAttribute.sAMAccountName.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.sAMAccountName.ToString()).StringValue : null; - - ldapEntry.userPrincipalName = attributeSet.ContainsKey(DTO.EntryAttribute.userPrincipalName.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.userPrincipalName.ToString()).StringValue : null; - - ldapEntry.distinguishedName = attributeSet.ContainsKey(DTO.EntryAttribute.distinguishedName.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.distinguishedName.ToString()).StringValue : null; - - ldapEntry.displayName = attributeSet.ContainsKey(DTO.EntryAttribute.displayName.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.displayName.ToString()).StringValue : null; - - ldapEntry.givenName = attributeSet.ContainsKey(DTO.EntryAttribute.givenName.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.givenName.ToString()).StringValue : null; - - ldapEntry.sn = attributeSet.ContainsKey(DTO.EntryAttribute.sn.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.sn.ToString()).StringValue : null; - - ldapEntry.description = attributeSet.ContainsKey(DTO.EntryAttribute.description.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.description.ToString()).StringValue : null; - - ldapEntry.telephoneNumber = attributeSet.ContainsKey(DTO.EntryAttribute.telephoneNumber.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.telephoneNumber.ToString()).StringValue : null; - - ldapEntry.mail = attributeSet.ContainsKey(DTO.EntryAttribute.mail.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.mail.ToString()).StringValue : null; - - ldapEntry.title = attributeSet.ContainsKey(DTO.EntryAttribute.title.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.title.ToString()).StringValue : null; - - ldapEntry.l = attributeSet.ContainsKey(DTO.EntryAttribute.l.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.l.ToString()).StringValue : null; - - ldapEntry.c = attributeSet.ContainsKey(DTO.EntryAttribute.c.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.c.ToString()).StringValue : null; - - tempValue = attributeSet.ContainsKey(DTO.EntryAttribute.sAMAccountType.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.sAMAccountType.ToString()).StringValue : null; - ldapEntry.samAccountType = GetSAMAccountTypeName(tempValue); - - if (attributeSet.ContainsKey(DTO.EntryAttribute.member.ToString())) - { - ldapEntry.member = attributeSet.GetAttribute(DTO.EntryAttribute.member.ToString()).StringValueArray; - } - - if (attributeSet.ContainsKey(DTO.EntryAttribute.memberOf.ToString())) - { - ldapEntry.memberOf = attributeSet.GetAttribute(DTO.EntryAttribute.memberOf.ToString()).StringValueArray; - } - - /// Load parent entries (groups/containers) if memberOf is requested and available. This allows callers to have the full parent objects with their attributes instead of just the distinguished names. - /// IMPORTANT: Note that this will perform additional LDAP searches for each parent entry, so it may impact performance if there are many parent entries or if the LDAP server is slow. Callers should consider this when requesting memberOf and the expected number of parent entries. - if (ldapEntry.memberOf != null && ldapEntry.memberOf.Length > 0) - { - var parentEntries = new List(); - - foreach (var parentDN in ldapEntry.memberOf) - { - // Avoid circular reference. In some cases, an entry could be member of a group that is itself member of the entry (this is not common but possible), so we can end in a loop if we try to get the parent entry in that case. To avoid this, we check if the parent DN is the same that the entry DN, and if it is, we skip it. - if (ldapEntry.distinguishedName.Equals(parentDN, StringComparison.OrdinalIgnoreCase)) - continue; //Pasar al siguiente objeto. - - var filter = new QueryFilters.AttributeFilter(DTO.EntryAttribute.distinguishedName, new QueryFilters.FilterValue(parentDN.ReplaceSpecialCharsToScapedChars())); - - var searchResult = await this.SearchEntriesAsync(filter, requiredEntryAttributes, requestLabel); - - //Parent DN could be out of Base DN - if (searchResult.Entries.Count() > 0) - parentEntries.Add(searchResult.Entries.First()); - } - - ldapEntry.memberOfEntries = parentEntries.ToArray(); - } - - if (attributeSet.ContainsKey(DTO.EntryAttribute.userAccountControl.ToString())) { - ldapEntry.userAccountControl = attributeSet.GetAttribute(DTO.EntryAttribute.userAccountControl.ToString()).StringValue; - } - - return ldapEntry; - } - - private async Task getSearchResultAsync(DTO.RequiredEntryAttributes requiredEntryAttributes, string searchFilter, string requestLabel) - { - try { - var attributesToLoad = this.GetRequiredAttributeNames(requiredEntryAttributes); - var entries = new List(); - using (var connection = await GetLdapConnection(this.ConnectionInfo, this.DomainAccountCredential)) { - - ILdapSearchQueueAdapter searchQueue = await connection.SearchAsync(this.SearchLimits, searchFilter, attributesToLoad.ToArray(), false); - - ILdapMessageAdapter responseMessage = null; - while ((responseMessage = searchQueue.GetResponse()) != null) { - //if (responseMessage is Novell.Directory.Ldap.LdapSearchResult) { - if (responseMessage.IsSearchResult && responseMessage.Entry != null) { - var _ldapEntry = await getEntryFromAttributeSet(responseMessage.Entry.GetAttributeSet(), requiredEntryAttributes, requestLabel); - - entries.Add(_ldapEntry); - } - } - - connection.Disconnect(); - } - - return new DTO.LDAPSearchResult(requestLabel, entries, $"The search returned {entries.Count} entries."); - } - catch (LdapException ex) - { - string msg = string.IsNullOrEmpty(ex.LdapErrorMessage) ? ex.Message : (string.IsNullOrEmpty(ex.Message) ? ex.LdapErrorMessage : $"{ex.Message} ({ex.LdapErrorMessage})"); - var searchResult = new DTO.LDAPSearchResult(msg, ex, requestLabel); - - return searchResult; - } - //// BITAI: Remain for future reference if we want to avoid direct dependency on Novell.Directory.Ldap in this class. The LdapException type is specific to the Novell library, so if we want to keep this class decoupled from that library, we can catch general Exception and check the type name as done in other parts of the code. However, if we are okay with referencing Novell.Directory.Ldap directly, catching LdapException is more straightforward and type-safe. - //catch (Exception ex) when (ex.GetType().Name == "LdapException") { - // var ldapErrorMessageProp = ex.GetType().GetProperty("LdapErrorMessage"); - // string ldapErrorMessage = ldapErrorMessageProp?.GetValue(ex) as string ?? ""; - // string msg = string.IsNullOrEmpty(ldapErrorMessage) ? ex.Message : $"{ex.Message} ({ldapErrorMessage})"; - // searchResult = new DTO.LDAPSearchResult(msg, ex, requestLabel); - // return searchResult; - //} - catch (Exception ex) { - var searchResult = new DTO.LDAPSearchResult($"Unexpected error encountered while performing search.", ex, requestLabel); - - return searchResult; - } - } - #endregion - - #region Public methods /// @@ -223,11 +44,58 @@ public Searcher(ConnectionInfo connectionInfo, SearchLimits searchLimits, DTO.LD /// any operation message. If an error occurs, the returned LDAPSearchResult will have /// IsSuccessfulOperation == false and contain error details. /// - public Task SearchEntriesAsync(QueryFilters.ICombinableFilter searchFilter, DTO.RequiredEntryAttributes requiredEntryAttributes, string requestLabel) + public async Task SearchEntriesAsync(QueryFilters.ICombinableFilter searchFilterObject, DTO.RequiredEntryAttributes requiredEntryAttributes, string requestLabel) { - return getSearchResultAsync(requiredEntryAttributes, searchFilter.ToString(), requestLabel); - } + try + { + string searchFilter = searchFilterObject.ToString(); + + var attributesToLoad = this.GetRequiredAttributeNames(requiredEntryAttributes); + var entries = new List(); + using (var connection = await GetLdapConnection(this.ConnectionInfo, this.DomainAccountCredential)) + { + ILdapSearchQueueAdapter searchQueue = await connection.SearchAsync(this.SearchLimits, searchFilter, attributesToLoad.ToArray(), false); + + ILdapMessageAdapter responseMessage = null; + while ((responseMessage = searchQueue.GetResponse()) != null) + { + //if (responseMessage is Novell.Directory.Ldap.LdapSearchResult) { + if (responseMessage.IsSearchResult && responseMessage.Entry != null) + { + var _ldapEntry = await GetEntryFromAttributeSet(responseMessage.Entry.GetAttributeSet(), requiredEntryAttributes, requestLabel); + + entries.Add(_ldapEntry); + } + } + + connection.Disconnect(); + } + + return new DTO.LDAPSearchResult(requestLabel, entries, $"The search returned {entries.Count} entries."); + } + catch (LdapException ex) + { + string msg = string.IsNullOrEmpty(ex.LdapErrorMessage) ? ex.Message : (string.IsNullOrEmpty(ex.Message) ? ex.LdapErrorMessage : $"{ex.Message} ({ex.LdapErrorMessage})"); + var searchResult = new DTO.LDAPSearchResult(msg, ex, requestLabel); + + return searchResult; + } + //// BITAI: Remain for future reference if we want to avoid direct dependency on Novell.Directory.Ldap in this class. The LdapException type is specific to the Novell library, so if we want to keep this class decoupled from that library, we can catch general Exception and check the type name as done in other parts of the code. However, if we are okay with referencing Novell.Directory.Ldap directly, catching LdapException is more straightforward and type-safe. + //catch (Exception ex) when (ex.GetType().Name == "LdapException") { + // var ldapErrorMessageProp = ex.GetType().GetProperty("LdapErrorMessage"); + // string ldapErrorMessage = ldapErrorMessageProp?.GetValue(ex) as string ?? ""; + // string msg = string.IsNullOrEmpty(ldapErrorMessage) ? ex.Message : $"{ex.Message} ({ldapErrorMessage})"; + // searchResult = new DTO.LDAPSearchResult(msg, ex, requestLabel); + // return searchResult; + //} + catch (Exception ex) + { + var searchResult = new DTO.LDAPSearchResult($"Unexpected error encountered while performing search.", ex, requestLabel); + + return searchResult; + } + } /// /// Searches for parent entries (groups or containers) for the entries matched by the provided filter, @@ -266,7 +134,7 @@ public Searcher(ConnectionInfo connectionInfo, SearchLimits searchLimits, DTO.LD if (!partialSearchResult.IsSuccessfulOperation) { return partialSearchResult; } - else if (partialSearchResult.Entries.Count() == 0) { + else if (!partialSearchResult.Entries.Any()) { throw new EntryNotFoundException("Unable to evaluate without an entry."); } @@ -313,6 +181,142 @@ public Searcher(ConnectionInfo connectionInfo, SearchLimits searchLimits, DTO.LD return searchResult; } } - #endregion - } + #endregion + + + + + #region Protected methods + protected async Task GetEntryFromAttributeSet(ILdapAttributeSetAdapter attributeSet, DTO.RequiredEntryAttributes requiredEntryAttributes, string requestLabel) + { + var ldapEntry = new DTO.LDAPEntry(requestLabel); + + //Novell.Directory.Ldap.LdapAttribute attribute; + ILdapAttributeAdapter attribute; + byte[] bytes; + string tempValue; + + if (attributeSet.ContainsKey(DTO.EntryAttribute.objectSid.ToString())) + { + attribute = attributeSet.GetAttribute(DTO.EntryAttribute.objectSid.ToString()); + if (attribute != null) + { + bytes = (byte[])(Array)attribute.ByteValue; + ldapEntry.objectSidBytes = bytes; + ldapEntry.objectSid = ConvertByteToStringSid(bytes); + } + } + + if (attributeSet.ContainsKey(DTO.EntryAttribute.objectGuid.ToString())) + { + attribute = attributeSet.GetAttribute(DTO.EntryAttribute.objectGuid.ToString()); + if (attribute != null) + { + bytes = (byte[])(Array)attribute.ByteValue; + ldapEntry.objectGuidBytes = bytes; + ldapEntry.objectGuid = new Guid(bytes).ToString(); + } + } + + ldapEntry.objectCategory = attributeSet.ContainsKey(DTO.EntryAttribute.objectCategory.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.objectCategory.ToString()).StringValue : null; + + ldapEntry.objectClass = attributeSet.ContainsKey(DTO.EntryAttribute.objectClass.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.objectClass.ToString()).StringValueArray : null; + + ldapEntry.company = attributeSet.ContainsKey(DTO.EntryAttribute.company.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.company.ToString()).StringValue : null; + + ldapEntry.co = attributeSet.ContainsKey(DTO.EntryAttribute.co.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.co.ToString()).StringValue : null; + + ldapEntry.manager = attributeSet.ContainsKey(DTO.EntryAttribute.manager.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.manager.ToString()).StringValue : null; + + if (attributeSet.ContainsKey(DTO.EntryAttribute.whenCreated.ToString())) + { + attribute = attributeSet.GetAttribute(DTO.EntryAttribute.whenCreated.ToString()); + if (attribute == null) + ldapEntry.whenCreated = null; + else + ldapEntry.whenCreated = DateTime.ParseExact(attribute.StringValue, "yyyyMMddHHmmss.0Z", System.Globalization.CultureInfo.InvariantCulture); + } + + if (attributeSet.ContainsKey(DTO.EntryAttribute.lastLogonTimestamp.ToString())) + { + attribute = attributeSet.GetAttribute(DTO.EntryAttribute.lastLogonTimestamp.ToString()); + ldapEntry.lastLogon = (attribute == null) ? null : new DateTime?(DateTime.FromFileTime(Convert.ToInt64(attribute.StringValue))); + } + + ldapEntry.department = attributeSet.ContainsKey(DTO.EntryAttribute.department.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.department.ToString()).StringValue : null; + + ldapEntry.cn = attributeSet.ContainsKey(DTO.EntryAttribute.cn.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.cn.ToString()).StringValue : null; + + ldapEntry.name = attributeSet.ContainsKey(DTO.EntryAttribute.name.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.name.ToString()).StringValue : null; + + ldapEntry.samAccountName = attributeSet.ContainsKey(DTO.EntryAttribute.sAMAccountName.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.sAMAccountName.ToString()).StringValue : null; + + ldapEntry.userPrincipalName = attributeSet.ContainsKey(DTO.EntryAttribute.userPrincipalName.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.userPrincipalName.ToString()).StringValue : null; + + ldapEntry.distinguishedName = attributeSet.ContainsKey(DTO.EntryAttribute.distinguishedName.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.distinguishedName.ToString()).StringValue : null; + + ldapEntry.displayName = attributeSet.ContainsKey(DTO.EntryAttribute.displayName.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.displayName.ToString()).StringValue : null; + + ldapEntry.givenName = attributeSet.ContainsKey(DTO.EntryAttribute.givenName.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.givenName.ToString()).StringValue : null; + + ldapEntry.sn = attributeSet.ContainsKey(DTO.EntryAttribute.sn.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.sn.ToString()).StringValue : null; + + ldapEntry.description = attributeSet.ContainsKey(DTO.EntryAttribute.description.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.description.ToString()).StringValue : null; + + ldapEntry.telephoneNumber = attributeSet.ContainsKey(DTO.EntryAttribute.telephoneNumber.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.telephoneNumber.ToString()).StringValue : null; + + ldapEntry.mail = attributeSet.ContainsKey(DTO.EntryAttribute.mail.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.mail.ToString()).StringValue : null; + + ldapEntry.title = attributeSet.ContainsKey(DTO.EntryAttribute.title.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.title.ToString()).StringValue : null; + + ldapEntry.l = attributeSet.ContainsKey(DTO.EntryAttribute.l.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.l.ToString()).StringValue : null; + + ldapEntry.c = attributeSet.ContainsKey(DTO.EntryAttribute.c.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.c.ToString()).StringValue : null; + + tempValue = attributeSet.ContainsKey(DTO.EntryAttribute.sAMAccountType.ToString()) ? attributeSet.GetAttribute(DTO.EntryAttribute.sAMAccountType.ToString()).StringValue : null; + ldapEntry.samAccountType = GetSAMAccountTypeName(tempValue); + + if (attributeSet.ContainsKey(DTO.EntryAttribute.member.ToString())) + { + ldapEntry.member = attributeSet.GetAttribute(DTO.EntryAttribute.member.ToString()).StringValueArray; + } + + if (attributeSet.ContainsKey(DTO.EntryAttribute.memberOf.ToString())) + { + ldapEntry.memberOf = attributeSet.GetAttribute(DTO.EntryAttribute.memberOf.ToString()).StringValueArray; + } + + /// Load parent entries (groups/containers) if memberOf is requested and available. This allows callers to have the full parent objects with their attributes instead of just the distinguished names. + /// IMPORTANT: Note that this will perform additional LDAP searches for each parent entry, so it may impact performance if there are many parent entries or if the LDAP server is slow. Callers should consider this when requesting memberOf and the expected number of parent entries. + if (ldapEntry.memberOf != null && ldapEntry.memberOf.Length > 0) + { + var parentEntries = new List(); + + foreach (var parentDN in ldapEntry.memberOf) + { + // Avoid circular reference. In some cases, an entry could be member of a group that is itself member of the entry (this is not common but possible), so we can end in a loop if we try to get the parent entry in that case. To avoid this, we check if the parent DN is the same that the entry DN, and if it is, we skip it. + if (ldapEntry.distinguishedName.Equals(parentDN, StringComparison.OrdinalIgnoreCase)) + continue; //Pasar al siguiente objeto. + + var filter = new QueryFilters.AttributeFilter(DTO.EntryAttribute.distinguishedName, new QueryFilters.FilterValue(parentDN.ReplaceSpecialCharsToScapedChars())); + + var searchResult = await this.SearchEntriesAsync(filter, requiredEntryAttributes, requestLabel); + + //Parent DN could be out of Base DN + if (searchResult.Entries.Count() > 0) + parentEntries.Add(searchResult.Entries.First()); + } + + ldapEntry.memberOfEntries = parentEntries.ToArray(); + } + + if (attributeSet.ContainsKey(DTO.EntryAttribute.userAccountControl.ToString())) + { + ldapEntry.userAccountControl = attributeSet.GetAttribute(DTO.EntryAttribute.userAccountControl.ToString()).StringValue; + } + + return ldapEntry; + } + #endregion + } } diff --git a/tests/Bitai.LDAPHelper.Tests/AccountManagerAdapterTests.cs b/tests/Bitai.LDAPHelper.Tests/AccountManagerAdapterTests.cs index 251dd78..442faca 100644 --- a/tests/Bitai.LDAPHelper.Tests/AccountManagerAdapterTests.cs +++ b/tests/Bitai.LDAPHelper.Tests/AccountManagerAdapterTests.cs @@ -96,9 +96,7 @@ public async Task SetUserAccountPasswordForMsAD_ValidAccount_ReturnsSuccess() { mockConnection.AddSearchResult(groupSearchFilter2.ToString(), new List { mockGroupEntry2 }); mockConnection.AddSearchResult(userSearchFilter.ToString(), new List { mockUserEntry }); - var userCredential = new LDAPDistinguishedNameCredential(mockUserEntry.DistinguishedName, "NewP@ssw0rd"); - - var result = await accountManager.SetUserAccountPasswordForMsAD(userCredential, "TestPassword", postUpdateTestAuthentication: true); + var result = await accountManager.SetMsADUserAccountPassword(EntryAttribute.distinguishedName, mockUserEntry.DistinguishedName, "TestPassword", postUpdateTestAuthentication: true); // Assert Assert.True(result.IsSuccessfulOperation); @@ -133,7 +131,7 @@ public async Task SetUserAccountPasswordForMsAD_AccountNotFound_ReturnsFailed() var userCredential = new LDAPDistinguishedNameCredential(mockUserEntry.DistinguishedName, "NewP@ssw0rd"); - var result = await accountManager.SetUserAccountPasswordForMsAD(userCredential, "TestPassword", postUpdateTestAuthentication: true); + var result = await accountManager.SetMsADUserAccountPassword(EntryAttribute.distinguishedName, mockUserEntry.DistinguishedName, "TestPassword", postUpdateTestAuthentication: true); Assert.False(result.IsSuccessfulOperation); Assert.StartsWith("user account not found", result.OperationMessage, StringComparison.OrdinalIgnoreCase); @@ -163,7 +161,7 @@ public async Task DisableUserAccountForMsAD_ValidAccount_ReturnsSuccess() { mockConnection.AddSearchResult(groupSearchFilter2.ToString(), new List { mockGroupEntry2 }); mockConnection.AddSearchResult(userSearchFilter.ToString(), new List { mockUserEntry }); - var result = await accountManager.DisableUserAccountForMsAD(EntryAttribute.distinguishedName, mockUserEntry.DistinguishedName, "TestDisable"); + var result = await accountManager.DisableMsADUserAccount(EntryAttribute.distinguishedName, mockUserEntry.DistinguishedName, "TestDisable"); // Assert Assert.True(result.IsSuccessfulOperation); @@ -195,7 +193,7 @@ public async Task DisableUserAccountForMsAD_AccountNotFound_ReturnsSuccess() { //Do not add user account in order to trigger user not found validation. //mockConnection.AddSearchResult(userSearchFilter.ToString(), new List { mockUserEntry }); - var result = await accountManager.DisableUserAccountForMsAD(EntryAttribute.distinguishedName, mockUserEntry.DistinguishedName, "TestDisable"); + var result = await accountManager.DisableMsADUserAccount(EntryAttribute.distinguishedName, mockUserEntry.DistinguishedName, "TestDisable"); // Assert Assert.False(result.IsSuccessfulOperation); @@ -226,7 +224,7 @@ public async Task RemoveUserAccountForMsAD_ValidAccount_ReturnsSuccess() { var accountManager = new AccountManager(connectionInfo, searchLimits, credential, mockConnectionFactory); - var result = await accountManager.RemoveUserAccountForMsAD(EntryAttribute.distinguishedName, mockUserEntry.DistinguishedName, "TestDelete"); + var result = await accountManager.RemoveMsADUserAccount(EntryAttribute.distinguishedName, mockUserEntry.DistinguishedName, "TestDelete"); Assert.True(result.IsSuccessfulOperation); Assert.Contains("successfully removed", result.OperationMessage.ToLower()); @@ -257,7 +255,7 @@ public async Task RemoveUserAccountForMsAD_AccountNotFound_ReturnsSuccess() { var accountManager = new AccountManager(connectionInfo, searchLimits, credential, mockConnectionFactory); - var result = await accountManager.RemoveUserAccountForMsAD(EntryAttribute.distinguishedName, mockUserEntry.DistinguishedName, "TestDelete"); + var result = await accountManager.RemoveMsADUserAccount(EntryAttribute.distinguishedName, mockUserEntry.DistinguishedName, "TestDelete"); Assert.False(result.IsSuccessfulOperation); Assert.Contains("user account not found", result.OperationMessage, StringComparison.OrdinalIgnoreCase); diff --git a/tests/Bitai.LDAPHelper.Tests/Bitai.LDAPHelper.Tests.csproj b/tests/Bitai.LDAPHelper.Tests/Bitai.LDAPHelper.Tests.csproj index 6f92695..982a7f5 100644 --- a/tests/Bitai.LDAPHelper.Tests/Bitai.LDAPHelper.Tests.csproj +++ b/tests/Bitai.LDAPHelper.Tests/Bitai.LDAPHelper.Tests.csproj @@ -7,9 +7,9 @@ false true - 10.0.1 - 10.0.1 - 10.0.1 + 10.1.2 + 10.1.2 + 10.1.2 5981b6a0-6b9e-439d-8324-a0ef8bfd0f11