Searching Active Directory for Users and Groups
by Jon Wojtowicz

A common activity in a corporate environment is determining if a user is in a certain group. Searching for users' attributes such as name and email are also very common. These searches involve querying the domain controllers using LDAP. Typically this amount to having multiple copies of the code sprinkled in many differing projects. By tying these searches into a common library a single code base needs to be maintained and the complexities of the queries can be hidden.
The scope of this discussion is on searching only since it is the most common activity. You can certainly use the DirectoryServices classes to manipulate Active Directory objects as well.


Directory Services Basics
The classes in the System.DirectoryServices namespace are used in querying Active Directory. The primary classes needed are the DirectoryEntry, SearchResult and DirectorySearcher.
The DirectoryEntry class contains the specific data about an object. This class is used to actually bind to the underlying ADSI object. Since this is a bound object it is also dynamic. This means the data retrieved from a DirectoryEntry will be current with the data on the domain controller. This also means that retrieving any information from the DirecotryEntry will require additional network traffic.
The DirectorySearcher is the main search object. It performs a search based on filter criteria. It retrieves a SearchResultCollection from the search.
The SearchResult is the cached object returned from a search using the DirectorySearcher. Since the data is retrieved and cached locally no additional network trip are necessary when retrieving the data. This also means the data can become out of date if the object is held for any period of time. The SearchResult also allows for obtaining the underlying DirectoryEntry for a given object.
Since most of these objects are simply wrappers for COM-based ADSI objects, it is important to call Dispose on the objects if they implement IDisposable.
Creating the DirectorySearcher
The code to create the DirectorySearcher is as follows:
public void PopulateSearchResults( string filter )
{
using(DirectoryEntry root = new DirectoryEntry(ldapPath))
{
using(DirectorySearcher searcher=new DirectorySearcher(root))
{
searcher.ReferralChasing = ReferralChasingOption.All;
searcher.SearchScope = SearchScope.Subtree;
searcher.Filter = filter;
results = searcher.FindAll();
}
}
}
The root DirectoryEntry points to the start object the search. Typically the root is domain controller. The format for the LDAP path is LDAP://HostName[:PortNumber][/DistinguishedName] with a typical example looking like LDAP:\\domain.fabrikam.com\dc=domain, dc=fabricam, dc=com. The DirectorySearcher is built on the root entry object. This sets up the search path.
To ensure that the all descendant objects are searched we set the search scope to be the entire sub tree. By default it is the base object only. The referral chasing is also set to all to follow referrals across domain controllers.
The filter is also set and the FindAll is called on the DirectorySearcher. This results in a SearchResultCollection of the matching objects.
There is also a FindOne method which returns the DirectoryEntry of the first SearchResult. This method has a memory leak when no results are returned and this method should be avoided (I do not know if this issue has been fixed in .Net 2.0).
Search Filters
When searching for specific objects a filter is used to narrow the returned results. The default filter is (objectClass=*) which returns all objects. LDAP filters format has the following restrictions:
  • The parts of the expressions must be in parenthesis.
  • Expressions can use the relational operators: <, <=, =, >=, and >. An example would be (lastName=Smith)
  • Compound expressions are formed with the prefix operators & and |. An example of this would be (&(objectClass=user)(|(lastName=Smith)(lastName=Jones))). This expression is read where the objectClass is user and last name is Smith or Jones.
