Friday, August 15, 2008

Active Directory Role Provider


I've been using the ActiveDirectoryMembershipProvider to allow my users to login to a custom ASP.Net site with their AD credentials and it is pretty straight forward. Recently I needed to add more granularity to who can view various parts of the site. I wanted to take advantage of our existing AD groups so I assumed there would be something like an ActiveDirectoryRoleProvider as well. After a little searching, it became clear that wasn't the case, so I decided to roll my own.

Creating a custom role provider is pretty easy, all you have to do is create a new class and inherit RoleProvider:


public class ActiveDirectoryRoleProvider : RoleProvider
{}


You will have to create stubs for all the inherited members. We only need to implement a couple of them to get basic role membership checking. We need to get our AD configuration information out of the Web.Config values, so we'll create a few properties and override the Initialize method:



private string ConnectionStringName { get; set; }
private string ConnectionUsername { get; set; }
private string ConnectionPassword { get; set; }
private string AttributeMapUsername { get; set; }

public override void Initialize(string name, NameValueCollection config)
{
ConnectionStringName = config["connectionStringName"];
ConnectionUsername = config["connectionUsername"];
ConnectionPassword = config["connectionPassword"];
AttributeMapUsername = config["attributeMapUsername"];

base.Initialize(name, config);
}


Now we'll override GetRolesForUser which is the bulk of our implementation. We use the DirectorySearcher class in System.DirectoryServices to query AD for the passed username. We then pull the memberOf property from that user and extract the CN component for each entry as the role:



public override string[] GetRolesForUser(string username)
{
var allRoles = new List<string>();

var root = new DirectoryEntry(WebConfigurationManager.ConnectionStrings[ConnectionStringName].ConnectionString, ConnectionUsername, ConnectionPassword);

var searcher = new DirectorySearcher(root, string.Format(CultureInfo.InvariantCulture, "(&(objectClass=user)({0}={1}))", AttributeMapUsername, username));
searcher.PropertiesToLoad.Add("memberOf");

SearchResult result = searcher.FindOne();

if (result != null && !string.IsNullOrEmpty(result.Path))
{
DirectoryEntry user = result.GetDirectoryEntry();

PropertyValueCollection groups = user.Properties["memberOf"];

foreach (string path in groups)
{
string[] parts = path.Split(',');

if (parts.Length > 0)
{
foreach (string part in parts)
{
string[] p = part.Split('=');

if (p[0].Equals("cn", StringComparison.OrdinalIgnoreCase))
{
allRoles.Add(p[1]);
}
}
}
}
}

return allRoles.ToArray();
}


And finally we need to override IsUserInRole so we can easily check for role membership in code:



public override bool IsUserInRole(string username, string roleName)
{
string[] roles = GetRolesForUser(username);

foreach (string role in roles)
{
if (role.Equals(roleName, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}

return false;
}


Here's the the full code (minus the unimplemented inherited methods):



public class ActiveDirectoryRoleProvider : RoleProvider
{
private string ConnectionStringName { get; set; }
private string ConnectionUsername { get; set; }
private string ConnectionPassword { get; set; }
private string AttributeMapUsername { get; set; }

public override void Initialize(string name, NameValueCollection config)
{
ConnectionStringName = config["connectionStringName"];
ConnectionUsername = config["connectionUsername"];
ConnectionPassword = config["connectionPassword"];
AttributeMapUsername = config["attributeMapUsername"];

base.Initialize(name, config);
}

public override bool IsUserInRole(string username, string roleName)
{
string[] roles = GetRolesForUser(username);

foreach (string role in roles)
{
if (role.Equals(roleName, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}

return false;
}

public override string[] GetRolesForUser(string username)
{
var allRoles = new List<string>();

var root = new DirectoryEntry(WebConfigurationManager.ConnectionStrings[ConnectionStringName].ConnectionString, ConnectionUsername, ConnectionPassword);

var searcher = new DirectorySearcher(root, string.Format(CultureInfo.InvariantCulture, "(&(objectClass=user)({0}={1}))", AttributeMapUsername, username));
searcher.PropertiesToLoad.Add("memberOf");

SearchResult result = searcher.FindOne();

if (result != null && !string.IsNullOrEmpty(result.Path))
{
DirectoryEntry user = result.GetDirectoryEntry();

PropertyValueCollection groups = user.Properties["memberOf"];

foreach (string path in groups)
{
string[] parts = path.Split(',');

if (parts.Length > 0)
{
foreach (string part in parts)
{
string[] p = part.Split('=');

if (p[0].Equals("cn", StringComparison.OrdinalIgnoreCase))
{
allRoles.Add(p[1]);
}
}
}
}
}

return allRoles.ToArray();
}
}


Add the role provider to your Web.Config:



<system.web>
<roleManager enabled="true" defaultProvider="ADRoleProvider" cacheRolesInCookie="true" cookieName=".ASPXROLES" cookiePath="/"
cookieTimeout="30" cookieRequireSSL="false" cookieSlidingExpiration="true" createPersistentCookie="false" cookieProtection="All">
<providers>
<clear />
<add name="ActiveDirectoryRoleProvider" connectionStringName="ADConnectionString" connectionUsername="username"
connectionPassword="password" attributeMapUsername="sAMAccountName" type="ActiveDirectoryRoleProvider" />
</providers>
</roleManager>
</system.web>


You can then check the roles of your user in code like so:



Roles.IsUserInRole("My Group")


Or control access to entire directories via the Web.Config:



<location path="RestrictedSubDirectory">
<system.web>
<authorization>
<allow roles="My Group"/>
<deny users="*" />
</authorization>
</system.web>
</location>

19 comments:

sara said...

nice return to the uber technical. i like it when i don't understand a single word in your posts :)

ZzZoel said...

In the config file the rolemanager property 'defaultProvider' should have the same value as the name of the provider which is 'ActiveDirectoryRoleProvider' instead of 'ADRoleProvider'

Greg Martin said...

Good catch, you are correct.

Mike said...

...many thanks for the time you saved me!

Rudolf said...

Hello,
Already a big thanks for having posted this. And thanks in advance for your help!

first, I'm using VS2005, so I did some minor refactoring, and the class compiles without problems.

I can use the class from within code to e.g. get the groups the user is in etc... (so in my default.aspx, I can write:
string[] userRoles = Roles.GetRolesForUser(strLogin); and I trace it through your class, all ok!!!

However, I'm using sqlsitemapprovider.cs(from the msdn site) and set the rolemanager as you wrote, but I never get the full menu, however in my DB the role field is filled in, and exactly the same as the values returned by your class function.

What am I doing wrong? Any ideas, suggestions are much appreciated.
thanks again, and again in advance,

Rudolf said...

I was wondering if it could be related to the username and password in the web.config to query LDAP, but then again,

I can run Roles.GetRolesForUser anonymously or by specifying my useraccount and login inside that function. Either way it works...
Except for the sqlsitemapprovider it does not seem to work.

Joel Forman said...

Hey Rudolf,

I have used Greg's AD role provider with a sitemap, and have had it work as expected for
me. I haven't used the SqlSiteMapProvider, just the standard
XmlSiteMapProvider. There is an attribute on the XmlSiteMapProvider
that is key for this to work as I want it to, securityTrimmingEnabled.

Setting the securityTrimmingEnabled attribute to "true" means that no menu item will show up in the menu if the user is not authorized to view that page. Otherwise, menu items continue to show but once a user clicks on that page, they are restricted.

kwazi said...

Hi,

Thank you for sharing this.

I already see some place are use it :-)

Anonymous said...

Many thanks!!! Just saved my a$$.

I did find that I needed to use the built in membership provider (AspNetActiveDirectoryMembershipProvider) as well as your role provider to get the same functionality as the default setup in asp.net mvc (removed the SQL Server store for users as mine is an internal app and AD was a better solution).

Again, many many thanks!

Pradeep said...

Hi,

While creating the "DirectoryEntry" instance, you are passing the connectionPassword.

Say, I am ruuningmy WCF service in IIS under an separate APP_POOL.

Is it possibel to user APP_POOL user account for "DirectoryEntry", instead of specifying UserName, Password explicitly?

Regards,
Pradeep.

Abdul said...

thanks........

Matt Penner said...

Thanks, this is great!

I did a little optimization. I take out the groups to ignore when returning the group list rather than at the end. Our Domain Users group belongs to about 150 groups by the time you recurse over it and it was taking about 6 seconds to run. Now domain users is removed before the recurse.

The main block of RecurseGroup is now:
var res =
principal
.GetGroups()
.Select(grp => grp.Name)
.Except(_groupsToIgnore)
.ToList();

Also the initial group list is:
var groupList =
UserPrincipal
.FindByIdentity(context, IdentityType.SamAccountName, username)
.GetGroups()
.Select(group => group.Name)
.Except(_groupsToIgnore)
.ToList();

Hope that helps!

Anonymous said...

So what would you do about the error....
ActiveDirectoryRoleProvider does not implement inherited abstract member 'Systm.web.Security.RoleProvider.FindUsersInRole()
As well as the 11 other errors that reference all the abstract methods that are not being referenced within roleprovider?

Andrew Cohen said...

Awesome simple implementation. I was able to get this running in conjunction with the ASP.Net System.Web.Security.ActiveDirectoryMembershipProvider in an MVC 4 application. I now can use the AutorizeAttribute to do "role" checking for Active Directory groups. Awesome!

Anonymous said...

Response to:

"So what would you do about the error....
ActiveDirectoryRoleProvider does not implement inherited abstract member "

And also:
"I'm using sqlsitemapprovider.cs(from the msdn site) and set the rolemanager as you wrote, but I never get the full menu..."

You still have to implement all the other mustinherit methods, and for those you do not implement, make sure you throw a NotImplementedException. This will tell you when something calls a method you haven't implemented so you can fix it. I am thinking that is the problem with the 2nd poster.

Also I'd like to note that this provider implementation does NOT support nested groups so don't count on that working. I think for most scenarios this will be acceptable.

Adam Herbert said...

Why wouldn't you just do this?

public override string[] GetRolesForUser(string username)
{
var allRoles = new List();
var ctx = new PrincipalContext(ContextType.Domain);
UserPrincipal user = UserPrincipal.FindByIdentity(ctx, username);
if (user != null)
{
var groups = user.GetGroups();
allRoles.AddRange(groups.Select(x => x.Name));
}

return allRoles.ToArray();
}

flyby said...

I put this quote inside Global.asax & I got error "Error 1 'Global.ActiveDirectoryRoleProvider' does not implement inherited abstract member 'System.Web.Security.RoleProvider.FindUsersInRole(string, string)'

How can I fix this?

hhh said...

flyby: Just set the cursor to "RoleProvider", in the line:
public class ActiveDirectoryRoleProvider : RoleProvider
and hit "Ctrl + . ", then Enter. This will implement all the missing abstract members of the base class.

Daniel Borg said...

Wow, thanks heaps for this. Worked straight up. Daniel.