Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to efficiently obtain list of users from windows active directory using C#

I followed the solution from this question How can I get a list of users from active directory? and am able to get a list of users from the AD. The issue i am having is that it takes 35 seconds to load all of the records.

There must be a more efficient way to query all of the data in one go rather than having to wait 35 seconds for it to return 700+ records. I have written a method to return the list of users. I have put some additional code to try and filter out any users that aren't accounts for humans.

public List<ActiveUser> GetActiveDirectoryUsers()
{
    List<ActiveUser> response = new List<ActiveUser>();
    using (var context = new PrincipalContext(ContextType.Domain, "mydomain"))
    {
        using (var searcher = new PrincipalSearcher(new UserPrincipal(context)))
        {
            foreach (var result in searcher.FindAll())
            {
                DirectoryEntry de = result.GetUnderlyingObject() as DirectoryEntry;
                if (de.NativeGuid != null && !Convert.ToBoolean((int)de.Properties["userAccountControl"].Value & 0x0002) &&
                    de.Properties["department"].Value != null && de.Properties["sn"].Value != null) response.Add(new ActiveUser(de));
            }
        }
    }
    return response.OrderBy(x => x.DisplayName).ToList();
}

The constructor for the ActiveUser just takes entry.property["whataver"] and assigns it to a property of that class. The overhead seems to be on the line for

DirectoryEntry de = result.GetUnderlyingObject() as DirectoryEntry;

I could cache the list of users to a file, but its still too much to be dealing with over 30 seconds of loading for one list. There has to be a faster way to do it.

like image 719
Dan Hastings Avatar asked May 02 '17 09:05

Dan Hastings


2 Answers

I had a crack at this using a number of different approaches, as a learning experience.

What I found for myself was that all methods could list a set of adspath values pretty quickly, but once introducing a Console.WriteLine in the iteration caused the performance to drastically vary.

My limited C# knowledge led me to experiment with various methods such as IEnumerator straight over the DirectoryEntry, PrincipleSearcher with context, but both of these methods are slow, and vary greatly depending on what is being done with the information

In the end, this is what I ended up with. It was far and away the fastest, and doesn't take any noticeable performance hit when increasing the options to parse.

Note: this is actually a complete copy/paste powershell wrapper for the class, as I am not currently near a VM with Visual Studio.

$Source = @"
// " "  <-- this just makes the code highlighter work
// Syntax:  [soexample.search]::Get("LDAP Path", "property1", "property2", "etc...")
// Example: [soexample.search]::Get("LDAP://CN=Users,DC=mydomain,DC=com","givenname","sn","samaccountname","distinguishedname")

namespace soexample
{
    using System;
    using System.DirectoryServices;

    public static class search
    {
        public static string Get(string ldapPath, params string[] propertiesToLoad)
        {
            DirectoryEntry entry = new DirectoryEntry(ldapPath);
            DirectorySearcher searcher = new DirectorySearcher(entry);
            searcher.SearchScope = SearchScope.OneLevel;
            foreach (string p in propertiesToLoad) { searcher.PropertiesToLoad.Add(p); }
            searcher.PageSize = 100;
            searcher.SearchRoot = entry;
            searcher.CacheResults = true;
            searcher.Filter = "(sAMAccountType=805306368)";
            SearchResultCollection results = searcher.FindAll();

            foreach (SearchResult result in results)
            {
                foreach (string propertyName in propertiesToLoad)
                {
                    foreach (object propertyValue in result.Properties[propertyName])
                    {
                        Console.WriteLine(string.Format("{0} : {1}", propertyName, propertyValue));
                    }
                }
                Console.WriteLine("");

            }
            return "";
        }
    }
}
"@
$Asem = ('System.DirectoryServices','System')
Add-Type -TypeDefinition $Source -Language CSharp -ReferencedAssemblies $Asem

I ran this on a particular domain that had 160 users, and here is the outcome;

Using the example command in the code comments:

PS > Measure-Command { [soexample.search]::Get(args as above..) }

Output:

givenname : John
sn : Surname
samaccountname : john.surname
distinguishedname : CN=John Surname,CN=Users,DC=mydomain,DC=com 

etc ... 159 more ...

Days              : 0
Hours             : 0
Minutes           : 0
Seconds           : 0
Milliseconds      : 431
Ticks             : 4317575
TotalDays         : 4.99719328703704E-06
TotalHours        : 0.000119932638888889
TotalMinutes      : 0.00719595833333333
TotalSeconds      : 0.4317575
TotalMilliseconds : 431.7575 

Every additional string argument given, seems to increase the total processing time by about 100ms.

Running it with only samaccountname takes only 0.1s to list 160 users, parsed into the console.

Using Microsoft's example here, and modifying it to just list one property, took over 3 seconds, and every additional property took about a second.

A couple notes:

  • (sAMAccountType=805306368) turns out to be more efficient than (&(objectClass=user)(objectCategory=person)) (see https://stackoverflow.com/a/10053397/3544399) and many other examples

  • searcher.CacheResults = true; didn't seem to make any difference (in my domain anyway) whether it was true or explicitly false.

  • searcher.PageSize = 100; makes a measurable difference. I believe the default MaxPageSize on a 2012R2 DC is 1000 (https://technet.microsoft.com/en-us/library/cc770976(v=ws.11).aspx)

  • The properties are not case sensitive (i.e. whatever is given to the searcher is returned in result.Properties.PropertyNames, hence why the foreach loop simply iterates those propertiesToLoad)

  • The three foreach loops at first glance seem un-necessary, but every successful removal of a loop ended up costing much more overhead in cast conversions and running through method extensions.

There may be better ways still, I've seen some elaborate examples with threading and result caching that I just wouldn't know what to do with, but the tuned DirectorySearcher does seem to be the most flexible, and this code here only requires System and System.DirectoryServices namespaces.

Not sure exactly what you do with your "//do stuff" as to whether this would help or not, but I did find this an interesting exercise as I didn't know there were so many ways to do something like this.

like image 106
hmedia1 Avatar answered Sep 18 '22 14:09

hmedia1


To give an update, i have found a partial work around. It is faster than the method above, but it is missing some additional data. From what ive read, the method in the question is the equivalent of doing something like

SELECT id FROM sometable
foreach row in table
SELECT * FROM sometable where id = ?

So it's clear to see why it is slow. The following method executes in under a second and gives me all of the properties i need. A separate call to the directory entry needs to be made in order to obtain that data, but this is quite easy to achieve as it is possible to grab just one user if you provide some search params.

Here is an updated method that is more efficient.

DirectoryEntry de = new DirectoryEntry("ldap://mydomain");
using (DirectorySearcher search = new DirectorySearcher())
{
    search.Filter = "(&(objectClass=user)(objectCategory=person))";
    search.PropertiesToLoad.Add("userAccountControl");
    search.PropertiesToLoad.Add("sn");
    search.PropertiesToLoad.Add("department");
    search.PropertiesToLoad.Add("l");
    search.PropertiesToLoad.Add("title");
    search.PropertiesToLoad.Add("givenname");
    search.PropertiesToLoad.Add("co");
    search.PropertiesToLoad.Add("displayName");
    search.PropertiesToLoad.Add("distinguishedName");
    foreach (SearchResult searchrecord in search.FindAll())
    {
        //do stuff
    }
}
like image 40
Dan Hastings Avatar answered Sep 20 '22 14:09

Dan Hastings