Managing Directory Security Principals in the .NET Framework 3.5
This article is based on a prerelase version of Visual Studio 2008. All information herein is subject to change.
This article discusses:
Directories are an important though rarely mastered component of enterprise application development. For the Windows® platform, Microsoft provides three primary directory platforms: Active Directory® Domain Services, the local Security Account Manager (SAM) data store on every Windows computer, and the relatively new Active Directory Lightweight Directory Services or AD LDS (which you may have previously known as Active Directory Application Mode or simply ADAM). While most enterprise developers know at least the basics of SQL programming, far fewer have experience programming directory services.
The original version of the Microsoft® .NET Framework provided a set of classes for programming directory services within the System.DirectoryServices namespace. These classes were a simple managed interop layer over an existing COM-based OS component (specifically, the Active Directory Service Interfaces or ADSI). While the programming model was reasonably powerful, it was more generalized and pared down than the full ADSI model.
In the .NET Framework 2.0, Microsoft has added features to its System.DirectoryServices and provided two new namespaces: System.DirectoryServices.ActiveDirectory and System.DirectoryServices.Protocols. (We will refer to these as the ActiveDirectory namespace and the Protocols namespace, respectively, throughout this article.) The ActiveDirectory namespace introduced a wealth of new classes for strongly typed management of directory infrastructure-level components, such as servers, domains, forests, schema, and replication. The Protocols namespace went in a different direction, providing an alternative API for programming Lightweight Directory Access Protocol (LDAP). This worked directly with the Windows LDAP subsystem (wldap32.dll), skipping over the ADSI COM interop layer entirely (see Figure 1).
Figure 1 Microsoft Directory Services Programming Architecture (Click the image for a larger view)
Still, developers missed some of the strongly typed interfaces in ADSI that they had used for managing security principals, such as Users and Groups. You could perform most of these tasks using the more general classes in System.DirectoryServices, but these were not as easy to use as they should have been, and many tasks were downright obscure. Microsoft addressed this problem in the .NET Framework 3.5 by adding a new namespace designed specifically for managing security principals: System.DirectoryServices.AccountManagement. (We'll refer to System.DirectoryServices.AccountManagement as the AccountManagement namespace throughout this article.)
This new namespace has three primary goals: to simplify principal management operations across the three directory platforms, to make principal management operations consistent regardless of the underlying directory, and to provide reliable results for these operations so you are not required to know each and every one of the caveats and special cases.
By waiting a few years for the .NET landscape to gel, Microsoft has actually outdone its previous work in ADSI by providing an even better API for these features that takes advantage of .NET capabilities while also providing much better support for new directory platforms such as AD LDS.
Directory Services Programming Architecture
Before you continue, see this article's code download for prerequisites you should already have in place in order to use these techniques. Now let's begin. Figure 1 depicts the overall programming architecture of System.DirectoryServices. The AccountManagement namespace, like the ActiveDirectory namespace, is an abstraction layer on top of System.DirectoryServices. And System.DirectoryServices is itself an abstraction layer on top of ADSI. The AccountManagement namespace also relies on the Protocols namespace for a small set of its features, such as high-performance authentication. The dark blue shading in Figure 1 shows the parts of the directory services programming architecture on which AccountManagement relies.
Figure 2 shows key types in the AccountManagement namespace. Unlike the namespaces added in the .NET Framework 2.0, AccountManagement has a relatively small surface area. With the exception of some supporting types, such as enumerations and exception classes, the namespace is composed of three primary components: a tree of Principal-derived classes representing strongly typed User, Group, and Computer objects; a PrincipalContext class used to establish a connection to the underlying store; and a PrincipalSearcher class (with supporting types) used for finding objects in the directory.
Figure 2 Key Classes in System.DirectoryServices.AccountManagement (Click the image for a larger view)
Under the hood, the AccountManagement namespace uses a provider design pattern for operating over the three supported directory platforms. As a result, the members of the various Principal classes behave similarly, regardless of the underlying directory store. This design is the key to providing simplicity and consistency.
Establishing Context
You use the PrincipalContext class to establish a connection to the target directory and specify credentials for performing operations against the directory. This approach is similar to how you would go about establishing context with the DirectoryContext class in the ActiveDirectory namespace.
The PrincipalContext constructor has a variety of overloads for providing the exact options you need for establishing context. If you've worked with the DirectoryEntry class in System.DirectoryServices, many of the PrincipalContext options will look familiar. However, three of the PrincipalContext options—ContextType, name, and container—are much more specific than the input parameters you use with the DirectoryEntry class. This specificity ensures that you use proper input parameter combinations. In System.DirectoryServices and ADSI, these three parameters of the PrincipalContext constructor are combined into a single string called the path. With these components separated, it's easier to understand what is intended by each and every part of the path.
You use the ContextType enum to specify the type of target directory: Domain (for Active Directory Domain Services), ApplicationDirectory (for AD LDS), or Machine (for the local SAM database). In contrast, when using System.DirectoryServices, you use the provider component of the path string (typically "LDAP" or "WinNT") to specify the target store. ADSI then reads this value under the hood to load the appropriate provider.
The classes in the AccountManagement namespace make this task easier and more consistent by ensuring that you only use providers supported by the Framework. It also avoids annoying provider misspellings and incorrect case issues common in ADSI and System.DirectoryServices programming. That said, AccountManagement does not support less popular ADSI providers, such as IIS and Novell Directory Services.
You use the name parameter on the PrincipalContext constructor in order to provide the name of the specific directory to connect to. This can be the name of a specific server, machine, or domain. It's important to note that if this parameter is null, AccountManagement will attempt to determine a default machine or domain for the connection based on your current security context. However, if you want to connect to an AD LDS store, you must specify a value for the name parameter.
The container parameter allows you to specify the target location in the directory for establishing context. You must not specify this parameter when using Machine ContextType, as the SAM database is not hierarchical. Conversely, you must provide a value when using ApplicationDirectory since AD LDS does not publish a defaultNamingContext attribute to be used when trying to infer the directory root object. This parameter is optional with Domain ContextType, and if it's not specified, AccountManagement will use defaultNamingContext.
The additional parameters (username, password, and the ContextOptions enum) let you supply plain text credentials if necessary and specify the various connection security options to be used.
All of the directories support the Windows Negotiate authentication method. If no option is specified for the Machine store, Windows Negotiate authentication is used. The default for Domain and ApplicationDirectory, however, is Windows Negotiate with both signing and sealing.
Note that Active Directory Domain Services and AD LDS also support LDAP simple bind. You generally want to avoid using this with Active Directory Domain Services, but it may be necessary with AD LDS if you want a user in the AD LDS store to perform principal operations.
If you specify null for the user name or password parameters, AccountManagement will use the current Windows security context. If you do specify credentials, the formats supported for user name are SamAccountName, UserPrincipalName, and NT4Name. Figure 3 shows three ways to establish context.
Figure 3 Three Examples of Establishing Context
// create a context for a domain called Fabrikam pointed
// to the TechWriters OU and using default credentials
PrincipalContext domainContext = new PrincipalContext(
ContextType.Domain,"Fabrikam","ou=TechWriters,dc=fabrikam,dc=com");
// create a context for the current machine SAM store with the
// current security context
PrincipalContext machineContext = new PrincipalContext(
ContextType.Machine);
// create a context for an AD LDS store pointing to the
// partition root using the credentials for a user in the AD LDS store
// and SSL for encryption
PrincipalContext ldsContext = new PrincipalContext(
ContextType.ApplicationDirectory, "sea-dc-02.fabrikam.com:50001",
"ou=ADAM Users,o=microsoft,c=us",
ContextOptions.SecureSocketLayer ContextOptions.SimpleBind,
"CN=administrator,OU=ADAM Users,O=Microsoft,C=US ", "pass@1w0rd01");
There is another subtle but important distinction between the AccountManagement PrincipalContext and the System.DirectoryServices DirectoryEntry classes when the bind operation occurs. PrincipalContext connects and binds to the underlying directory upon object creation, whereas DirectoryEntry doesn't bind until you perform another operation that forces the connection. As a result, with PrincipalContext, you get immediate feedback about whether a connection is successfully bound to a directory.
Creating a User Account
Now you should have a fairly good understanding of how AccountManagement uses PrincipalContext to connect and bind to a container. Next we will discuss a typical DirectoryServices operation—creating a user account. In the process, each code sample assigns a value to one mandatory attribute, adds two optional attributes, sets a password, enables the user account, and commits the changes to the directory.
Here we use the domainContext variable that was introduced in example 1 of Figure 3 to create a new UserPrincipal:
// create a user principal object
UserPrincipal user = new UserPrincipal(domainContext,
"User1Acct", "pass@1w0rd01", true);
// assign some properties to the user principal
user.GivenName = "User";
user.Surname = "One";
// force the user to change password at next logon
user.ExpirePasswordNow();
// save the user to the directory
user.Save();
The domainContext establishes the connection to the directory and the security context used to perform the operation. Then, in a single line of code, we create a new user object, set the password, and enable it. After that, we use the GivenName and Surname properties to set the corresponding directory attributes in the underlying store. Before saving the object to the underlying directory store, the password expires, forcing the user to change the password upon first logon.
By comparison, Figure 4 demonstrates the equivalent steps required to create a user account in System.DirectoryServices. The container variable in the first code line is a DirectoryEntry class object, which uses a path for its connection. The path specifies the provider, a domain, and container (TechWriters OU). It also connects using the current user's security context. The container variable is similar to the domainContext in the previous example of creating a user principal.
Figure 4 Create an Account with System.DirectoryServices
DirectoryEntry container =
new DirectoryEntry("LDAP://ou=TechWriters,dc=fabrikam,dc=com");
// create a user directory entry in the container
DirectoryEntry newUser = container.Children.Add("cn=user1Acct", "user");
// add the samAccountName mandatory attribute
newUser.Properties["sAMAccountName"].Value = "User1Acct";
// add any optional attributes
newUser.Properties["givenName"].Value = "User";
newUser.Properties["sn"].Value = "One";
// save to the directory
newUser.CommitChanges();
// set a password for the user account
// using Invoke method and IadsUser.SetPassword
newUser.Invoke("SetPassword", new object[] { "pAssw0rdO1" });
// require that the password must be changed on next logon
newUser.Properties["pwdLastSet"].Value = 0;
// enable the user account
// newUser.InvokeSet("AccountDisabled", new object[]{false});
// or use ADS_UF_NORMAL_ACCOUNT (512) to effectively unset the
// disabled bit
newUser.Properties["userAccountControl"].Value = 512;
// save to the directory
newUser.CommitChanges();
Aside from the fact that this code is quite a bit longer than the AccountManagement example, it is also more complex. We need to know more about the underlying data model in the directory and we need to know how to handle certain account management features, such as setting an initial password and enabling a user object by undoing the disabled flag. This gets a bit clunky when you have to use the reflection-based Invoke and InvokeSet methods to call underlying ADSI COM interface members. It is this complexity that has made directory services programming so frustrating for many developers.
In addition, if we were trying to create the same user account in AD LDS or the local SAM database, there would have been even more differences. AD LDS and Active Directory Domain Services use a different mechanism for enabling user accounts (the msds-userAccountDisabled attribute in AD LDS rather than the userAccountControl attribute in Active Directory Domain Services), while the SAM store requires you to call an ADSI interface member. This lack of consistency among the account management features of the three directory stores was one of the key design challenges that the AccountManagement namespace had to overcome. Now, by simply changing the PrincipalContext used to create the UserPrincipal object, you can easily switch between directory stores and get one consistent set of account management features.
The Protocols namespace introduced in the .NET Framework 2.0 doesn't address any of these needs. Its purpose is to provide systems-level LDAP programmers with a more powerful and flexible API for building LDAP-based applications. However, it provides even less abstraction over the LDAP model than System.DirectoryServices, and it does nothing to simplify the differences among various directories. In addition, it is not intended for use with the local SAM database (which is not an LDAP directory). The online code samples that accompany this article include a sample similar to the AccountManager example following Figure 3. It performs the same task but takes three times as many lines of code.
The PrincipalContext in Figure 5 shows the ContextType enumeration referring to a machine in order to target a SAM store. The next parameter targets the actual machine by name (or IP address), and the last two values provide the credentials of an account that can perform the account creation.
Figure 5 Creating a SAM Database User Account
PrincipalContext principalContext = new PrincipalContext(
ContextType.Machine. "computer01", "adminUser", "adminPassword");
UserPrincipal user = new UserPrincipal(principalContext,
"User1Acct", "pass@1w0rd01", true);
//Note the difference in attributes when accessing a different store
//the attributes appearing in IntelliSense are not derived from the
//underlying store
user.Name = "User One";
user.Description = "User One";
user.ExpirePasswordNow();
user.Save();
We set the Name and Description properties because the SAM store does not contain givenName, sn, or displayName attributes, as in Active Directory Domain Services. Even though AccountManagement attempts to provide a consistent experience across all three directory stores, there are differences in the underlying models. However, it will throw an InvalidOperationException if you attempt to get an attribute that is not available in the underlying store.
Figure 3 and Figure 5 are two examples of creating user accounts; they show the consistent programming model inherent in the AccountManagement namespace when operating against any store. The PrincipalContext in Figure 6 uses ContextType.ApplicationDirectory to target an AD LDS store, as was shown in Figure 3. The next parameter shows the AD LDS server. In this case, sea-dc-02.fabrikam.com is the Fully Qualified Domain Name (FQDN) of the server hosting the AD LDS instance, and the instance is listening on port 50001 for SSL communications. Note that the code download uses non-SSL communications over port 50000. This is not secure but is fine for your own testing.
Figure 6 Creating an ADAM or AD LDS User Account
PrincipalContext principalContext = new PrincipalContext(
ContextType.ApplicationDirectory,
"sea-dc-02.fabrikam.com:50001",
"ou=ADAM Users,o=microsoft,c=us",
ContextOptions.SecureSocketLayer ContextOptions.SimpleBind,
"CN=administrator,OU=ADAM Users,O=Microsoft,C=US",
"P@55w0rd0987");
UserPrincipal user = new UserPrincipal(principalContext,
"User1Acct", "pass@1w0rd01", true);
user.GivenName = "User";
user.Surname = "One";
user.Save();
The next parameter designates the container where you want to perform a CRUD (Create/Read/Update/Delete) operation. This example specifies a user stored in AD LDS for performing our CRUD operations, so it must use an LDAP simple bind, and it's combined with SSL for security. Even though AD LDS supports secure DIGEST authentication natively, ADSI itself does not. Once again, our example is practically identical to the two previous examples with only the PrincipalContext being significantly different.
The AccountManagement namespace provides a comprehensive set of account management features such as password expiration and account unlock. We don't have space here to demonstrate all of them, but the bottom line is that they work consistently and reliably across directory stores, and they take the hassle out of implementing such capabilities.
Creating Groups and Computers
You've seen that creating a user account is simple and consistent from one DirectoryServices store to another. This consistency extends to creating the other two supported DirectoryServices objects, groups, and computers. Like the UserPrincipal class, the GroupPrincipal and ComputerPrincipal classes inherit from the Principal abstract class and operate similarly. For example, to create a group named Group01 in Active Directory Domain Services, AD LDS, or the SAM account database, you can use this code:
GroupPrincipal group = new GroupPrincipal(principalContext,
"Group01");
group.Save();
In each case, differences are contained in the PrincipalContext class that establishes context with the different stores. The code used to create a computer object follows a similar pattern of creating the principal object using a context and then saving the object to the target of the principal context:
ComputerPrincipal computer = new ComputerPrincipal(domainContext);
computer.DisplayName = "Computer1";
computer.SamAccountName = "Computer1$";
computer.Enabled = true;
computer.SetPassword("p@ssw0rd01");
computer.Save();
Once again, AccountManagement takes care of unifying the interaction model for all of the supported identity stores. This sample shows the proper syntax for creating a computer object that can be joined to the domain (which requires a trailing $ in the sAMAccountName attribute) but sets the display name and common name so the $ is not included. Note that since the SAM database and AD LDS do not contain the computer class, AccountManagement will only allow you to create this type of object inside a domain-based PrincipalContext. Additionally, only Active Directory Domain Services contains a variety of group scopes—global, universal, and domain local—and contains both security and distribution groups. Therefore, the GroupPrincipal class provides nullable properties that allow you to set these values when necessary.
Managing Group Membership
The AccountManagement namespace also simplifies managing group membership. Before AccountManagement, there were lots of idiosyncrasies and inconsistencies among managing groups in different stores. Managing groups with many members was programmatically difficult. In addition, you had to use COM interop to manage SAM group membership and use LDAP attributes to manage Active Directory Domain Services and AD LDS groups. But now, the Members property of the GroupPrincipal class lets you enumerate a group's membership and manage its members. It all just works.
Another seemingly simple operation that is actually difficult to achieve is getting all the groups to which a user belongs. AccountManagement provides several methods to help you. The Principal base class includes two GetGroups methods and two IsMemberOf methods that, respectively, get the group membership of any Principal type and check to see if that principal is a member of a group. Also, UserPrincipal provides a special GetAuthorizationGroups method that returns the fully expanded security group membership of any UserPrincipal type. Figure 7 shows how you can use the GetAuthorizationGroups method.
Figure 7 Using the GetAuthorizationGroups Method
string userName = "user1Acct";
// find the user in the identity store
UserPrincipal user =
UserPrincipal.FindByIdentity(
adPrincipalContext,
userName);
// get the groups for the user principal and
// store the results in a PrincipalSearchResult object
PrincipalSearchResult results =
user.GetAuthorizationGroups();
// display the names of the groups to which the
// user belongs
Console.WriteLine("groups to which {0} belongs:", userName);
foreach (Principal result in results)
{
Console.WriteLine("name: {0}", result.Name);
}
Yet another tricky operation made simple with AccountManagement is the task of expanding group membership across trusted domains or with foreign security principals. The GetGroups(PrincipalContext) method on the Principal class does the heavy lifting for you.
Finding Ourselves
Another task programmers often struggle with is finding objects in the directory. While LDAP is not a particularly complex query language compared to the syntaxes that developers tackle routinely, it is nonetheless unfamiliar. Additionally, even if you know the rudiments of LDAP, it is often difficult to figure out how to use it to perform common tasks.
Once again, AccountManagement makes these tasks a breeze by helping you find objects with the FindByIdentity method. This method is part of the Principal abstract class from which the UserPrincipal, GroupPrincipal, and ComputerPrincipal classes inherit. Therefore, whenever you need to search for one of these principal types, FindByIdentity is a good friend to have.
FindByIdentity contains two overloads, both of which take a PrincipalContext and value to find. For the value, you can specify any of the supported identity types: SamAccountName, Name, UserPrincipalName, DistinguishedName, Sid, or Guid. The second overload also allows you to explicitly define the identity type you will specify as the value.
Starting with the simpler overload, here's how you can go about using FindByIdentity to return the user account we created in the previous examples:
UserPrincipal user = UserPrincipal.FindByIdentity(principalContext, "user1Acct");
Once you have a context (stored in the principalContext object), you use the FindByIdentity method to retrieve the principal object, in this case a UserPrincipal. After establishing context to any supported identity store, the code for finding the identity is always the same.
The second FindByIdentity constructor allows you to be explicit about the identity format you will specify. When you use this constructor, you must match the value's format to the identity type you specify. For instance, this code will properly return a UserPrincipal using its distinguished name provided the object exists in the directory and in the specified location:
UserPrincipal user = UserPrincipal.FindByIdentity(
adPrincipalContext,
IdentityType.DistinguishedName,
"CN=User1Acct,OU=TechWriters,DC=FABRIKAM,DC=COM");
In contrast, this code will not return a UserPrincipal since the IdentityType enumeration specifies a DistinguishedName format, but the value is not in this format:
UserPrincipal user = UserPrincipal.FindByIdentity(
adPrincipalContext,
IdentityType.DistinguishedName,
"user1Acct");
Format is important. For example, if you decide to use the GUID or SID IdentityTypes, you must use the standard COM GUID format and the Security Descriptor Description Language (SDDL) format, respectively, for the value. The code download for this article provides two methods (FindByIdentityGuid and FindByIdentitySid) that show the proper format. Note that you must change the GUID or SID values in these methods in order to find a match in your directory store. Obtaining either format is easy using the PrincipalSearcher class, as we'll show you in just a moment.
Now that you've found a Principal object and bound to it, you can easily perform operations on it. For example, you can add a user to a group, like so:
// get a user principal
UserPrincipal user =
UserPrincipal.FindByIdentity(adPrincipalContext, "User1Acct");
// get a group principal
GroupPrincipal group =
GroupPrincipal.FindByIdentity(adPrincipalContext, "Administrators");
// add the user
group.Members.Add(user);
// save changes to directory
group.Save();
Here, we use the FindByIdentity method to first find a user and then a group. Once the code obtains these principal objects, we call the Add method of the group's Members property to add the user principal to the group. Finally, we call the group's Save method to save the change to the directory.
Finding Matches
You can also use the powerful Query by Example (QBE) facility and the PrincipalSearcher class to find an object based on defined criteria. We will explain more about QBE and the PrincipalSearcher class, but first we want to examine a simple search example. Figure 8 shows how you can find all user accounts beginning with a name/cn prefix of "user" that are disabled.
Figure 8 Using PrincipalSearcher
// create a principal object representation to describe
// what will be searched
UserPrincipal user = new UserPrincipal(adPrincipalContext);
// define the properties of the search (this can use wildcards)
user.Enabled = false;
user.Name = "user*";
// create a principal searcher for running a search operation
PrincipalSearcher pS = new PrincipalSearcher();
// assign the query filter property for the principal object
// you created
// you can also pass the user principal in the
// PrincipalSearcher constructor
pS.QueryFilter = user;
// run the query
PrincipalSearchResult results = pS.FindAll();
Console.WriteLine("Disabled accounts starting with a name of 'user':");
foreach (Principal result in results)
{
Console.WriteLine("name: {0}", result.Name);
}
The PrincipalContext variable, adPrincipalContext, points to an Active Directory domain, but it could just as easily point to an AD LDS application partition. After establishing context, notice that the code creates a new UserPrincipal object. This is an in-memory representation of the principal for the search operation. Once you've created this principal, you then set the properties that limit the search results. The next two code lines demonstrate some limits you can set—all disabled user accounts where the user name begins with some value. Note that the property value for the Name attribute supports wildcards.
If you're already familiar with the LDAP dialect for setting up search filters, you'll immediately appreciate why QBE is a novel and more intuitive alternative. With QBE, you set up an example object that you then use for the query operation. To clearly demonstrate that QBE is simpler than the typical DirectoryServices search dialect, here's the LDAP dialect for setting up a filter equivalent to the QBE object created in Figure 8:
(&(objectCategory=person)(objectClass=user)(name=user*)(userAccount
Control:1.2.840.113556.1.4.803:=2))
As you can see, the LDAP dialect is quite a bit more complicated, and it won't work for AD LDS because the Active Directory LDS user schema uses the msDS-UserAccountDisabled attribute instead of the userAccountControl attribute shown in the LDAP dialect. Once again, AccountManagement handles these differences for us behind the scenes.
After setting up the QBE object shown in Figure 8, we create a PrincipalSearcher object and assign its QueryFilter property that the Principal object created earlier in the code. Note that you can also pass the user principal in the PrincipalSearcher constructor, rather than setting the QueryFilter property. We then run the query, calling the FindAll method of the PrincipalSearcher and assigning the returned results to the PrincipalSearchResult generic list. The PrincipalSearchResult list stores the returned Principal objects. Lastly, the code enumerates the list of principals and displays the Name attribute of each returned principal.
Note that QBE does not work for referential attributes. That is, attributes that are not owned by the QBE object cannot be used to configure your in-memory representation of the object.
You can do a lot more in the foreach loop. For example, you can enable the disabled user accounts or delete them. If you're after just read operations, keep in mind that if you do point to some other identity store, the attributes you return must exist in that store. For example, since an AD LDS user doesn't contain the sAMAccountName attribute, it wouldn't make sense to try to return this attribute in the results.
Difficult Search Operations Made Easy
There are other powerful FindBy methods that, when coupled with the PrincipalSearchResult class, can retrieve information about user and computer principals that is otherwise difficult to retrieve. Figure 9 demonstrates how to retrieve the name of each user who changed his password today. This example uses the FindByPasswordSetTime method and the PrincipalSearchResult class. Without AccountManagement, this operation is complicated because the underlying pwdLastSet attribute is stored in the directory as a Large Integer.
Figure 9 Retrieving Users Who Reset Their Password Today
// get today's date
DateTime dt = DateTime.Today;
// run a query
PrincipalSearchResult results =
UserPrincipal.FindByPasswordSetTime(
adPrincipalContext,
dt,
MatchType.GreaterThanOrEquals);
Console.WriteLine("users whose password was set on {0}",
dt.ToShortDateString());
foreach (Principal result in results)
{
Console.WriteLine("name: {0}", result.Name);
}
The code download for this article contains examples of using other FindBy methods. They all operate similarly to that which we've shown you in Figure 9.
FindBy methods are convenient shortcuts to information that is otherwise hard to retrieve. However, they are not appropriate if you need to further filter the results using the QBE facility. An important nuance here is that the associated attribute is read-only and therefore cannot be set on a QBE object, just as it cannot be set by a user on the object to which the QBE refers. To use the QBE, you use the equivalent read-only property in your example principal object combined with the AdvancedSearchFilter property. More about that will come later. Figure 10 lists more FindBy methods and shows the equivalent read-only properties that you can use instead of the FindBy method in a search.
Figure 10 Other FindBy Methods
Method Name Read-Only Property Description
FindByLogonTime LastLogonTime Accounts that have logged on within the specified time.
FindByExpirationTime AccountExpirationDate Expired accounts within the specified time.
FindByPasswordSetTime LastPasswordSetTime Accounts whose password was set within the specified time.
FindByLockoutTime AccountLockoutTime Accounts locked out within the specified time.
FindByBadPasswordAttempt LastBadPasswordAttempt Bad password attempts within the specified time.
No equivalent method BadLogonCount Accounts that have attempted to logon the specified number of times but have failed to logon.
You can't set a value on a read-only property when configuring a QBE. So how can you work with the property in a search operation? You can retrieve a result set and then perform a conditional test using the read-only property when enumerating the result set. Just keep in mind that this approach is not advisable for potentially large resultsets since the code must first retrieve results unfiltered for the read-only property and then filter the returned resultset by the read-only property. The PrincipalSearchEx6v2 method in the code download demonstrates this less-than-ideal approach.
The Directory Services team addressed this QBE limitation by adding the AdvancedSearchFilter property to the AuthenticablePrincipal class. AdvancedSearchFilter allows you to search based on the read-only properties and then combine them with other properties you can set using the QBE mechanism. Figure 11 demonstrates how you can use the LastBadPasswordAttempt read-only property of the UserPrincipal class to return a list of users who had a bad password attempt today.
Figure 11 AdvancedSearchFilter with a Read-Only Property
DateTime dt = DateTime.Today;
// create a principal object representation to describe
// what will be searched
UserPrincipal user = new UserPrincipal(adPrincipalContext);
user.Enabled = true;
// define the properties of the search (this can use wildcards)
user.Name = "*";
//add the LastBadPasswordAttempt >= Today to the query filter
user.AdvancedSearchFilter.LastBadPasswordAttempt
(dt, MatchType.GreaterThanOrEquals);
// create a principal searcher for running a search operation
// and assign the QBE user principal as the query filter
PrincipalSearcher pS = new PrincipalSearcher(user);
// run the query
PrincipalSearchResult results = pS.FindAll();
Console.WriteLine("Bad password attempts on {0}:",
dt.ToShortDateString());
foreach (UserPrincipal result in results)
{
Console.WriteLine("name: {0}, {1}",
result.Name,
result.LastBadPasswordAttempt.Value);
}
Authenticating Users
Developers who build directory-based applications often need to authenticate the credentials of users stored in the directory, especially when using AD LDS. Before the .NET Framework 3.5, programmers accomplished this task using the DirectoryEntry class in System.DirectoryServices to force an LDAP bind operation under the hood. However, be careful: it is exceedingly easy to write poor versions of this code that are not secure, that are slow, or that are just plain clunky. Additionally, ADSI itself is not designed for this type of operation and can fail under high-use conditions due to the way it caches LDAP connections internally.
As we've already discussed, the System.DirectoryServices.Protocols assembly in the .NET Framework 2.0 contains lower-level LDAP classes that use a connection-based programming metaphor. This design allows you to overcome the inherent limitations in ADSI but at the expense of having to write more complicated code.
In the .NET Framework 3.5, AccountManagement delivers both the power and ease of use offered by the ActiveDirectoryMembershipProvider implementation in ASP.NET to programmers working in any environment. Additionally, the AccountManagement namespace allows you to authenticate credentials against the local SAM database if needed.
The two ValidateCredentials methods on the PrincipalContext class provide credential validation. You first create an instance of a PrincipalContext using the directory you wish to validate against and specify the appropriate options. After getting context, you test whether ValidateCredentials returns true or false based on the supplied user name and password values. Figure 12 shows an example of authenticating a user in AD LDS.
Figure 12 Authenticating a User in AD LDS
// establish context with AD LDS
PrincipalContext ldsContext =
new PrincipalContext(
ContextType.ApplicationDirectory,
"sea-dc-02.fabrikam.com:50000",
"ou=ADAM Users,O=Microsoft,C=US");
// determine whether a user can validate to the directory
Console.WriteLine(
ldsContext.ValidateCredentials(
"user1@adam",
"Password1",
ContextOptions.SimpleBind +
ContextOptions.SecureSocketLayer));
This approach is most useful when you want to validate many different sets of user credentials quickly and efficiently. You create a single PrincipalContext object for the directory store in question and reuse that object instance for each call to ValidateCredentials. The PrincipalContext can reuse the connection to the directory, which results in good performance and scalability. And calls to ValidateCredentials are thread-safe, so your instance can be used across threads for this operation. It's important to note that the credentials used to create the PrincipalContext are not changed by calls to ValidateCredentials—the context and method call maintain separate connections.
By default, AccountManagement uses secure Windows Negotiate authentication and attempts to use SSL when performing a simple bind against AD LDS. We recommend that you always be explicit with the type of authentication you want to perform and the connection protection you wish to use (if applicable), but at least the defaults err on the side of caution.
Active Directory Domain Services in Windows Server® 2003 and later and AD LDS both include fast concurrent binding, which is designed for high-performance authentication operations. It validates a user's password without actually building a security token for the user. Unlike in a normal bind operation, with fast concurrent binding the state of the LDAP connection remains unbound. You can use fast concurrent binding to perform bind operations on the same connection repeatedly and simply check for a failed password attempt. This feature is not an available option through ADSI or System.DirectoryServices, but it is exposed as an option in the Protocols namespace.
AccountManagement uses fast concurrent binding whenever possible and enables this option automatically. This is the reason that the AccountManagement layer also appears above the Protocols layer in Figure 1. Note that it only works in simple bind mode, which passes plain text credentials on the network. Therefore, fast concurrent binding should always be combined with SSL for security.
Extensibility Model
Directory Services Resources
.NET Framework 3.5 Beta 2 Download
System.DirectoryServices.AccountManagement Namespace Overview
System.DirectoryServices.AccountManagement Namespace Documentation
About Active Directory Lightweight Directory Services
Windows Server 2003 Active Directory Application Mode
Introduction to System.DirectoryServices.Protocols
Introduction to System.DirectoryServices.ActiveDirectory
Another area where AccountManagement really shines is its extensibility model. Many developers will choose to use the various Principal-derived classes for building custom provisioning systems for both Active Directory Domain Services and AD LDS. In many cases (especially with AD LDS), an organization will add custom schema extensions to the directory to support its own metadata for users and groups.
Using the .NET Framework object-oriented design and attribute-based extensible metadata, AccountManagement makes it easy to create custom security principal classes that support your custom schema. By simply inheriting from one of the Principal-derived classes and marking your class and properties with the appropriate attributes, your custom principal class can read and write these directory attributes as well as the attributes already supported by the built-in types.
An important nuance worth noting is that the extensibility mechanism provided by AccountManagement is designed for use by security principals stored in Active Directory Domain Services or AD LDS. It doesn't have a focus on non-Microsoft LDAP directories. If you wish to build a framework for provisioning in non-Microsoft LDAP directories, you should use the lower-level classes in the Protocols namespace. (In addition, the extensibility model is not intended for use with local SAM accounts, as the SAM schema is not extensible.)
Consider an AD LDS directory that uses the standard LDAP user class for storing security principals for an application. In addition, the LDAP directory schema is extended to support a special attribute for identifying user objects called msdn-subscriberID. Figure 13 demonstrates how to create a custom class that can provision user objects and also provide create, read, and write operations against this attribute.
Figure 13 Our Sample MsdnUser Class
[DirectoryObjectClass("user")]
[DirectoryRdnPrefix("CN")]
class MsdnUser : UserPrincipal
{
public MsdnUser(PrincipalContext context)
: base(context) { }
public MsdnUser(
PrincipalContext context,
string samAccountName,
string password,
bool enabled
)
: base(
context,
samAccountName,
password,
enabled
)
{
}
[DirectoryProperty("msdn-subscriberID")]
public string MsdnSubscriberId
{
get
{
object[] result = this.ExtensionGet("msdn-subscriberID");
if (result != null) {
return (string)result[0];
}
else {
return null;
}
}
set { this.ExtensionSet("msdn-subscriberID", value); }
}
}
Notice that the code inherits from the UserPrincipal class and is decorated with two attributes: DirectoryObjectClass and DirectoryRdnPrefix. Both of these attributes are required for principal extension classes. The DirectoryObjectClass attribute determines the value the supported store (Active Directory Domain Services or AD LDS) uses for the objectClass directory attribute when creating instances of this object in the directory. Here, this is still the default AD LDS user class, but in reality it could be anything. The DirectoryRdnPrefix attribute determines the RDN (relative distinguished name) attribute name to use for naming objects of this class in the directory. Under Active Directory Domain Services, you cannot change the RDN prefix—it is always CN for security principal classes. Under AD LDS, however, there is more flexibility and you can use a different RDN if desired.
Our class has a property called MsdnSubscriberID that returns a string. This class is marked with the DirectoryProperty attribute, specifying the LDAP schema attribute used to store the property value. The underlying framework uses this value for optimizing search operations against this Principal type.
Our property get and set implementations use the protected ExtensionGet and ExtensionSet methods of the Principal base class to read and write values to the underlying property cache. These methods support storing values in memory for objects that have not yet been persisted to the database/identity store. In addition, these methods support reading and writing values from existing objects. Since LDAP directories support attributes of various types and also allow an attribute to contain multiple values, these methods use the object[] type for reading and writing values. This flexibility is nice, but if you want to provide a strongly typed scalar string value on top of an array of object types, you have to do a little extra work, as our implementation demonstrates. The result for consumers of our custom MsdnUser class is an interface that is very easy to program.
The ability to provide strongly typed values on top of our directory schema is one of the most useful features of this extensibility model. Beyond simple string types, you can also use the rich type system offered by the .NET Framework to do such things as represent the Active Directory Domain Services jpgPhoto attribute as a System.Drawing.Image or a System.IO.Stream instead of the default byte[] that you would usually get by reading the value from System.DirectoryServices.
The code download for this article provides a few more samples to demonstrate these capabilities. It also has some schema extensions (via a standard LDIF formatted file, msdnschema.ldf) that you can use to extend your test directory with the MsdnUser class. We also provided some valuable links in the "Directory Services Resources" sidebar.
Final Thoughts
AccountManagement is a much-needed managed code addition to the rich directory services programming model offered by Microsoft. With the AccountManagement namespace, developers now have a set of strongly typed principals for common CRUD and search operations.
The namespace encapsulates directory service programming best practices to help you write secure and high-performance managed code. In addition, AccountManagement is extensible, allowing you to fully interact with your custom directory objects in Active Directory Domain Services and AD LDS.
This article is based on a prerelase version of Visual Studio 2008. All information herein is subject to change.
This article discusses:
- The System.DirectoryServices.AccountManagement class
- Active Directory Domain Services
- Active Directory Lightweight Directory Services (AD LDS)
- Managing user, computer, and group principals
- This article uses the following technologies:
- The .NET Framework 3.5, Visual Studio 2008 Contents - Directory Services Programming Architecture
- Establishing Context
- Creating a User Account
- Creating Groups and Computers
- Managing Group Membership
- Finding Ourselves
- Finding Matches
- Difficult Search Operations Made Easy
- Authenticating Users
- Extensibility Model
- Final Thoughts
Directories are an important though rarely mastered component of enterprise application development. For the Windows® platform, Microsoft provides three primary directory platforms: Active Directory® Domain Services, the local Security Account Manager (SAM) data store on every Windows computer, and the relatively new Active Directory Lightweight Directory Services or AD LDS (which you may have previously known as Active Directory Application Mode or simply ADAM). While most enterprise developers know at least the basics of SQL programming, far fewer have experience programming directory services.
The original version of the Microsoft® .NET Framework provided a set of classes for programming directory services within the System.DirectoryServices namespace. These classes were a simple managed interop layer over an existing COM-based OS component (specifically, the Active Directory Service Interfaces or ADSI). While the programming model was reasonably powerful, it was more generalized and pared down than the full ADSI model.
In the .NET Framework 2.0, Microsoft has added features to its System.DirectoryServices and provided two new namespaces: System.DirectoryServices.ActiveDirectory and System.DirectoryServices.Protocols. (We will refer to these as the ActiveDirectory namespace and the Protocols namespace, respectively, throughout this article.) The ActiveDirectory namespace introduced a wealth of new classes for strongly typed management of directory infrastructure-level components, such as servers, domains, forests, schema, and replication. The Protocols namespace went in a different direction, providing an alternative API for programming Lightweight Directory Access Protocol (LDAP). This worked directly with the Windows LDAP subsystem (wldap32.dll), skipping over the ADSI COM interop layer entirely (see Figure 1).
Figure 1 Microsoft Directory Services Programming Architecture (Click the image for a larger view)
Still, developers missed some of the strongly typed interfaces in ADSI that they had used for managing security principals, such as Users and Groups. You could perform most of these tasks using the more general classes in System.DirectoryServices, but these were not as easy to use as they should have been, and many tasks were downright obscure. Microsoft addressed this problem in the .NET Framework 3.5 by adding a new namespace designed specifically for managing security principals: System.DirectoryServices.AccountManagement. (We'll refer to System.DirectoryServices.AccountManagement as the AccountManagement namespace throughout this article.)
This new namespace has three primary goals: to simplify principal management operations across the three directory platforms, to make principal management operations consistent regardless of the underlying directory, and to provide reliable results for these operations so you are not required to know each and every one of the caveats and special cases.
By waiting a few years for the .NET landscape to gel, Microsoft has actually outdone its previous work in ADSI by providing an even better API for these features that takes advantage of .NET capabilities while also providing much better support for new directory platforms such as AD LDS.
Directory Services Programming Architecture
Before you continue, see this article's code download for prerequisites you should already have in place in order to use these techniques. Now let's begin. Figure 1 depicts the overall programming architecture of System.DirectoryServices. The AccountManagement namespace, like the ActiveDirectory namespace, is an abstraction layer on top of System.DirectoryServices. And System.DirectoryServices is itself an abstraction layer on top of ADSI. The AccountManagement namespace also relies on the Protocols namespace for a small set of its features, such as high-performance authentication. The dark blue shading in Figure 1 shows the parts of the directory services programming architecture on which AccountManagement relies.
Figure 2 shows key types in the AccountManagement namespace. Unlike the namespaces added in the .NET Framework 2.0, AccountManagement has a relatively small surface area. With the exception of some supporting types, such as enumerations and exception classes, the namespace is composed of three primary components: a tree of Principal-derived classes representing strongly typed User, Group, and Computer objects; a PrincipalContext class used to establish a connection to the underlying store; and a PrincipalSearcher class (with supporting types) used for finding objects in the directory.
Figure 2 Key Classes in System.DirectoryServices.AccountManagement (Click the image for a larger view)
Under the hood, the AccountManagement namespace uses a provider design pattern for operating over the three supported directory platforms. As a result, the members of the various Principal classes behave similarly, regardless of the underlying directory store. This design is the key to providing simplicity and consistency.
Establishing Context
You use the PrincipalContext class to establish a connection to the target directory and specify credentials for performing operations against the directory. This approach is similar to how you would go about establishing context with the DirectoryContext class in the ActiveDirectory namespace.
The PrincipalContext constructor has a variety of overloads for providing the exact options you need for establishing context. If you've worked with the DirectoryEntry class in System.DirectoryServices, many of the PrincipalContext options will look familiar. However, three of the PrincipalContext options—ContextType, name, and container—are much more specific than the input parameters you use with the DirectoryEntry class. This specificity ensures that you use proper input parameter combinations. In System.DirectoryServices and ADSI, these three parameters of the PrincipalContext constructor are combined into a single string called the path. With these components separated, it's easier to understand what is intended by each and every part of the path.
You use the ContextType enum to specify the type of target directory: Domain (for Active Directory Domain Services), ApplicationDirectory (for AD LDS), or Machine (for the local SAM database). In contrast, when using System.DirectoryServices, you use the provider component of the path string (typically "LDAP" or "WinNT") to specify the target store. ADSI then reads this value under the hood to load the appropriate provider.
The classes in the AccountManagement namespace make this task easier and more consistent by ensuring that you only use providers supported by the Framework. It also avoids annoying provider misspellings and incorrect case issues common in ADSI and System.DirectoryServices programming. That said, AccountManagement does not support less popular ADSI providers, such as IIS and Novell Directory Services.
You use the name parameter on the PrincipalContext constructor in order to provide the name of the specific directory to connect to. This can be the name of a specific server, machine, or domain. It's important to note that if this parameter is null, AccountManagement will attempt to determine a default machine or domain for the connection based on your current security context. However, if you want to connect to an AD LDS store, you must specify a value for the name parameter.
The container parameter allows you to specify the target location in the directory for establishing context. You must not specify this parameter when using Machine ContextType, as the SAM database is not hierarchical. Conversely, you must provide a value when using ApplicationDirectory since AD LDS does not publish a defaultNamingContext attribute to be used when trying to infer the directory root object. This parameter is optional with Domain ContextType, and if it's not specified, AccountManagement will use defaultNamingContext.
The additional parameters (username, password, and the ContextOptions enum) let you supply plain text credentials if necessary and specify the various connection security options to be used.
All of the directories support the Windows Negotiate authentication method. If no option is specified for the Machine store, Windows Negotiate authentication is used. The default for Domain and ApplicationDirectory, however, is Windows Negotiate with both signing and sealing.
Note that Active Directory Domain Services and AD LDS also support LDAP simple bind. You generally want to avoid using this with Active Directory Domain Services, but it may be necessary with AD LDS if you want a user in the AD LDS store to perform principal operations.
If you specify null for the user name or password parameters, AccountManagement will use the current Windows security context. If you do specify credentials, the formats supported for user name are SamAccountName, UserPrincipalName, and NT4Name. Figure 3 shows three ways to establish context.
Figure 3 Three Examples of Establishing Context
// create a context for a domain called Fabrikam pointed
// to the TechWriters OU and using default credentials
PrincipalContext domainContext = new PrincipalContext(
ContextType.Domain,"Fabrikam","ou=TechWriters,dc=fabrikam,dc=com");
// create a context for the current machine SAM store with the
// current security context
PrincipalContext machineContext = new PrincipalContext(
ContextType.Machine);
// create a context for an AD LDS store pointing to the
// partition root using the credentials for a user in the AD LDS store
// and SSL for encryption
PrincipalContext ldsContext = new PrincipalContext(
ContextType.ApplicationDirectory, "sea-dc-02.fabrikam.com:50001",
"ou=ADAM Users,o=microsoft,c=us",
ContextOptions.SecureSocketLayer ContextOptions.SimpleBind,
"CN=administrator,OU=ADAM Users,O=Microsoft,C=US ", "pass@1w0rd01");
There is another subtle but important distinction between the AccountManagement PrincipalContext and the System.DirectoryServices DirectoryEntry classes when the bind operation occurs. PrincipalContext connects and binds to the underlying directory upon object creation, whereas DirectoryEntry doesn't bind until you perform another operation that forces the connection. As a result, with PrincipalContext, you get immediate feedback about whether a connection is successfully bound to a directory.
Creating a User Account
Now you should have a fairly good understanding of how AccountManagement uses PrincipalContext to connect and bind to a container. Next we will discuss a typical DirectoryServices operation—creating a user account. In the process, each code sample assigns a value to one mandatory attribute, adds two optional attributes, sets a password, enables the user account, and commits the changes to the directory.
Here we use the domainContext variable that was introduced in example 1 of Figure 3 to create a new UserPrincipal:
// create a user principal object
UserPrincipal user = new UserPrincipal(domainContext,
"User1Acct", "pass@1w0rd01", true);
// assign some properties to the user principal
user.GivenName = "User";
user.Surname = "One";
// force the user to change password at next logon
user.ExpirePasswordNow();
// save the user to the directory
user.Save();
The domainContext establishes the connection to the directory and the security context used to perform the operation. Then, in a single line of code, we create a new user object, set the password, and enable it. After that, we use the GivenName and Surname properties to set the corresponding directory attributes in the underlying store. Before saving the object to the underlying directory store, the password expires, forcing the user to change the password upon first logon.
By comparison, Figure 4 demonstrates the equivalent steps required to create a user account in System.DirectoryServices. The container variable in the first code line is a DirectoryEntry class object, which uses a path for its connection. The path specifies the provider, a domain, and container (TechWriters OU). It also connects using the current user's security context. The container variable is similar to the domainContext in the previous example of creating a user principal.
Figure 4 Create an Account with System.DirectoryServices
DirectoryEntry container =
new DirectoryEntry("LDAP://ou=TechWriters,dc=fabrikam,dc=com");
// create a user directory entry in the container
DirectoryEntry newUser = container.Children.Add("cn=user1Acct", "user");
// add the samAccountName mandatory attribute
newUser.Properties["sAMAccountName"].Value = "User1Acct";
// add any optional attributes
newUser.Properties["givenName"].Value = "User";
newUser.Properties["sn"].Value = "One";
// save to the directory
newUser.CommitChanges();
// set a password for the user account
// using Invoke method and IadsUser.SetPassword
newUser.Invoke("SetPassword", new object[] { "pAssw0rdO1" });
// require that the password must be changed on next logon
newUser.Properties["pwdLastSet"].Value = 0;
// enable the user account
// newUser.InvokeSet("AccountDisabled", new object[]{false});
// or use ADS_UF_NORMAL_ACCOUNT (512) to effectively unset the
// disabled bit
newUser.Properties["userAccountControl"].Value = 512;
// save to the directory
newUser.CommitChanges();
Aside from the fact that this code is quite a bit longer than the AccountManagement example, it is also more complex. We need to know more about the underlying data model in the directory and we need to know how to handle certain account management features, such as setting an initial password and enabling a user object by undoing the disabled flag. This gets a bit clunky when you have to use the reflection-based Invoke and InvokeSet methods to call underlying ADSI COM interface members. It is this complexity that has made directory services programming so frustrating for many developers.
In addition, if we were trying to create the same user account in AD LDS or the local SAM database, there would have been even more differences. AD LDS and Active Directory Domain Services use a different mechanism for enabling user accounts (the msds-userAccountDisabled attribute in AD LDS rather than the userAccountControl attribute in Active Directory Domain Services), while the SAM store requires you to call an ADSI interface member. This lack of consistency among the account management features of the three directory stores was one of the key design challenges that the AccountManagement namespace had to overcome. Now, by simply changing the PrincipalContext used to create the UserPrincipal object, you can easily switch between directory stores and get one consistent set of account management features.
The Protocols namespace introduced in the .NET Framework 2.0 doesn't address any of these needs. Its purpose is to provide systems-level LDAP programmers with a more powerful and flexible API for building LDAP-based applications. However, it provides even less abstraction over the LDAP model than System.DirectoryServices, and it does nothing to simplify the differences among various directories. In addition, it is not intended for use with the local SAM database (which is not an LDAP directory). The online code samples that accompany this article include a sample similar to the AccountManager example following Figure 3. It performs the same task but takes three times as many lines of code.
The PrincipalContext in Figure 5 shows the ContextType enumeration referring to a machine in order to target a SAM store. The next parameter targets the actual machine by name (or IP address), and the last two values provide the credentials of an account that can perform the account creation.
Figure 5 Creating a SAM Database User Account
PrincipalContext principalContext = new PrincipalContext(
ContextType.Machine. "computer01", "adminUser", "adminPassword");
UserPrincipal user = new UserPrincipal(principalContext,
"User1Acct", "pass@1w0rd01", true);
//Note the difference in attributes when accessing a different store
//the attributes appearing in IntelliSense are not derived from the
//underlying store
user.Name = "User One";
user.Description = "User One";
user.ExpirePasswordNow();
user.Save();
We set the Name and Description properties because the SAM store does not contain givenName, sn, or displayName attributes, as in Active Directory Domain Services. Even though AccountManagement attempts to provide a consistent experience across all three directory stores, there are differences in the underlying models. However, it will throw an InvalidOperationException if you attempt to get an attribute that is not available in the underlying store.
Figure 3 and Figure 5 are two examples of creating user accounts; they show the consistent programming model inherent in the AccountManagement namespace when operating against any store. The PrincipalContext in Figure 6 uses ContextType.ApplicationDirectory to target an AD LDS store, as was shown in Figure 3. The next parameter shows the AD LDS server. In this case, sea-dc-02.fabrikam.com is the Fully Qualified Domain Name (FQDN) of the server hosting the AD LDS instance, and the instance is listening on port 50001 for SSL communications. Note that the code download uses non-SSL communications over port 50000. This is not secure but is fine for your own testing.
Figure 6 Creating an ADAM or AD LDS User Account
PrincipalContext principalContext = new PrincipalContext(
ContextType.ApplicationDirectory,
"sea-dc-02.fabrikam.com:50001",
"ou=ADAM Users,o=microsoft,c=us",
ContextOptions.SecureSocketLayer ContextOptions.SimpleBind,
"CN=administrator,OU=ADAM Users,O=Microsoft,C=US",
"P@55w0rd0987");
UserPrincipal user = new UserPrincipal(principalContext,
"User1Acct", "pass@1w0rd01", true);
user.GivenName = "User";
user.Surname = "One";
user.Save();
The next parameter designates the container where you want to perform a CRUD (Create/Read/Update/Delete) operation. This example specifies a user stored in AD LDS for performing our CRUD operations, so it must use an LDAP simple bind, and it's combined with SSL for security. Even though AD LDS supports secure DIGEST authentication natively, ADSI itself does not. Once again, our example is practically identical to the two previous examples with only the PrincipalContext being significantly different.
The AccountManagement namespace provides a comprehensive set of account management features such as password expiration and account unlock. We don't have space here to demonstrate all of them, but the bottom line is that they work consistently and reliably across directory stores, and they take the hassle out of implementing such capabilities.
Creating Groups and Computers
You've seen that creating a user account is simple and consistent from one DirectoryServices store to another. This consistency extends to creating the other two supported DirectoryServices objects, groups, and computers. Like the UserPrincipal class, the GroupPrincipal and ComputerPrincipal classes inherit from the Principal abstract class and operate similarly. For example, to create a group named Group01 in Active Directory Domain Services, AD LDS, or the SAM account database, you can use this code:
GroupPrincipal group = new GroupPrincipal(principalContext,
"Group01");
group.Save();
In each case, differences are contained in the PrincipalContext class that establishes context with the different stores. The code used to create a computer object follows a similar pattern of creating the principal object using a context and then saving the object to the target of the principal context:
ComputerPrincipal computer = new ComputerPrincipal(domainContext);
computer.DisplayName = "Computer1";
computer.SamAccountName = "Computer1$";
computer.Enabled = true;
computer.SetPassword("p@ssw0rd01");
computer.Save();
Once again, AccountManagement takes care of unifying the interaction model for all of the supported identity stores. This sample shows the proper syntax for creating a computer object that can be joined to the domain (which requires a trailing $ in the sAMAccountName attribute) but sets the display name and common name so the $ is not included. Note that since the SAM database and AD LDS do not contain the computer class, AccountManagement will only allow you to create this type of object inside a domain-based PrincipalContext. Additionally, only Active Directory Domain Services contains a variety of group scopes—global, universal, and domain local—and contains both security and distribution groups. Therefore, the GroupPrincipal class provides nullable properties that allow you to set these values when necessary.
Managing Group Membership
The AccountManagement namespace also simplifies managing group membership. Before AccountManagement, there were lots of idiosyncrasies and inconsistencies among managing groups in different stores. Managing groups with many members was programmatically difficult. In addition, you had to use COM interop to manage SAM group membership and use LDAP attributes to manage Active Directory Domain Services and AD LDS groups. But now, the Members property of the GroupPrincipal class lets you enumerate a group's membership and manage its members. It all just works.
Another seemingly simple operation that is actually difficult to achieve is getting all the groups to which a user belongs. AccountManagement provides several methods to help you. The Principal base class includes two GetGroups methods and two IsMemberOf methods that, respectively, get the group membership of any Principal type and check to see if that principal is a member of a group. Also, UserPrincipal provides a special GetAuthorizationGroups method that returns the fully expanded security group membership of any UserPrincipal type. Figure 7 shows how you can use the GetAuthorizationGroups method.
Figure 7 Using the GetAuthorizationGroups Method
string userName = "user1Acct";
// find the user in the identity store
UserPrincipal user =
UserPrincipal.FindByIdentity(
adPrincipalContext,
userName);
// get the groups for the user principal and
// store the results in a PrincipalSearchResult object
PrincipalSearchResult
user.GetAuthorizationGroups();
// display the names of the groups to which the
// user belongs
Console.WriteLine("groups to which {0} belongs:", userName);
foreach (Principal result in results)
{
Console.WriteLine("name: {0}", result.Name);
}
Yet another tricky operation made simple with AccountManagement is the task of expanding group membership across trusted domains or with foreign security principals. The GetGroups(PrincipalContext) method on the Principal class does the heavy lifting for you.
Finding Ourselves
Another task programmers often struggle with is finding objects in the directory. While LDAP is not a particularly complex query language compared to the syntaxes that developers tackle routinely, it is nonetheless unfamiliar. Additionally, even if you know the rudiments of LDAP, it is often difficult to figure out how to use it to perform common tasks.
Once again, AccountManagement makes these tasks a breeze by helping you find objects with the FindByIdentity method. This method is part of the Principal abstract class from which the UserPrincipal, GroupPrincipal, and ComputerPrincipal classes inherit. Therefore, whenever you need to search for one of these principal types, FindByIdentity is a good friend to have.
FindByIdentity contains two overloads, both of which take a PrincipalContext and value to find. For the value, you can specify any of the supported identity types: SamAccountName, Name, UserPrincipalName, DistinguishedName, Sid, or Guid. The second overload also allows you to explicitly define the identity type you will specify as the value.
Starting with the simpler overload, here's how you can go about using FindByIdentity to return the user account we created in the previous examples:
UserPrincipal user = UserPrincipal.FindByIdentity(principalContext, "user1Acct");
Once you have a context (stored in the principalContext object), you use the FindByIdentity method to retrieve the principal object, in this case a UserPrincipal. After establishing context to any supported identity store, the code for finding the identity is always the same.
The second FindByIdentity constructor allows you to be explicit about the identity format you will specify. When you use this constructor, you must match the value's format to the identity type you specify. For instance, this code will properly return a UserPrincipal using its distinguished name provided the object exists in the directory and in the specified location:
UserPrincipal user = UserPrincipal.FindByIdentity(
adPrincipalContext,
IdentityType.DistinguishedName,
"CN=User1Acct,OU=TechWriters,DC=FABRIKAM,DC=COM");
In contrast, this code will not return a UserPrincipal since the IdentityType enumeration specifies a DistinguishedName format, but the value is not in this format:
UserPrincipal user = UserPrincipal.FindByIdentity(
adPrincipalContext,
IdentityType.DistinguishedName,
"user1Acct");
Format is important. For example, if you decide to use the GUID or SID IdentityTypes, you must use the standard COM GUID format and the Security Descriptor Description Language (SDDL) format, respectively, for the value. The code download for this article provides two methods (FindByIdentityGuid and FindByIdentitySid) that show the proper format. Note that you must change the GUID or SID values in these methods in order to find a match in your directory store. Obtaining either format is easy using the PrincipalSearcher class, as we'll show you in just a moment.
Now that you've found a Principal object and bound to it, you can easily perform operations on it. For example, you can add a user to a group, like so:
// get a user principal
UserPrincipal user =
UserPrincipal.FindByIdentity(adPrincipalContext, "User1Acct");
// get a group principal
GroupPrincipal group =
GroupPrincipal.FindByIdentity(adPrincipalContext, "Administrators");
// add the user
group.Members.Add(user);
// save changes to directory
group.Save();
Here, we use the FindByIdentity method to first find a user and then a group. Once the code obtains these principal objects, we call the Add method of the group's Members property to add the user principal to the group. Finally, we call the group's Save method to save the change to the directory.
Finding Matches
You can also use the powerful Query by Example (QBE) facility and the PrincipalSearcher class to find an object based on defined criteria. We will explain more about QBE and the PrincipalSearcher class, but first we want to examine a simple search example. Figure 8 shows how you can find all user accounts beginning with a name/cn prefix of "user" that are disabled.
Figure 8 Using PrincipalSearcher
// create a principal object representation to describe
// what will be searched
UserPrincipal user = new UserPrincipal(adPrincipalContext);
// define the properties of the search (this can use wildcards)
user.Enabled = false;
user.Name = "user*";
// create a principal searcher for running a search operation
PrincipalSearcher pS = new PrincipalSearcher();
// assign the query filter property for the principal object
// you created
// you can also pass the user principal in the
// PrincipalSearcher constructor
pS.QueryFilter = user;
// run the query
PrincipalSearchResult
Console.WriteLine("Disabled accounts starting with a name of 'user':");
foreach (Principal result in results)
{
Console.WriteLine("name: {0}", result.Name);
}
The PrincipalContext variable, adPrincipalContext, points to an Active Directory domain, but it could just as easily point to an AD LDS application partition. After establishing context, notice that the code creates a new UserPrincipal object. This is an in-memory representation of the principal for the search operation. Once you've created this principal, you then set the properties that limit the search results. The next two code lines demonstrate some limits you can set—all disabled user accounts where the user name begins with some value. Note that the property value for the Name attribute supports wildcards.
If you're already familiar with the LDAP dialect for setting up search filters, you'll immediately appreciate why QBE is a novel and more intuitive alternative. With QBE, you set up an example object that you then use for the query operation. To clearly demonstrate that QBE is simpler than the typical DirectoryServices search dialect, here's the LDAP dialect for setting up a filter equivalent to the QBE object created in Figure 8:
(&(objectCategory=person)(objectClass=user)(name=user*)(userAccount
Control:1.2.840.113556.1.4.803:=2))
As you can see, the LDAP dialect is quite a bit more complicated, and it won't work for AD LDS because the Active Directory LDS user schema uses the msDS-UserAccountDisabled attribute instead of the userAccountControl attribute shown in the LDAP dialect. Once again, AccountManagement handles these differences for us behind the scenes.
After setting up the QBE object shown in Figure 8, we create a PrincipalSearcher object and assign its QueryFilter property that the Principal object created earlier in the code. Note that you can also pass the user principal in the PrincipalSearcher constructor, rather than setting the QueryFilter property. We then run the query, calling the FindAll method of the PrincipalSearcher and assigning the returned results to the PrincipalSearchResult generic list. The PrincipalSearchResult list stores the returned Principal objects. Lastly, the code enumerates the list of principals and displays the Name attribute of each returned principal.
Note that QBE does not work for referential attributes. That is, attributes that are not owned by the QBE object cannot be used to configure your in-memory representation of the object.
You can do a lot more in the foreach loop. For example, you can enable the disabled user accounts or delete them. If you're after just read operations, keep in mind that if you do point to some other identity store, the attributes you return must exist in that store. For example, since an AD LDS user doesn't contain the sAMAccountName attribute, it wouldn't make sense to try to return this attribute in the results.
Difficult Search Operations Made Easy
There are other powerful FindBy methods that, when coupled with the PrincipalSearchResult class, can retrieve information about user and computer principals that is otherwise difficult to retrieve. Figure 9 demonstrates how to retrieve the name of each user who changed his password today. This example uses the FindByPasswordSetTime method and the PrincipalSearchResult class. Without AccountManagement, this operation is complicated because the underlying pwdLastSet attribute is stored in the directory as a Large Integer.
Figure 9 Retrieving Users Who Reset Their Password Today
// get today's date
DateTime dt = DateTime.Today;
// run a query
PrincipalSearchResult
UserPrincipal.FindByPasswordSetTime(
adPrincipalContext,
dt,
MatchType.GreaterThanOrEquals);
Console.WriteLine("users whose password was set on {0}",
dt.ToShortDateString());
foreach (Principal result in results)
{
Console.WriteLine("name: {0}", result.Name);
}
The code download for this article contains examples of using other FindBy methods. They all operate similarly to that which we've shown you in Figure 9.
FindBy methods are convenient shortcuts to information that is otherwise hard to retrieve. However, they are not appropriate if you need to further filter the results using the QBE facility. An important nuance here is that the associated attribute is read-only and therefore cannot be set on a QBE object, just as it cannot be set by a user on the object to which the QBE refers. To use the QBE, you use the equivalent read-only property in your example principal object combined with the AdvancedSearchFilter property. More about that will come later. Figure 10 lists more FindBy methods and shows the equivalent read-only properties that you can use instead of the FindBy method in a search.
Figure 10 Other FindBy Methods
Method Name Read-Only Property Description
FindByLogonTime LastLogonTime Accounts that have logged on within the specified time.
FindByExpirationTime AccountExpirationDate Expired accounts within the specified time.
FindByPasswordSetTime LastPasswordSetTime Accounts whose password was set within the specified time.
FindByLockoutTime AccountLockoutTime Accounts locked out within the specified time.
FindByBadPasswordAttempt LastBadPasswordAttempt Bad password attempts within the specified time.
No equivalent method BadLogonCount Accounts that have attempted to logon the specified number of times but have failed to logon.
You can't set a value on a read-only property when configuring a QBE. So how can you work with the property in a search operation? You can retrieve a result set and then perform a conditional test using the read-only property when enumerating the result set. Just keep in mind that this approach is not advisable for potentially large resultsets since the code must first retrieve results unfiltered for the read-only property and then filter the returned resultset by the read-only property. The PrincipalSearchEx6v2 method in the code download demonstrates this less-than-ideal approach.
The Directory Services team addressed this QBE limitation by adding the AdvancedSearchFilter property to the AuthenticablePrincipal class. AdvancedSearchFilter allows you to search based on the read-only properties and then combine them with other properties you can set using the QBE mechanism. Figure 11 demonstrates how you can use the LastBadPasswordAttempt read-only property of the UserPrincipal class to return a list of users who had a bad password attempt today.
Figure 11 AdvancedSearchFilter with a Read-Only Property
DateTime dt = DateTime.Today;
// create a principal object representation to describe
// what will be searched
UserPrincipal user = new UserPrincipal(adPrincipalContext);
user.Enabled = true;
// define the properties of the search (this can use wildcards)
user.Name = "*";
//add the LastBadPasswordAttempt >= Today to the query filter
user.AdvancedSearchFilter.LastBadPasswordAttempt
(dt, MatchType.GreaterThanOrEquals);
// create a principal searcher for running a search operation
// and assign the QBE user principal as the query filter
PrincipalSearcher pS = new PrincipalSearcher(user);
// run the query
PrincipalSearchResult
Console.WriteLine("Bad password attempts on {0}:",
dt.ToShortDateString());
foreach (UserPrincipal result in results)
{
Console.WriteLine("name: {0}, {1}",
result.Name,
result.LastBadPasswordAttempt.Value);
}
Authenticating Users
Developers who build directory-based applications often need to authenticate the credentials of users stored in the directory, especially when using AD LDS. Before the .NET Framework 3.5, programmers accomplished this task using the DirectoryEntry class in System.DirectoryServices to force an LDAP bind operation under the hood. However, be careful: it is exceedingly easy to write poor versions of this code that are not secure, that are slow, or that are just plain clunky. Additionally, ADSI itself is not designed for this type of operation and can fail under high-use conditions due to the way it caches LDAP connections internally.
As we've already discussed, the System.DirectoryServices.Protocols assembly in the .NET Framework 2.0 contains lower-level LDAP classes that use a connection-based programming metaphor. This design allows you to overcome the inherent limitations in ADSI but at the expense of having to write more complicated code.
In the .NET Framework 3.5, AccountManagement delivers both the power and ease of use offered by the ActiveDirectoryMembershipProvider implementation in ASP.NET to programmers working in any environment. Additionally, the AccountManagement namespace allows you to authenticate credentials against the local SAM database if needed.
The two ValidateCredentials methods on the PrincipalContext class provide credential validation. You first create an instance of a PrincipalContext using the directory you wish to validate against and specify the appropriate options. After getting context, you test whether ValidateCredentials returns true or false based on the supplied user name and password values. Figure 12 shows an example of authenticating a user in AD LDS.
Figure 12 Authenticating a User in AD LDS
// establish context with AD LDS
PrincipalContext ldsContext =
new PrincipalContext(
ContextType.ApplicationDirectory,
"sea-dc-02.fabrikam.com:50000",
"ou=ADAM Users,O=Microsoft,C=US");
// determine whether a user can validate to the directory
Console.WriteLine(
ldsContext.ValidateCredentials(
"user1@adam",
"Password1",
ContextOptions.SimpleBind +
ContextOptions.SecureSocketLayer));
This approach is most useful when you want to validate many different sets of user credentials quickly and efficiently. You create a single PrincipalContext object for the directory store in question and reuse that object instance for each call to ValidateCredentials. The PrincipalContext can reuse the connection to the directory, which results in good performance and scalability. And calls to ValidateCredentials are thread-safe, so your instance can be used across threads for this operation. It's important to note that the credentials used to create the PrincipalContext are not changed by calls to ValidateCredentials—the context and method call maintain separate connections.
By default, AccountManagement uses secure Windows Negotiate authentication and attempts to use SSL when performing a simple bind against AD LDS. We recommend that you always be explicit with the type of authentication you want to perform and the connection protection you wish to use (if applicable), but at least the defaults err on the side of caution.
Active Directory Domain Services in Windows Server® 2003 and later and AD LDS both include fast concurrent binding, which is designed for high-performance authentication operations. It validates a user's password without actually building a security token for the user. Unlike in a normal bind operation, with fast concurrent binding the state of the LDAP connection remains unbound. You can use fast concurrent binding to perform bind operations on the same connection repeatedly and simply check for a failed password attempt. This feature is not an available option through ADSI or System.DirectoryServices, but it is exposed as an option in the Protocols namespace.
AccountManagement uses fast concurrent binding whenever possible and enables this option automatically. This is the reason that the AccountManagement layer also appears above the Protocols layer in Figure 1. Note that it only works in simple bind mode, which passes plain text credentials on the network. Therefore, fast concurrent binding should always be combined with SSL for security.
Extensibility Model
Directory Services Resources
.NET Framework 3.5 Beta 2 Download
System.DirectoryServices.AccountManagement Namespace Overview
System.DirectoryServices.AccountManagement Namespace Documentation
About Active Directory Lightweight Directory Services
Windows Server 2003 Active Directory Application Mode
Introduction to System.DirectoryServices.Protocols
Introduction to System.DirectoryServices.ActiveDirectory
Another area where AccountManagement really shines is its extensibility model. Many developers will choose to use the various Principal-derived classes for building custom provisioning systems for both Active Directory Domain Services and AD LDS. In many cases (especially with AD LDS), an organization will add custom schema extensions to the directory to support its own metadata for users and groups.
Using the .NET Framework object-oriented design and attribute-based extensible metadata, AccountManagement makes it easy to create custom security principal classes that support your custom schema. By simply inheriting from one of the Principal-derived classes and marking your class and properties with the appropriate attributes, your custom principal class can read and write these directory attributes as well as the attributes already supported by the built-in types.
An important nuance worth noting is that the extensibility mechanism provided by AccountManagement is designed for use by security principals stored in Active Directory Domain Services or AD LDS. It doesn't have a focus on non-Microsoft LDAP directories. If you wish to build a framework for provisioning in non-Microsoft LDAP directories, you should use the lower-level classes in the Protocols namespace. (In addition, the extensibility model is not intended for use with local SAM accounts, as the SAM schema is not extensible.)
Consider an AD LDS directory that uses the standard LDAP user class for storing security principals for an application. In addition, the LDAP directory schema is extended to support a special attribute for identifying user objects called msdn-subscriberID. Figure 13 demonstrates how to create a custom class that can provision user objects and also provide create, read, and write operations against this attribute.
Figure 13 Our Sample MsdnUser Class
[DirectoryObjectClass("user")]
[DirectoryRdnPrefix("CN")]
class MsdnUser : UserPrincipal
{
public MsdnUser(PrincipalContext context)
: base(context) { }
public MsdnUser(
PrincipalContext context,
string samAccountName,
string password,
bool enabled
)
: base(
context,
samAccountName,
password,
enabled
)
{
}
[DirectoryProperty("msdn-subscriberID")]
public string MsdnSubscriberId
{
get
{
object[] result = this.ExtensionGet("msdn-subscriberID");
if (result != null) {
return (string)result[0];
}
else {
return null;
}
}
set { this.ExtensionSet("msdn-subscriberID", value); }
}
}
Notice that the code inherits from the UserPrincipal class and is decorated with two attributes: DirectoryObjectClass and DirectoryRdnPrefix. Both of these attributes are required for principal extension classes. The DirectoryObjectClass attribute determines the value the supported store (Active Directory Domain Services or AD LDS) uses for the objectClass directory attribute when creating instances of this object in the directory. Here, this is still the default AD LDS user class, but in reality it could be anything. The DirectoryRdnPrefix attribute determines the RDN (relative distinguished name) attribute name to use for naming objects of this class in the directory. Under Active Directory Domain Services, you cannot change the RDN prefix—it is always CN for security principal classes. Under AD LDS, however, there is more flexibility and you can use a different RDN if desired.
Our class has a property called MsdnSubscriberID that returns a string. This class is marked with the DirectoryProperty attribute, specifying the LDAP schema attribute used to store the property value. The underlying framework uses this value for optimizing search operations against this Principal type.
Our property get and set implementations use the protected ExtensionGet and ExtensionSet methods of the Principal base class to read and write values to the underlying property cache. These methods support storing values in memory for objects that have not yet been persisted to the database/identity store. In addition, these methods support reading and writing values from existing objects. Since LDAP directories support attributes of various types and also allow an attribute to contain multiple values, these methods use the object[] type for reading and writing values. This flexibility is nice, but if you want to provide a strongly typed scalar string value on top of an array of object types, you have to do a little extra work, as our implementation demonstrates. The result for consumers of our custom MsdnUser class is an interface that is very easy to program.
The ability to provide strongly typed values on top of our directory schema is one of the most useful features of this extensibility model. Beyond simple string types, you can also use the rich type system offered by the .NET Framework to do such things as represent the Active Directory Domain Services jpgPhoto attribute as a System.Drawing.Image or a System.IO.Stream instead of the default byte[] that you would usually get by reading the value from System.DirectoryServices.
The code download for this article provides a few more samples to demonstrate these capabilities. It also has some schema extensions (via a standard LDIF formatted file, msdnschema.ldf) that you can use to extend your test directory with the MsdnUser class. We also provided some valuable links in the "Directory Services Resources" sidebar.
Final Thoughts
AccountManagement is a much-needed managed code addition to the rich directory services programming model offered by Microsoft. With the AccountManagement namespace, developers now have a set of strongly typed principals for common CRUD and search operations.
The namespace encapsulates directory service programming best practices to help you write secure and high-performance managed code. In addition, AccountManagement is extensible, allowing you to fully interact with your custom directory objects in Active Directory Domain Services and AD LDS.
Acknowledgement that this is a MSDN article by Joe Kaplan and Ethan Wilansky would be nice
ReplyDelete