Contents
Introduction
I've written this code in order to fill a percieved gap in Microsoft's current ASP.NET role management offerings. (If you are not familiar with ASP.NET Membership and Role Management, you may want to check out the current framework documentation on Securing ASP.NET Web Sites before continuing this article. The Membership and Role summary pages should give you a good start.)
Currently, if you are using forms-based authentication with Microsoft's ActiveDirectoryMembershipProvider
, you are a bit limited in your options for a role provider. There is nothing built into the framework that is as relatively simple to configure and maintain ActiveDirectoryMembershipProvider
. The best solution you are left with is setting up AzMan. However, there's a significant learning curve and a lot of conflicting information out there regarding it, and it also requires setup and configuration outside of the web site's web.config
.
That's where ADRoleProvider
comes into play. Simply stated, ADRoleProvider
is a role provider class that allows you to use your existing Active Directory groups as ASP.NET Roles, and attempts to provide that framework as easily as ActiveDirectoryMembershipProvider
provides your membership framework. Since it inherits from Microsoft's base RoleProvider
class, it should function seamlessly in your site and work with all the standard .NET controls (LoginView, etc) and Web.config authorization settings.
Please make certain that you read through this documentation and understand the security and performance concerns mentioned. Also, look through the code to gain an understanding of what exactly how it operates.
Requirements
In order to make use of this RoleProvider
class, there are only two requirements
- You must be running an ASP.NET website using version 3.5 of the framework. This is because the code uses the
System.DirectoryServices.AccountManagement
namespace to enumerate group membership.
- Your IIS web server must be a member of the domain you wish to use for group/role maintenance. You can use an off-domain server with a little code tweaking, and I will be supporting this option down the road.
- For the time being, attributeMapUsername="sAMAccountName" should be set in your ActiveDirectoryMembershipProvider. This is explained further below in the Technical Summary.
Technical Summary
Using the source code that Microsoft has released for SqlRoleProvider
as a jumping-off point, I've tried to make this class adhere as close as possible to the behavior of the "built-in" role providers. The only significant difference to note is that this is a read-only provider. Since management should be done only through Active Directory, any role operations that would require write access will throw a NotSupportedException
.
IMPORTANT - All of the Active Directory queries in the class are currently made against the LDAP sAMAccountName attribute. Consequently, any time you are referring to a group or user name in the web.config, please be certain you are using the sAMAccountName. However, ActiveDirectoryMembershipProvider
can be configured to use either sAMAccountName OR userPrincipalName. To avoid any confusion, the safe bet is to set attributeMapUsername="sAMAccountName" in the membership configuration section of the web.config. The one critical change I am currently planning for this code is the option to use UPN to avoid any confusion. This change should complete and tested within a week. If you are working in a live environment in which users are accustomed to logging in as username@domain.com
rather than username
, please hold off using this until then.
Certain built-in or common Active Directory groups are specifically excluded in the source code. For example, Exchange Enterprise Servers would never, ever be used as a role, so that is in an internal exclusion list. These system groups cannot be queried against, and will never show up in any results. You can also specify your own groups to ignore in the Web.config (see configuration below).
Querying Active Directory
All of the heavy lifting done by this class involves querying against Active Directory, and there are as many ways to query Active Directory as there are to shoot yourself in the foot. Below is the complete code for retrieving a user's role (i.e. AD Group) membership. The SQL Caching code is ellipsed out for explanation purposes.
public override String[] GetRolesForUser(String username)
{
...
ArrayList results = new ArrayList();
using (PrincipalContext context = new PrincipalContext(ContextType.Domain,
null, _DomainDN))
{
try
{
UserPrincipal p = UserPrincipal.FindByIdentity(context,
IdentityType.SamAccountName, username);
var groups = p.GetAuthorizationGroups();
foreach (GroupPrincipal group in groups)
{
if (!_GroupsToIgnore.Contains(group.SamAccountName))
{
if (_IsAdditiveGroupMode)
{
if (
_GroupsToUse.Contains(
group.SamAccountName))
{
results.Add(
group.SamAccountName);
}
}
else
{
results.Add(group.SamAccountName);
}
}
}
}
catch (Exception ex)
{
throw new ProviderException(
"Unable to query Active Directory.", ex);
}
}
...
return results.ToArray(typeof(String)) as String[];
}
Do not let the nested if
statements throw you off; they are there only to make certain that the proper groups are ignored/used, depending on your configuration, explained below. As the comments explain, programming against System.DirectoryServices.AccountManagement
is actually quite simple.
Caching to SQL Server
Querying against Active Directory can be pretty slow. To alleviate this, ADRoleProvider
includes options to enable caching the results from its Active Directory queries to a Microsoft SQL Server. It seems counterintuitive, but caching querying against SQL Server is generally faster than executing AD queries.
The manner of caching is extremely simple. I vacillated between using a more fully normalized series of tables and the simplified method I finally settled upon. Data is cached to a single table as specified below.
- An ID field, used to speed up duplicate lookups in one of the stored procedures
- Application Name for the item, allowing a single table to be used for multiple applications
- The type of object being cached (L=Roles List, U=User membership, R=Role membership)
- User or role name
- Comma-separated list of membership
- Expiration datetime
CacheId ApplicationId CacheType CacheKey CacheValue ExpireDT
49 MyApp L AllRoles Customer Service,IT 8/18/2008 2:49:50 PM
50 MyApp U asmith IT 8/18/2008 2:49:50 PM
51 MyApp R IT dsmith,msmith,asmith,jsmith,ssmith,bsmith 8/18/2008 2:49:50 PM
There are certain situations in which this simplified method of caching could present minor annoyances. For example: The cached results for IT is set to expire at 2:50, the results for asmith are set to expire at 3:50, and asmith is removed from the IT Active Directory group. In this situation, there could be an hour period where enumerating IT shows that asmith is not a member, but asmith still thinks he is. This could be avoided by having a group table, a user table, and a join table. However, I decided that the added complexity and overhead was not worth the trade-off, since every time a cache item is set, the integrity of every item involved would have to be verified.
Included in the code zip file is a SQL file that will create the necessary table and stored procedures to implement caching. This has been tested under SQL Server 2005 Standard and Developer editions, and SQL Server 2008 Standard.
SQL Caching Performance
Honestly, the impact of enabling SQL caching is much larger than I had thought it would be. My test environment consists of only two groups and about a half dozen users. With such a small set of data to work with, I figured the performance impact would be minimal. The SQL instance I am connecting to is on a separate server from the test site. The code below basically what I used to test performance, though I actually set it to run a few hundred times.
DateTime dtStart;
DateTime dtEnd;
TimeSpan executionTime;
string[] results;
dtStart = DateTime.Now;
results = Roles.GetAllRoles();
results = Roles.GetRolesForUser("dsoref");
results = Roles.GetUsersInRole("IT");
dtEnd = DateTime.Now;
executionTime = dtEnd - dtStart;
tests.InnerHtml += "Execution Time: " + executionTime.TotalMilliseconds + "<br /><br />";
Without caching enabled, this chunk of code would take from 78 to 154ms to execute, with the average hovering at about 98ms.
With caching enabled, the initial request would take from 81 to 204ms to execute, with the average hovering around 102ms since the data needed to be cached to the SQL database. However, subsequent requests within the expiration time took only 14 to 16ms. I guess SQL is faster than AD after all.
Using the Code
First, you will want to compile the provider and copy the resulting .dll into your website's bin folder. You could instead copy the .cs file into your App_Code directory, but this is somewhat less secure. Granted, you should be able to trust all of your developers, but better safe than sorry.
Web.Config
Next, you will want to enter the proper configuration settings into your web.config, as below. I will run through each of these settings.
...
<connectionStrings>
...
<add name="ActiveDirCS"
connectionString="LDAP://DC=YourDomain,DC=com"/>
</connectionStrings>
...
<roleManager enabled="true" defaultProvider="ActiveDirRP">
<providers>
<clear/>
<add applicationName="MyApp"
name="ActiveDirRP"
type="DanielPS.Roles.ADRoleProvider"
activeDirectoryConnectionString="ActiveDirCS"
groupMode="Additive"
groupsToUse="IT, Customer Service"
groupsToIgnore="Senior Management"
usersToIgnore="asmith, ksose"
enableSqlCache="True"
sqlConnectionString="SQLCacheCS"
cacheTimeInMinutes="30" />
</providers>
</roleManager>
...
- Name should be specified as with any other role provider for reference in the web.config.
- Type will refer to our new roleprovider class,
DanielPS.Roles.ADRoleProvider
, or something else if you don't like the namespace.
- ApplicationName should be the same as used in your membershipprovider section.
- activeDirectoryConnectionString should be LDAP-formatted and serverless, i.e.
LDAP:
. If you specify a server, an error will be thrown.
- The most important setting here is groupMode. It has two options:
- Additive - All Active Directory groups are essentially invisible and useless unless the are specified in the groupsToUse section. This is the safest method, as you are assured that no secure Active Directory groups are exposed to the web site, and will not be listed even on a
GetAllRoles()
call. All groups you wish to use as roles must be specified in groupsToUse
- Subtractive - All Active Directory groups are exposed as roles unless they are listed in the groupsToIgnore section. This is somewhat less secure, but requires less maintenance when groups are added or removed. As mentioned, there is a list in the source code of common AD groups that will be ignored whether or not they are in the groupsToIgnore list.
- groupsToUse is a comma-separated list of groups that should be used as roles. This is only used if groupMode is set to Additive.
- groupsToIgnore is a comma-seperated list of groups that should be ignore for roles purposes. They will not should in the results for any Roles functions. In the example above,
Roles.GetRolesForUser()
will never include "Senior Management" in the results, and Roles.GetUsersInRole("Senior Management")
will throw a ProviderException
.
- enableSqlCache should be set to True to enable SQL caching. I highly recommend it.
- sqlConnectionString is the name of the connection string to use for SQL connections if SQL caching is enabled.
- cacheTimeInMinutes is the length of time, in minutes, that items should be cached if SQL caching is enabled.
Note: If a groupMode is additive, and a group is specified in both groupsToUse and groupsToIgnore, groupsToIgnore will take precedence.
Note: If a group is specified in groupsToUse, but does not exist in Active Directory, it will be ignored.
Groups and Users Excluded in the Source
The following groups are excluded in the source code. Even specifying them in groupsToUse will not make them functional.
Domain Guests, Domain Computers, Group Policy Creator Owners, Guests, Users, Domain Users, Pre-Windows 2000 Compatible Access, Exchange Domain Servers, Schema Admins, Enterprise Admins, Domain Admins, Cert Publishers, Backup Operators, Account Operators, Server Operators, Print Operators, Replicator, Domain Controllers, WINS Users, DnsAdmins, DnsUpdateProxy, DHCP Users, DHCP Administrators, Exchange Services, Exchange Enterprise Servers, Remote Desktop Users, Network Configuration Operators, Incoming Forest Trust Builders, Performance Monitor Users, Performance Log Users, Windows Authorization Access Group, Terminal Server License Servers, Distributed COM Users, Administrators, Everybody, RAS and IAS Servers, MTS Trusted Impersonators, MTS Impersonators
The following users are excluded in the source code. They will not show up in any group membership enumeration.
Administrator, TsInternetUser, Guest, krbtgt, Replicate, SERVICE, SMSService
Future Enhancements
- Add configuration options to allow a non-domain IIS server to host a web site making use of this code without any tweaking.
- Add configuration options to use Common-Name rather than sAMAccountName.
History
- 8/12/2008 - Initial Posting
- 8/20/2008 - Update to include SQL Caching, rewrite of some sections for clarification, more detailed explanations, and misc. corrections
- 8/20/2008 - Rewrote group membership enumeration to use
System.DirectoryServices.AccountManagement
(.NET 3.5 only) and support recursive membership
- 9/4/2008 - Source updated
- 9/30/2008 - Article Rewrite