| 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 |