This article's example involves creating a Data Access Layer (DAL) to wrap around the System.DirectoryServices namespace to allow us to perform very rudimentary user administration tasks. A DAL is a method for abstracting the actual complexities of performing the data access. For example, if you were to write a DAL around an Access database, you could hide all work with ADO.NET within your objects, and when a developer needs to perform any database interaction, the developer simply needs to use these objects and not worry about specific implementation details. The developer never has to worry about ADO.NET (or even SQL). If you decide later to upgrade to Microsoft SQL Server, you simply need to change the DAL to work with the new database, and nothing else needs to be changed. For more information, read my introduction to N-Tier Application Architecture at http://www.15seconds.com/Issue/011023.htm.
The User Object
The first object we will create is used to represent the current state of any given User. This object abstracts the "User" DirectoryEntry class, and it resembles a more tangible User object for clarity.
namespace DSHelper {
public class DSUser {
public DSUser(System.DirectoryServices.DirectoryEntry user) {
this.domainName=user.Path;
this.Username=user.Name;
this.Password=user.Password;
try {
this.FullName=Convert.ToString(user.Invoke("Get", new object[] {"FullName"}));
this.Description=Convert.ToString(user.Invoke("Get", new object[] {"Description"}));
this.PasswordExpired=Convert.ToInt32(user.Invoke("Get", new object[] {"PasswordExpired"}));
this.RasPermissions=Convert.ToInt32(user.Invoke("Get", new object[] {"RasPermissions"}));
this.MaxStorage=Convert.ToInt32(user.Invoke("Get", new object[] {"MaxStorage"}));
this.PasswordAge=Convert.ToInt32(user.Invoke("Get", new object[] {"PasswordAge"}));
this.HomeDirectory=Convert.ToString(user.Invoke("Get", new object[] {"HomeDirectory"}));
this.LoginScript=Convert.ToString(user.Invoke("Get", new object[] {"LoginScript"}));
this.HomeDirDrive=Convert.ToString(user.Invoke("Get", new object[] {"HomeDirDrive"}));
this.userDirEntry=user;
}catch(Exception e) {
throw(new Exception("Could not load user from given DirectoryEntry"));
}
}
public DSUser(string Username, string Password, string DomainName) {
domainName=DomainName;
if(domainName=="" || domainName==null) domainName=Environment.MachineName;
username=Username;
password=Password;
}
private object groups=null;
public object Groups{get{return groups;} set{groups=value;}}
//I removed the public getters and setters, and the private member variables
//download the full source for a complete listing
}
}
Figure 1.2 The User Object
Our user object has two default constructors. The first is used to initialize our user with a given DirectoryEntry object. It will attempt to use the Invoke method to "Get" the user's properties off of the object.
The second constructor is used to create a new user. You only need to pass in the three required parameters and it will create a new DSUser object for you to use when creating a new user. Notice that no AD work is being done, thus no real user in your AD is being created yet.
The DAL
The next step is to create the DAL wrapper for AD. The next few sections of code will be a break down of the entire UserAdmin DAL. For a complete listing please see the downloadable source ZIP file.
We first create and initialize the variables we will need in our UserAdmin class:
#region default property initialization
//our error logging device. keeping it simple to avoid bloating sample code
System.Text.StringBuilder errorLog = new System.Text.StringBuilder();
private System.Boolean error=false;
public System.Boolean Error{get{return error;}}
public string ErrorLog{get{return errorLog.ToString();}}
//setup default properties
private string loginPath="WinNT://"+Environment.MachineName+",computer";
private string domainName=null;
private string loginUsername=null;
private string loginPassword=null;
private System.DirectoryServices.AuthenticationTypes authenticationType = System.DirectoryServices.AuthenticationTypes.None;
private System.DirectoryServices.DirectoryEntry AD=null;
private System.Boolean connected=false;
#endregion
Figure 1.3 Default Property Initialization
Notice how we hardcode the LoginPath to be the WinNT provider. This would need to be changed in order for this DAL to work with any of the other AD service providers. Also notice that I simplified error handling by using a System.Text.StringBuilder object to hold the error log. In the case of an error, it will simply be appended to this, and the "error" Boolean variable will be set.
The next region of code to examine is the constructors for our class (see Figure 1.4, below).
#region .ctor's
public UserAdmin() {
Connect();
}
/// <summary>
/// Allows you to create a UserAdmin class specifying all credentials, if needed
/// </summary>
/// <param name="LoginUsername"></param>
/// <param name="LoginPassword"></param>
/// <param name="AuthenticationType"></param>
/// <param name="DomainName"></param>
public UserAdmin(string LoginUsername, string LoginPassword,
System.DirectoryServices.AuthenticationTypes AuthenticationType, string DomainName) {
loginUsername=LoginUsername;
loginPassword=LoginPassword;
authenticationType=AuthenticationType;
if(DomainName=="" || DomainName==null) DomainName=System.Environment.UserDomainName;
domainName=DomainName;
Connect();
}
/// <summary>
/// An alternative way to get a UserAdmin class which allows you to specify an alternative LoginPath, for example LDAP, or IIS
/// </summary>
/// <param name="LoginPath"></param>
/// <param name="LoginUsername"></param>
/// <param name="LoginPassword"></param>
/// <param name="AuthenticationType"></param>
/// <param name="DomainName"></param>
public UserAdmin(string LoginPath, string LoginUsername, string LoginPassword,
System.DirectoryServices.AuthenticationTypes AuthenticationType, string DomainName) {
loginPath=LoginPath;
loginUsername=LoginUsername;
loginPassword=LoginPassword;
authenticationType=AuthenticationType;
if(DomainName=="" || DomainName==null) DomainName=System.Environment.UserDomainName;
domainName=DomainName;
Connect();
}
#endregion
Figure 1.4 Constructors
The first constructor provided is the basic default constructor. This allows our object to be created without any given parameters. Since there are no required pieces of data needed, we allow for this. This means that the object will connect to the local WinNT provider with no special security privileges.
The second constructor provided allows us to specify the login credentials for connecting to the AD for the given domain. This is handy when you want to connect to the any domain with the given credentials.
Finally, the last constructor adds the LoginPath parameter. This allows you to override the LoginPath, which gives you the ability to choose an alternative service provider than the default.
The next region of code is what I call the "Action Methods." There is actually quite an abundance of code so please refer to the downloadable source file while covering these methods. The methods in order are as follows:
public DSHelper.DSUser LoadUser(string username)
This method is used to take a username and find its DirectoryEntry in the current provider. If it cannot be found, it will simply return null.
public System.Boolean AddUserToGroup(string groupname, DSHelper.DSUser dsUser)
This method takes the name of an existing group and an instance of our custom DSUser class, and makes an attempt to add that user to the group provided. This is the first method where we can see impersonation being done. (Impersonation is discussed near the end of this article.) The core to this method is the line:
public System.Collections.ArrayList GetGroups()
We will use this method to get a list of all the groups in our domain for the given provider.
public System.Boolean DeleteUser(DSHelper.DSUser dsUser)
This will take the given user and completely delete it out of the Active Directory.
public System.DirectoryServices.DirectoryEntry SaveUser(DSHelper.DSUser dsUser)
No DAL would be complete without the ability to actually Save and Insert new records into our datastore. This method does both. It will first check the AD to see if the specified user exists or not. If it does, it will attempt to update it with the settings you provide to it. If the user does not exist, it will attempt to create it.
public System.Boolean Connect()
Lastly we need a way to connect to our datastore. The Connect() method is used to do just that. Notice, however it does not perform any impersonation or specify any credentials to use the AD at this point. We only provide credentials when we need too. This allows the DAL to be used in a Read-Only state when no credentials have been provided.
With that high-level overview, let's put it all together and see just how we would use this DAL to create an ASP.NET user administration page.
ASP.NET User Admin Page
This sample project gives you the ability to enter in the username and password for the administrator user (to actual make changes you will need to supply these credentials). It also offers a a text box so you can enter in the name of any user account for the domain, and it will list its properties. You can edit and save, or delete that user entirely.
Take time now to review the project in detail. Concentrate on the portions where we interact with the DAL. Think of new ways to improve the interface, and actually create a more useable and friendly design. Also consider how you could create an interface using a console application.
Impersonation
The word "Impersonation" indicates that we can act as another person or user. Within our ASP.NET applications this means we can temporarily act as another user, instead of the ASP.NET user that is the default account that IIS uses for anonymous access. We want to be able to allow our code to impersonate another user that has greater access to the AD resource that we need to modify. This is accomplished fairly easily.
#region setup impersonation via interop
//need to import from COM via InteropServices to do the impersonation when saving the details
public const int LOGON32_LOGON_INTERACTIVE = 2;
public const int LOGON32_PROVIDER_DEFAULT = 0;
System.Security.Principal.WindowsImpersonationContext impersonationContext;
[DllImport("advapi32.dll", CharSet=CharSet.Auto)]public static extern int LogonUser(String lpszUserName,
String lpszDomain,String lpszPassword,int dwLogonType,int dwLogonProvider,ref IntPtr phToken);
[DllImport("advapi32.dll", CharSet=System.Runtime.InteropServices.CharSet.Auto, SetLastError=true)]public
extern static int DuplicateToken(IntPtr hToken, int impersonationLevel, ref IntPtr hNewToken);
#endregion
Figure 1.5 Setting Up Impersonation
First we need to import a few methods from the advapi32.dll, including a couple of helpful constants. The variable named impersonationContext will be used to hold the Windows user prior to the impersonation operation.
Now that we have set up impersonation, here is an example of how to use it:
if(impersonateValidUser(this.LoginUsername, this.DomainName, this.loginPassword)) {
//Insert your code that runs under the security context of a specific user here.
//don't forget to undo the impersonation
undoImpersonation();
} else {
//Your impersonation failed. Therefore, include a fail-safe mechanism here.
}
Figure 1.6 Using Impersonation
We simply need to call the impersonateValidUser method with the valid username and password to impersonate that user. From then on, until undoImpersonation() is called, we will be performing all actions as the supplied username.
Authors Note:
To get impersonation working correctly under asp.net, change the processModel node in the machine.config
(c:\winnt\microsoft.net\framework\v%VERSION%\config\machine.config). The username attribute must be set to "System". For more information
regarding this change please see the security documentation on MSDN.