The search filter for finding users in the samples code is as follows:
Finding a user:
"(&(objectCategory=user)(objectClass=person)(sAMAccountName=" + userId + "))" where the userId id the logon id of the user.
Finding a group:
"(&(objectCategory=group)(sAMAccountName=" + groupName + "))" where the group name is the name of the group.
Searching for users:
"(&(objectCategory=user)(objectClass=person)(mail=*)(sn=" + lastName.Trim() + "*)(!userAccountControl:1.2.840.113556.1.4.803:=2))" for a search based on a partial last name only
"(&(objectCategory=user)(objectClass=person)(mail=*)(sn=" + lastName.Trim() + "*)(!userAccountControl:1.2.840.113556.1.4.803:=2)(givenName=" + firstName.Trim() + "*))" for searching on a partial first and last name.
The user search filters will only return users that have an email address and that are active. Disabled accounts are filtered out by the expression (!userAccountControl:1.2.840.113556.1.4.803:=2) and the email filter is (mail=*).
Marshalling the Results
If using the DirectoryServices classes directly from the client application, the data can be retrieved as needed. Since these classes deal with unmanaged resources it is best to release them as soon as possible. Since the requirements were for using this through a web service the data had to be wrapped in custom objects. The advantage to the custom objects is being able to safely cache them on the server or client without worrying about the unmanaged resources.
The basics of this design were to extract all the text properties into a collection and target specific properties that were commonly needed, such as email address. Also the group membership for a user was handled as a separate case since they are returned as an array of strings. The following code was used to populate an internal collection:
public AdObject( SearchResult obj )
{
if( obj != null )
{
foreach( string propName in obj.Properties.PropertyNames)
{
// Only add properties that are non-array strings
if( obj.Properties[propName].Count == 1 && obj.Properties[propName][0] is string)
{
try
{
// Check to see if we have non-printable characters, This is due to Xml not handling
// non-printable characters as attribute values.
if( ! HasNonPrintCharacters(obj.Properties[propName][0].ToString()) )
{
properties.Add(new AdProperty( propName.ToLower(), obj.Properties[propName][0].ToString() ));
}
}
catch{}
}
}
ParseDistinguishedName(obj.Properties["distinguishedName"][0].ToString());
}
}
Since this had to be converted to XML, any property with a non-printable character was also ignored.
This was the base for both type of objects that were being extracted from Active Directory, User and Group.
The User had the additional requirement of getting the group membership. This was performed as an additional step by loading the TokenGroups for the user. By default, the MemberOf property contains the users' direct group membership. This can be used in a single domain environment but fails when the user has indirect membership in domain local groups, either in the same domain or a foreign domain. To force the retrieval of the indirect membership, the computed property Tokengroups must be used. This can be called with the following:
private static void GetUserGroups( AdHelper helper, AdUser user )
{
SearchResult result = helper[0];
using( DirectoryEntry entry = result.GetDirectoryEntry())
{
entry.RefreshCache(new string[]{"TokenGroups"});
PropertyValueCollection tg = entry.Properties["TokenGroups"];
foreach (byte[] SID in (Array)tg.Value)
{
AdGroup group = GetGroupInfo(SID, helper.LDAPPath);
if( ! user.MemberOf.Contains( group ) )
user.MemberOf.Add( group );
}
}
}
The TokenGroups property returns the security identifier(SID) for the group. The group can then be looked up using a separate Active Directory search or by using an API call, LookupAccountSid. The API call is much faster but has limitations when resolving the domain name, it only returns the NetBIOS name of the domain, not the distinguished name. This can cause issues when trying to compare whether the user is in a particular group.
The Group has the requirement of getting the members property. These are the direct members' distinguished name as strings. These can be users or groups but the code sample treats them all as groups.
Since checking group membership is the primary purpose of the Group collection, it is based on a sorted collection. This allows the collection to be searched using a fast binary search.
Your Mileage May Vary
The code sample provided was designed in a particular environment. The code may or may not work without modifications. It does not pass in a user name and password since it assumes the caller has permissions to query Active Directory for the properties needed.

Hopefully this will allow a quick start to providing the basic functionality needed for searching users and groups in Active Directory.

Download the Visual Studio 2003 Solution that accompanies this article

Jon Wojtowicz is a C# MVP and a Systems Analyst at a large insurance company in Chattanooga, TN where he currently provides developer support and internal training. He has worked as a consultant working with Microsoft Technologies. This includes ASP, COM, VB6 and .Net, both C# and VB.Net since Beta 1. He has been an MCSD since 1999 and an MCT since 2000. Prior to getting a degree in Computer science he worked as a process engineer focusing on process automation, programmable controllers and equipment installations. In his spare time he likes woodworking and gardening.