http://msdn.microsoft.com/en-us/library/aa479048.aspx
Jit Ghosh
Microsoft Corporation
May 2004
Applies To:
Microsoft® ASP.NET 2.0
Microsoft® Visual Studio® .NET 2005
Microsoft® Active Directory® Application Mode
Summary: This article discusses how to build a custom provider based on Microsoft Active Directory Application Mode for the ASP.NET 2.0 Membership Service. (32 printed pages)
Download the example code.
Contents
Introduction
Forms Authentication
Membership Service
The "Provider Model" Design Pattern
Configuring Your System
Active Directory Application Mode
Implementing a Custom Provider
Provider Initialization
User Creation
Retrieving User Information
Password Management, Storage, and Protection
Changing User Information
Running the Sample Provider
Conclusion
Related Books
Introduction
If you are developing web applications utilizing Microsoft® ASP.NET and have the need to secure your site from unauthorized access, you have surely investigated the various authentication and authorization techniques that ASP.NET 1.x enables. ASP.NET 2.0 improves further by introducing a set of "building block" services, which provide rich functionality related to many common web application features like site membership, role management, personalization, etc. These "building block" services have the added advantage of supporting a provider-model design pattern, that allows developers to "plug-in" custom providers underneath the service without disrupting application design.
In this article I will briefly discuss the Membership service in ASP.NET 2.0 and then show you how to build a custom provider for Membership.
Forms Authentication
Of the authentication techniques available in ASP.NET 1.x, Forms Authentication represents one of the most common scenarios encountered in web sites—a web site user logs in by providing a user name and password, and the web application backend validates this set of credentials against some underlying repository.
Forms Authentication provides the necessary infrastructure for enabling this scenario. Using configuration settings in the web.config, you can protect resources from anonymous access, and force redirection of a request to a login page. Once credentials have been successfully validated through this login page, you can use Forms Authentication APIs to create and associate a token with the user session, such that subsequent requests can be authenticated automatically, based on the presence of the token.
While Forms Authentication certainly makes your life easier, it does not address several aspects of implementing a comprehensive membership system. In addition to the features that Forms Authentication possesses, a Membership system typically exposes consistent APIs to perform tasks commonly associated with membership, such as:
- Creating, persisting and maintaining user credentials.
- Changing and resetting passwords.
- Associating security question and answers with passwords.
- Generating random passwords.
- Searching the membership repository for users.
Membership Service
The ASP.NET 2.0 Membership service builds on Forms Authentication by specifying a standard interface, to perform both the above mentioned tasks and several others. There are several parts to the Membership service architecture, as shown in Figure 1.
Figure 1. ASP.NET Membership service architecture
The focus of this article is to familiarize you with the provider layer in the above stack, and show you how to write a custom provider for the Membership service. I will take a cursory look at the other layers in the stack in the rest of this section, and then move on to the discussion about writing a provider.
At the top of the stack are the security server controls. These controls are a part of the framework and make it very easy for you to integrate Membership service functionality into your application's user interface with little or no code. They also support templates, thus allowing you to further enhance or modify their default user interface and customize them to your application needs. Table 1 lists out some of these controls and their uses.
Table 1. Security server controls
Control | Use |
---|---|
Login | Encapsulates a common login interface. |
LoginView | Supports separate views for anonymous users, logged in users and specific roles. |
LoginStatus | Displays the login status for the current user. |
LoginName | Displays the user name for the current logged in user. |
PasswordRecovery | Allows password recovery. Sends password via email. |
CreateUserWizard | Creates new users in the system with a wizard like interface. |
ChangePassword | Allows the changing of a password. |
There will be many scenarios in which you will encounter use-cases beyond the functionality of these controls, or you will need better control of how your application interacts with the Membership service. ASP.NET includes types in the System.Web.Security namespace that allow you that flexibility. In fact, you can see from Figure 1 that the various Security server controls mentioned earlier use these types to perform their tasks.
The two primary types that you will interact with the most are Membership and MembershipUser. The Membership class exposes most of the functionality of the Membership service API, and allows you to perform various tasks such as:
- Creating/updating/removing/retrieving Membership users.
- Validating user credentials.
- Enumerating users in the system using user name or email.
The MembershipUser class is the runtime representation of a persisted membership user. All of the methods in the Membership class that accept or return user information do so as an instance or a collection of MembershipUser. The MembershipUser class also exposes some of the more specific functionality that only makes sense in the context of an active user, such as changing or resetting passwords.
With that said, let us take a look at the provider layer of the stack.
The "Provider Model" Design Pattern
A common scenario encountered by many business applications which need a membership system is the use of a database system like SQL Server to store user credentials and other associated profile information. Alternatively, you may want to use a directory service for the same purpose, or maybe just the file system. Wouldn't it be great if you could implement your membership system on top of a repository that best suits your organization, while at the same time keep your application's architecture, design and code independent of this choice?
All of the ASP.NET 2.0 "Building Block" services, including Membership, implement what is commonly known as a "Provider Model" design pattern. A "Provider Model" design pattern separates the application logic for the service from its underlying storage repository by introducing a "provider" software component in between. This "provider" component exposes a standard interface to the service logic, but allows developers to implement the provider component on top of a repository of their choice.
In the case of Membership, the Membership and MembershipUser classes described earlier constitute the service logic for the Membership service. These classes, in turn, use a Membership Provider component to perform most of their interaction with the membership repository. The ASP.NET 2.0 Membership Service ships with several pre-built providers, notable among which are the ones for SQL Server and Microsoft® Access, implemented respectively in the types SQLMembershipProvider and AccessMembershipProvider found in the System.Web.Security namespace.
If you investigate these provider implementations, you will notice that both of these classes derive from an abstract base class named MembershipProvider, also found in System.Web.Security. To implement a custom Membership Provider, you will need to provide a concrete implementation of the MembershipProvider class to ASP.NET, and plug it into the system through appropriate configuration settings.
Before we go into the details of implementing our own custom provider, I want to show you how to configure ASP.NET to use one of the built-in providers. This should familiarize you with how the provider plugs into the system, and our custom provider will take the same route.
Configuring Your System
To configure your application to use the Membership Service, you need to add a section named membership to your web.config (for a machine wide setting, you can also use the machine.config). The listing below shows a sample configuration setting.
<membership defaultProvider="MembershipSqlProvider" userIsOnlineTimeWindow="15"> <providers> <add name="MembershipSqlProvider" type="System.Web.Security.SqlMembershipProvider, System.Web, Version=1.2.3400.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" connectionStringName="AspNetMembershipDatabase" enablePasswordRetrieval="false" enablePasswordReset="true" requiresQuestionAndAnswer="false" applicationName="/" requiresUniqueEmail="false" passwordFormat="Hashed" description="Stores and retrieves membership data from the local Microsoft SQL Server database"/> </providers> </membership>
The Membership section can have multiple providers listed within the providers subsection, using the add element once for each provider. Each provider entry includes attributes that specify a type that implements the provider, a name for the provider entry, and several other parameters that influence the provider's functionality. Of these parameters, only the type and the name of the provider are compulsory. Parameters like enablePasswordRetrieval or passwordFormat are optional, and a good provider implementation will provide sensible defaults for these, even if you did not specify them in your settings. Some of the other parameters, like connectionStringName, may or may not be present depending on the nature of the provider. If a provider was built on a repository other than a relational database, for example, it would probably accept its connection parameters some other way.
The Membership section defines a defaultProvider attribute that picks one of the listed providers as the default provider for the application. The userIsOnlineTimeWindow attribute describes the interval in minutes from the user's last activity in the system, beyond which the user is no longer considered to be online.
Also note that to secure resources from unauthorized access, you continue to use the <authentication> and the <authorization> section as you would for a regular forms authentication scenario.
Beyond providing the configuration settings, each provider will most likely have separate requirements to prepare the repository for Membership data. For instance, ASP.NET 2.0 ships with a command line tool named aspnet_regsql.exe that allows you to create the necessary membership schema in a SQL Server database for SqlMembershipProvider. For more details on these tools, please refer to the MSDN documentation for the provider.
Active Directory Application Mode
The sample provider that is discussed in this article uses Active Directory Application Mode as the underlying repository. Microsoft® Active Directory Application Mode™ (ADAM) is a fully featured implementation of Microsoft® Active Directory®, but much less complex to deploy and maintain. It's completely LDAP compliant, supports the same programming interfaces as Active Directory, but can be run as an independent service on Microsoft® Windows™ XP or Microsoft® Windows Server™ 2003. A detailed discussion of ADAM is beyond the scope of this article, but you can refer to http://www.microsoft.com/windowsserver2003/adam/default.mspx for more details on ADAM.
Implementing a Custom Provider
The first step in implementing the custom provider is to derive a class from the MembershipProvider abstract base class.
public class ADAMMembershipProvider : MembershipProvider { }
To provide a complete implementation, you will need to provide overrides for all of the methods and properties of the MembershipProvider class. To keep this article within manageable length, I will only discuss the pertinent specifics for these implementations. For details around how to implement each method or property, please look at the sample code accompanying this article.
Provider Initialization
The MembershipProvider, in turn, derives from the ProviderBase class. You will need to override the Initialize method, the mechanism by which the ASP.NET 2.0 configuration system passes on the values of the various configuration settings for the provider to your code at runtime, from this class. Once the provider is loaded, the runtime calls Initialize and passes the settings as name-value pairs in an instance of the NameValueCollection class.
public override void Initialize(string name, System.Collections.Specialized.NameValueCollection config) { _Name = name; try { if (config["server"] != null) _ADAMServerUri = config["server"]; if (config["userName"] != null) _ADAMUserName = config["userName"]; if (config["password"] != null) _ADAMPassword = config["password"]; if (config["topContainer"] != null) _ADAMTopContainer = config["topContainer"]; if (config["userContainer"] != null) _ADAMUserContainer = config["userContainer"]; if (config["applicationName"] != null) _ApplicationName = config["applicationName"]; if (config["enablePasswordRetrieval"] != null) _EnablePasswordRetrieval = Convert.ToBoolean(config["enablePasswordRetrieval"].ToLower()); . . . } catch (Exception Ex) { throw new System.Configuration.ConfigurationException( "There was an error reading the membership configuration settings", Ex); } }
A key to implementing a well behaved provider is to ensure that you either set reasonable defaults for the configuration settings that you deem to be optional, or throw appropriate and informative exceptions to indicate that a certain setting is mandatory, but missing.
In case of the sample provider, you will need to specify the URI of the ADAM instance, and optionally a user name and a password for the provider to bind to ADAM. Think of these parameters as being equivalent to the connection string setting that you would need to specify if you were using the SQL Server based provider. The default URI is set to localhost, and in case you do not specify a user name and password, the provider tries to bind to the ADAM instance without specifying credentials. This being a sample, I have chosen to specify these settings in clear text; for a production-grade provider based on ADAM, you should also secure the password specified here with some sort of encryption.
You may also optionally specify the topContainer and userContainer settings for the sample provider. The topContainer indicates the LDAP common name for the ADAM partition that will house the data for your membership system, and the userContainer indicates the LDAP common name for the container, within that partition, which contains all the user records. By default, they are set to "ASP.NET Security Provider" and "Users," respectively.
Below is a possible configuration setting for the ADAM Membership Provider sample.
<membership defaultProvider="ADAMProvider" userIsOnlineTimeWindow="15"> <providers> <add name="ADAMProvider" type="CustomProviders.ADAMMembershipProvider" server="localhost" userName="ADAMUser" password="ADAMPassword" enablePasswordRetrieval="true" enablePasswordReset="true" requiresQuestionAndAnswer="true" applicationName="/" requiresUniqueEmail="false" passwordFormat="Encrypted" description="Stores and retrieves membership data from the local Microsoft SQL Server database" decryptionKey="68d288624f967bce6d93957b5341f931f73d25fef798ba75" validationKey="65a31e547b659a6e35fdc029de3acce43f8914cb1b24 fff3e1aef13be438505b3f5becb5702d15bc7b98cd6fd2b7702 b46ff63fdc9ea8979f6508c82638b129a"/> </providers> </membership>
User Creation
The Membership service provides a logical partition of users by associating an application name with the user entry. This allows you to make duplicate user names in your repository unique by the associated application name; alternatively, by not specifying an application name, it allows you to make the same user available to multiple applications. A common validation that you should perform in any method that deals with user creation, user removal, or update of user information, is one for existence (or duplicity in case of creation), by either looking for the combination of the user name and the application name or just the user name (in case an application name has not been specified) in the repository.
Below is some code that tries to find a user in the ADAM datastore. Note the inclusion of the application name as well as the user name in the search filter, in case the current settings support partitioning user names by application name.
private string _ADAMUserSearchByUserNameFilterAppSpecific = "(&(&(objectClass=aspnetmembershipuser) (aspnetmembershipuserApplicationName={0})) (aspnetmembershipuserUserName={1}))"; private string _ADAMUserSearchByUserNameFilter = "(&(objectClass=aspnetmembershipuser)(aspnetmembershipuserUserName={0}))"; DirectoryEntry deUserContainer = null; DirectoryEntry deMembershipUser = null; deUserContainer = (_ADAMUserName != null && _ADAMPassword != null) ? new DirectoryEntry(_ADAMLDAPPathToUserContainer, _ADAMUserName, _ADAMPassword) : new DirectoryEntry(_ADAMLDAPPathToUserContainer); if (ADAMFindUser((_ApplicationName != null) ? string.Format(_ADAMUserSearchByUserNameFilterAppSpecific, _ApplicationName, name) : string.Format(_ADAMUserSearchByUserNameFilter, name), deUserContainer, out deMembershipUser) == false) { status = MembershipCreateStatus.DuplicateUsername; return null; }
This partitioning by application name may also require some thinking in terms of unique identifiers for user records, depending on the nature of the repository you are using for your custom provider. The challenge stems from the fact that most repositories need some sort of unique key—in case of the ADAM based sample provider, it is the common name (CN) of the user record that needs to be unique. Simply assigning the user name to be the CN will not work, since there can be users with the same user name belonging to different applications.
I have chosen to generate a globally unique identifier (GUID) and assign that as the CN of the user record. Another option could have been to concatenate the user name with the application name and use that as the CN for the entry, thus making it unique.
To allow creation of users through your providers, you must implement the CreateUser method. Your CreateUser implementation should validate for duplicity. You should also validate for the uniqueness of the supplied e-mail address, in case the requiresUniqueEmail setting is set to true. The CreateUser implementation returns the appropriate value from the MembershipCreateStatus enumeration through an out variable to indicate success/failure of the user creation effort, and a new instance of MembershipUser on successful user creation. The listing below shows a sample implementation for CreateUser.
public override MembershipUser CreateUser(string username, string password, string email, out MembershipCreateStatus status) { MembershipUser NewUser = null; DirectoryEntry deUserContainer = null; DirectoryEntry deMembershipUser = null; status = MembershipCreateStatus.UserRejected; try { deUserContainer = (_ADAMUserName != null && _ADAMPassword != null) ? new DirectoryEntry(_ADAMLDAPPathToUserContainer, _ADAMUserName, _ADAMPassword) : new DirectoryEntry(_ADAMLDAPPathToUserContainer); if (ADAMFindUser((_ApplicationName != null) ? string.Format(_ADAMUserSearchByUserNameFilterAppSpecific, _ApplicationName, username) : string.Format(_ADAMUserSearchByUserNameFilter, username), deUserContainer, out deMembershipUser) == true) { status = MembershipCreateStatus.DuplicateUsername; return null; } if (_RequiresUniqueEMail && ADAMFindUser((_ApplicationName != null) ? string.Format(_ADAMUserSearchByEmailFilterAppSpecific, _ApplicationName, email) : string.Format(_ADAMUserSearchByEmailFilter, email), deUserContainer, out deMembershipUser) == true) { status = MembershipCreateStatus.DuplicateEmail; return null; } Guid UserId = Guid.NewGuid(); DateTime TimeNow = DateTime.UtcNow; deMembershipUser = deUserContainer.Children.Add( string.Format(_ADAMCommonName, UserId.ToString()), _ADAMUserObjectClass); deMembershipUser.Properties[_ADAMPropUserId].Value = UserId.ToByteArray(); deMembershipUser.Properties[_ADAMPropUserName].Value = username; deMembershipUser.Properties[_ADAMPropApplicationName].Value = _ApplicationName; deMembershipUser.Properties[_ADAMPropPassword].Value = ConvertPasswordForStorage(password); deMembershipUser.Properties[_ADAMPropEMail].Value = email; deMembershipUser.Properties[_ADAMPropUserCreationTimestamp].Value = TimeNow; deMembershipUser.Properties[_ADAMPropIsApproved].Value = true; deMembershipUser.CommitChanges(); NewUser = new MembershipUser(this, username, email, null, null, true, (DateTime)deMembershipUser.Properties[ _ADAMPropUserCreationTimestamp].Value, DateTime.default, DateTime.default, DateTime.default); status = MembershipCreateStatus.Success; } catch (Exception Ex) { status = MembershipCreateStatus.ProviderError; NewUser = null; } finally { try { deMembershipUser.Close(); deUserContainer.Close(); } catch (Exception Ex) { } } return NewUser; }
Retrieving User Information
The MembershipProvider exposes several methods for retrieving user information, including ones that allow you to retrieve all users, users with matching user names or e-mails addresses, or a specific user.
The GetUser method should retrieve the user with a matching user name as an instance of MembershipUser, or return null if none is found. You should make sure to update the timestamp for last user activity in the system if the UserIsOnline parameter is set to true.
The listing below shows a sample implementation of GetUser.
public override MembershipUser GetUser(string name, bool userIsOnline) { DirectoryEntry deUserContainer = null; DirectoryEntry deMembershipUser = null; try { //let's first see if the user exists deUserContainer = (_ADAMUserName != null && _ADAMPassword != null) ? new DirectoryEntry(_ADAMLDAPPathToUserContainer, _ADAMUserName, _ADAMPassword) : new DirectoryEntry(_ADAMLDAPPathToUserContainer); if (ADAMFindUser((_ApplicationName != null) ? string.Format(_ADAMUserSearchByUserNameFilterAppSpecific, _ApplicationName, name) : string.Format(_ADAMUserSearchByUserNameFilter, name), deUserContainer, out deMembershipUser) == false) throw new ApplicationException(string.Format( "User {0} does not exist", name)); if (deMembershipUser != null) { deMembershipUser.RefreshCache(); if (userIsOnline) { deMembershipUser.Properties[_ADAMPropLastActivityTimestamp].Value = DateTime.UtcNow; deMembershipUser.CommitChanges(); } return new MembershipUser(this, (string)deMembershipUser.Properties[_ADAMPropUserName].Value, deMembershipUser.Properties[_ADAMPropEMail].Value == null ? String.Empty : (string)deMembershipUser.Properties[_ADAMPropEMail].Value, deMembershipUser.Properties[_ADAMPropPasswordQuestion].Value == null ? String.Empty : (string)deMembershipUser.Properties[_ADAMPropPasswordQuestion].Value, deMembershipUser.Properties[_ADAMPropComment].Value == null ? String.Empty : (string)deMembershipUser.Properties[_ADAMPropComment].Value, deMembershipUser.Properties[_ADAMPropIsApproved].Value == null ? false : (bool)deMembershipUser.Properties[_ADAMPropIsApproved].Value, deMembershipUser.Properties[_ADAMPropUserCreationTimestamp].Value == null ? DateTime.default : (DateTime)deMembershipUser.Properties[ _ADAMPropUserCreationTimestamp].Value, deMembershipUser.Properties[ _ADAMPropLastLoginTimestamp].Value == null ? DateTime.default : (DateTime)deMembershipUser.Properties[ _ADAMPropLastLoginTimestamp].Value, deMembershipUser.Properties[ _ADAMPropLastActivityTimestamp].Value == null ? DateTime.default : (DateTime)deMembershipUser.Properties[ _ADAMPropLastActivityTimestamp].Value, deMembershipUser.Properties[ _ADAMPropLastPasswordChangeTimestamp].Value == null ? DateTime.default : (DateTime)deMembershipUser.Properties[ _ADAMPropLastPasswordChangeTimestamp].Value); } } catch (Exception Ex) { throw new ApplicationException("Error getting user", Ex); } finally { try { deMembershipUser.Close(); deUserContainer.Close(); } catch (Exception Ex) { } } return null; }
The GetAllUsers, FindUsersByName and FindUsersByEmail methods all return multiple users, contained in an instance of the MembershipUser Collection class. GetAllUsers returns all users in the system for the current application, whereas FindUsersByName and FindUsersByEmail return users with matching user names or e-mail addresses, respectively. All three functions also accept a pageIndex and a pageSize parameter, and they also return the total record count as an out parameter named totalRecords. You should use the pageIndex and the pageSize parameters to return user records in a paged fashion.
The listing below shows you a sample implementation of the FindUsersByName method.
public override MembershipUserCollection FindUsersByName( string usernameToMatch, int pageIndex, int pageSize, out int totalRecords) { totalRecords = 0; DirectoryEntry deUserContainer = null; MembershipUserCollection RetVal = null; try { deUserContainer = (_ADAMUserName != null && _ADAMPassword != null) ? new DirectoryEntry(_ADAMLDAPPathToUserContainer, _ADAMUserName, _ADAMPassword) : new DirectoryEntry(_ADAMLDAPPathToUserContainer); RetVal = ADAMFindUsers((_ApplicationName != null) ? string.Format(_ADAMUserSearchByUserNameFilterAppSpecific, _ApplicationName, usernameToMatch) : string.Format(_ADAMUserSearchByUserNameFilter, usernameToMatch), deUserContainer, pageIndex, pageSize, out totalRecords); } catch (Exception Ex) { throw; } finally { try { deUserContainer.Close(); } catch (Exception Ex) { } } return RetVal; } private MembershipUserCollection ADAMFindUsers(string Filter, DirectoryEntry SearchRoot, int PageIndex, int PageSize, out int Count) { Count = 0; SearchResultCollection dsUserSearchResult = null; MembershipUserCollection RetVal = new MembershipUserCollection(); DirectorySearcher dsUser = new DirectorySearcher( SearchRoot, Filter); dsUser.SearchScope = SearchScope.OneLevel; try { if ((dsUserSearchResult = dsUser.FindAll()) != null) { Count = dsUserSearchResult.Count; if(PageSize == int.MaxValue) PageSize = Count; int StartIndex = (PageIndex - 1) * PageSize; for(int Idx = StartIndex; Idx < StartIndex + PageSize ; Idx++) { DirectoryEntry deMembershipUser = dsUserSearchResult[Idx].GetDirectoryEntry(); deMembershipUser.RefreshCache(); RetVal.Add(new MembershipUser(this, (string)deMembershipUser.Properties[_ADAMPropUserName].Value, deMembershipUser.Properties[_ADAMPropEMail].Value == null ? String.Empty : (string)deMembershipUser.Properties[_ADAMPropEMail].Value, deMembershipUser.Properties[ _ADAMPropPasswordQuestion].Value == null ? String.Empty : (string)deMembershipUser.Properties[_ADAMPropPasswordQuestion].Value, deMembershipUser.Properties[_ADAMPropComment].Value == null ? String.Empty : (string)deMembershipUser.Properties[_ADAMPropComment].Value, deMembershipUser.Properties[ _ADAMPropIsApproved].Value == null ? false : (bool)deMembershipUser.Properties[_ADAMPropIsApproved].Value, deMembershipUser.Properties[ _ADAMPropUserCreationTimestamp].Value == null ? DateTime.default : (DateTime)deMembershipUser.Properties[ _ADAMPropUserCreationTimestamp].Value, deMembershipUser.Properties[ _ADAMPropLastLoginTimestamp].Value == null ? DateTime.default : (DateTime)deMembershipUser.Properties[ _ADAMPropLastLoginTimestamp].Value, deMembershipUser.Properties[ _ADAMPropLastActivityTimestamp].Value == null ? DateTime.default : (DateTime)deMembershipUser.Properties[ _ADAMPropLastActivityTimestamp].Value, deMembershipUser.Properties[ _ADAMPropLastPasswordChangeTimestamp].Value == null ? DateTime.default : (DateTime)deMembershipUser.Properties[ _ADAMPropLastPasswordChangeTimestamp].Value)); deMembershipUser.Close(); } } return RetVal; } catch (Exception Ex) { throw new ApplicationException("Error retrieving users", Ex); } }
Password Management, Storage, and Protection
Since you will be storing user passwords, you are surely wondering about protecting these passwords. The ASP.NET Membership Service specifies three formats for storing a password—in clear text, encrypted, or hashed, as defined in the PasswordFormat enumeration. The table below gives a brief description of the three settings.
Table 2. Allowed password formats
Password Format Setting | Description | Password Retrieval |
---|---|---|
PasswordFormat.Clear | The password is stored in clear text. | Possible |
PasswordFormat.Hashed | A one-way hash of the password is stored. To compare a supplied password, a hash using the same key is calculated on the supplied password and then compared against the stored hash. | Not possible |
PasswordFormat.Encrypted | The password is stored in encrypted format. To perform password comparisons, the stored password can be decrypted first. | Possible |
The passwordFormat configuration setting allows you to set the desired format for the application, with hashed being the default format.
To support password hashing or encryption in your provider, you need to decide on a hashing or encryption scheme. The sample provider with this article uses a SHA1 hash algorithm to create a keyed-hash of the password, and a TripleDES symmetric encryption algorithm to provide password encryption.
The sample code listing below shows you how to hash or encrypt a password and convert an encoded password back to its readable format.
private byte[] ConvertPasswordForStorage(string Password) { System.Text.UnicodeEncoding ue = new System.Text.UnicodeEncoding(); byte[] uePassword = ue.GetBytes(Password); byte[] RetVal = null; switch (_PasswordFormat) { case MembershipPasswordFormat.Clear: RetVal = uePassword; break; case MembershipPasswordFormat.Hashed: HMACSHA1 SHA1KeyedHasher = new HMACSHA1(); SHA1KeyedHasher.Key = _ValidationKey; RetVal = SHA1KeyedHasher.ComputeHash(uePassword); break; case MembershipPasswordFormat.Encrypted: TripleDESCryptoServiceProvider tripleDes = new TripleDESCryptoServiceProvider(); tripleDes.Key = _DecryptionKey; tripleDes.IV = new byte[8]; MemoryStream mStreamEnc = new MemoryStream(); CryptoStream cryptoStream = new CryptoStream(mStreamEnc, tripleDes.CreateEncryptor(), CryptoStreamMode.Write); cryptoStream.Write(uePassword, 0, uePassword.Length); cryptoStream.FlushFinalBlock(); RetVal = mStreamEnc.ToArray(); cryptoStream.Close(); break; } return RetVal; }
private string GetHumanReadablePassword(byte[] StoredPassword) { System.Text.UnicodeEncoding ue = new System.Text.UnicodeEncoding(); string RetVal = null; switch (_PasswordFormat) { case MembershipPasswordFormat.Clear: RetVal = ue.GetString(StoredPassword); break; case MembershipPasswordFormat.Hashed: throw new ApplicationException( "Password cannot be recovered from a hashed format"); case MembershipPasswordFormat.Encrypted: TripleDESCryptoServiceProvider tripleDes = new TripleDESCryptoServiceProvider(); tripleDes.Key = _DecryptionKey; tripleDes.IV = new byte[8]; CryptoStream cryptoStream = new CryptoStream(new MemoryStream(StoredPassword), tripleDes.CreateDecryptor(), CryptoStreamMode.Read); MemoryStream msPasswordDec = new MemoryStream(); int BytesRead = 0; byte[] Buffer = new byte[32]; while ((BytesRead = cryptoStream.Read(Buffer, 0, 32)) > 0) { msPasswordDec.Write(Buffer, 0, BytesRead); } cryptoStream.Close(); RetVal = ue.GetString(msPasswordDec.ToArray()); msPasswordDec.Close(); break; } return RetVal; }
If you look at the code listings above, you will notice that keys are required to perform a keyed-hash or encryption on the password. These keys are provided to the sample provider using the validationKey configuration attribute for keyed-hashing, and the decryptionKey configuration attribute for encryption and decryption of passwords.
In a production-grade provider, you should try to use strong keys acquired via traditional commercial means, such as digital certificates. For the sample provider, I have used the RNGCryptoServiceProvider class to generate the required random keys. I have included a small Windows Forms application named KeyGen in the sample, with which you can generate a random key of a specified size (in bytes). I have used a 64 byte key for the SHA1 hash and a 24 byte key for the TripleDES encryption. If you decide to use other schemes for your provider, you should refer to the documentation for that scheme for appropriate key length information.
The MembershipProvider exposes the ValidateUser method that allows the application to validate a set of user credentials against your implementation. The primary task of the ValidateUser function should be to locate the user record based on the user name and the application name, decode the stored password if necessary, and compare it with the supplied password.
Changing User Information
The Membership provider also exposes methods like UpdateUser, ChangePassword, ChangePasswordQuestionAnswer, and ResetPassword to allow modifications to user information through the membership service.
The UpdateUser method accepts an instance of MembershipUser, locates the appropriate record in the repository, and updates the pertinent attributes, other than password or password related ones. The listing below shows you the UpdateUser method from the sample provider.
public override void UpdateUser(MembershipUser user) { DirectoryEntry deUserContainer = null; DirectoryEntry deMembershipUser = null; try { //let's first see if the user exists deUserContainer = (_ADAMUserName != null && _ADAMPassword != null) ? new DirectoryEntry(_ADAMLDAPPathToUserContainer, _ADAMUserName, _ADAMPassword) : new DirectoryEntry(_ADAMLDAPPathToUserContainer); if (ADAMFindUser((_ApplicationName != null) ? string.Format(_ADAMUserSearchByUserNameFilterAppSpecific, _ApplicationName, user.Username) : string.Format(_ADAMUserSearchByUserNameFilter, user.Username), deUserContainer, out deMembershipUser) == false) throw new ApplicationException( string.Format("User {0} does not exist", user.Username)); int Count = 0; if (_RequiresUniqueEMail && deMembershipUser.Properties[ _ADAMPropEMail].Value != user.Email) { FindUsersByEmail(user.Email, 1, int.MaxValue, out Count); if (Count > 0) throw new ApplicationException( string.Format("A user with email {0} already exists", user.Email)); } if(deMembershipUser != null) { deMembershipUser.Properties[_ADAMPropUserName].Value = user.Username; deMembershipUser.Properties[_ADAMPropEMail].Value = user.Email; deMembershipUser.Properties[_ADAMPropComment].Value = user.Comment; deMembershipUser.Properties[_ADAMPropIsApproved].Value = user.IsApproved; deMembershipUser.Properties[ _ADAMPropLastActivityTimestamp].Value = user.LastActivityDate = DateTime.UtcNow; deMembershipUser.Properties[_ADAMPropPasswordQuestion].Value = user.PasswordQuestion; deMembershipUser.CommitChanges(); } } catch (Exception Ex) { throw new ApplicationException("Error updating User", Ex); } finally { try { deMembershipUser.Close(); deUserContainer.Close(); } catch (Exception Ex) { } } }
The ChangePassword and the ResetPassword methods directly impact the password. Your implementation of ResetPassword will also need to generate a new random password for the application user and return it. You can use the GeneratePassword method on the Membership class, to generate this new password. The listing below shows a sample implementations of ResetPassword.
public override string ResetPassword(string name, string answer) { DirectoryEntry deUserContainer = null; DirectoryEntry deMembershipUser = null; string RetVal = null; try { if (_EnablePasswordReset == false) throw new NotSupportedException( @"Current configuration settings do not allow resetting passwords"); //let's first see if the user exists deUserContainer = (_ADAMUserName != null && _ADAMPassword != null) ? new DirectoryEntry(_ADAMLDAPPathToUserContainer, _ADAMUserName, _ADAMPassword) : new DirectoryEntry(_ADAMLDAPPathToUserContainer); if (ADAMFindUser((_ApplicationName != null) ? string.Format(_ADAMUserSearchByUserNameFilterAppSpecific, _ApplicationName, name) : string.Format(_ADAMUserSearchByUserNameFilter, name), deUserContainer, out deMembershipUser) == false) throw new ApplicationException( string.Format("User {0} does not exist", name)); if (deMembershipUser != null) { if(_RequiresQuestionAndAnswer && (deMembershipUser.Properties[ _ADAMPropPasswordAnswer].Value == null || (string)deMembershipUser.Properties[_ADAMPropPasswordAnswer].Value != answer)) throw new ApplicationException("Password answer does not match"); RetVal = Membership.GeneratePassword(6); deMembershipUser.Properties[_ADAMPropPassword].Value = this.ConvertPasswordForStorage(RetVal); deMembershipUser.Properties[ _ADAMPropLastPasswordChangeTimestamp].Value = DateTime.UtcNow; deMembershipUser.CommitChanges(); } } catch (Exception Ex) { throw new ApplicationException("Error resetting password", Ex); } finally { try { deMembershipUser.Close(); deUserContainer.Close(); } catch (Exception Ex) { } } return RetVal; ; }
Running the Sample Provider
To prepare your environment to run the sample provider, you will need to install ADAM and create the required schema on your ADAM instance.
You can obtain ADAM from http://www.microsoft.com/windowsserver2003/adam/default.mspx. Once you have obtained ADAM, follow the steps below:
- Start the ADAM installation process.
- In the course of installation, you will be asked to provide port numbers for the ADAM instance to use. If you supply port numbers other than the defaults suggested by the installation process, note the port numbers. If your computer belongs to an Active Directory domain, it is advised that you do not use the default port numbers.
- During the installation process you will prompted to create an Application Partition. Choose to create one, and name it CN=ASP.NET Security Provider.
- Once ADAM has been installed, you will use the attached schema definition file named membershipschema.ldf to populate your ADAM instance with the necessary schema. To do so, you can use following command at the ADAM command prompt. The command shown below assumes that your instance is running as localhost on port 10250.
ldifde -s localhost -t 10250 -v -f membershipschema.ldf -c "CN=Configuration,DC=X" "#configurationNamingContext" -i –e –z –b <UserName> <DomainName> *
Table 3. ADAM schema elements needed for the sample provider
Object Type Purpose aspnetmembershipuser class Schema object representing a user, and containing attributes described below. Attributes aspnetmembershipuserUserID Octet String A GUID representing a unique user in the system. aspnetMembershipuserUserName Unicode String User Name. aspnetmembershipuserEMail Unicode String User E-mail. aspnetmembershipuserIsAnonymous Boolean True if user is anonymous. aspnetmembershipuserIsApproved Boolean True if user has been approved. aspnetmembershipuserComment Unicode String Comment. aspnetmembershipuserUserCreationTimeStamp UTC Coded Time Date/Time when user entry was created. aspnetmembershipuserLastActivityTimeStamp UTC Coded Time Date/Time of user's last activity. aspnetmembershipuserLastLoginTimeStamp UTC Coded Time Date/Time of user's last login. aspnetmembershipuserLastPasswordChangeTimeStamp UTC Coded Time Date/Time of last password change/reset. aspnetmembershipuserPassword Octet String Encrypted/Hashed/Clear password. aspnetmembershipuserPasswordFormat Integer Encrypted/Hashed/Clear. aspnetmembershipuserPasswordQuestion Unicode String Security question. aspnetmembershipuserPasswordAnswer Unicode String Answer to security question. aspnetmembershipuserApplicationName Unicode String Application Name. - Now open the ADAM ADSI Edit tool from the start menu and select Action | Connect To…
Figure 2. Connection Settings dialog box in ADAM ADSI Edit
- In the dialog box above, populate the distinguished name to point to the Application Partition for the provider, specify an appropriate user name and password to connect (if the current user is not a valid user for your ADAM instance), and specify the appropriate port number.
- Once connected, you should see a screen as shown below:
Figure 3. ADAM ADSI Edit
- Select the node titled "CN=ASP.NET Security Provider," right click and select New | Object…
- Create a new object of type container and name it Users. Your ADAM installation is now ready.
- Appropriately modify the membership section in the web.config belonging to the TestWeb project in the sample. You can look at the sample configuration setting shown in the section titled Provider Initialization for some guidance.
- The provider is made available to the TestWeb application by placing the source code (ADAMMembershipProvider.cs) in the code folder of the application, and thus compiling it with the application. If you want a separate assembly for the provider, you can create a class library project in Visual Studio, add the source code (ADAMMembershipProvider.cs) to the project, build it, and add the assembly to the Global Assembly Cache. You will need to modify the type attribute of the appropriate provider element in your web.config to point ASP.NET to the provider assembly.
Start by creating a few new users and have fun!!
Conclusion
The ASP.NET team has built in a really extensible provider model into the "building block" services in ASP.NET 2.0. While I have demonstrated building a custom provider for the Membership system, other building block services such as Role Management and Personalization also follow the same design pattern, allowing custom providers to be constructed for each of these services. Whether you have legacy repositories already containing vital profile information, or your organization's standards needs you to use repositories other than the ones for which standard providers are shipped, you can now target ASP.NET to use any such repository without impacting your application design process. More importantly, you can very easily use one repository for your development tasks, and a different one for the application in production, simply by changing some configuration.
Related Books
- A First Look at ASP.NET V. 2.0
- Active Directory
- Microsoft Windows Server 2003 Administrator's Pocket Consultant
About the author
Jit Ghosh is a .NET Architect Evangelist working for Microsoft. He has been working with .NET since the early betas of the framework. When not helping customers understand and adopt .NET, he likes to find out interesting ways to extend the framework class libraries. Jit can be reached at pghosh@microsoft.com.