Click here to Skip to main content
15,881,687 members
Articles / Web Development / ASP.NET

(Yet Another) Windows Role Provider - For Enterprise Environment

Rate me:
Please Sign up or sign in to vote.
5.00/5 (2 votes)
20 Feb 2014Eclipse8 min read 10.8K   84   18  
Implementing custom RoleProvider for both AD and local machine for use with Forms Authentication
In this article, you will learn how to implement a custom RoleProvider for both Active Directory and local machine to be used with Forms Authentication.

Introduction

The tendencies in an enterprise environment might look contradictory in some cases. Everybody knows SSO (Single Sign-On). Great stuff, once logged in - thus authenticated by the Active Directory infrastructure, a third party (external) application does not need a new authentication anymore. On the other - even with Kerberos or NTLM in place - you might need to do forms authentication inside the enterprise perimeters. Why? Because of obvious security reasons: a computer left unattended is making it a door to all applications without re-authenticating. Of course, several methods can be implemented not to let unlocked PCs unattended, there are many places where these are not used. On the other hand, re-authentication is considered is some situations an electronic signature.

Either way, I needed forms authentication and of course, authorization against Active Directory for my ASP.NET (MVC3) application.

For the authorization part, you had ActiveDirectoryMembershipProvider, but for role based authorization, I found nothing useful. AspNetWindowsTokenRoleProvider looked promising, but it won't work with forms authentication, or at least not without hacking. So I Googled really intensive to find a usable custom role provider. And I have to admit, there were some out there, but none of them satisfied my needs. Most of them were LDAP based, which is not bad, but do I really need to do LDAP myself when System.DirectoryServices namespace is at hand? But the biggest problem is that all that I have found (like this for example) doesn't take into consideration the fact that an enterprise AD can be huge. Really huge. I am working in a forest of several ten thousand users. And a user can be member of hundreds of groups - worse: nested groups. Most of these groups are of no meaning for a concrete application - why gather them all, if only several are needed? And there is an other important topic: an enterprise might (will) have its own naming policy for groups (that might change over time). So a group name hard-coded in the application by a developer is not a good choice.

So, What's Really Needed?

What I needed was a role provider that can handle and ignore in the same time extensive nested group memberships and provide me a possibility to map between the application role and the domain security group. I have to admit I haven't investigated all commercial solutions, I have decided to write it on my own. I also made my own membership provider but that wasn't really necessary, thus I won't present it in this article. Still, it is included in the source package.

Design Considerations

Neither my membership provider nor my role provider needed to handle user management. That is done with ADUC or other tools; so many method implementations overriding the respective base methods will throw NotImplementedException. I needed to be able to run my code in AD and non-AD environment, so I decided to define a parameter electable scope: either machine or domain - without affecting the rest of the usage. And of course, as I mentioned before, I needed the ability to map the application roles to security groups. And where would this mapping be better placed as in the web.config aside of the rest of the configuration. So I needed a configuration handler. Of course, this could have been achieved in a more dynamic way like with a database table - feel free to implement it if you need this, and you are welcome to share it with us. Smile | :)

Let's start with this latest one.

Storing the Mapping in web.config

Implementing a configuration handler is not a hard task, but could have been made simpler to implement. You decide to use an XML structure, and then you need to make counterparts of respective elements and attributes in classes. I have chosen the following structure:

XML
<RoleGroups>
    <RoleWindowsGroups>
      <clear />
      <add WindowsGroupName="group1" RoleName="role1" IdentityType="identity type"/>
      <add WindowsGroupName="group2" RoleName="role2" />
      <add WindowsGroupName="group3" RoleName="role3" />
    </RoleWindowsGroups>
</RoleGroups>

The role names are the ones used in the application in passed to the AuthorizeAttribute, while group names are windows or Active Director security group identifiers depending on scope. The third attribute defines the identity type that the identifier in group attribute is representing. This is optional, by default, it is SamAccountName. In this case, its format can be domain/group_name (useful in a forest) or simply group_name. So this is the structure for which I had to create the configuration handler. The implementation consists of many classes and methods that need to be written in order to make it work (why on Earth can't this all be generated from on an xsd?) - you can see all then in the ConfigurationHandler.cs file. If you are interested in this topic, consider reading this article and of course consult MSDN. To use the handler, you need to reference it in the web.config/<configuration>/<configSections> section like this:

XML
<section name="RoleGroups" 
type="WinntSecurityProviders.RoleWindowsGroupSection, WinntSecurityProviders" />

What's also included in the file (I know, not the best practice) is a helper class, that translates the mapping in the configuration to a strongly typed list:

C#
public sealed class RoleConfigurationHelper
    {
        public class RoleMapping
        {
            public string RoleName { get; private set; }
            public string WindowsGroupName { get; private set; }
            public IdentityType IdentityType { get; private set; }
           
            public RoleMapping(string RoleName, string WindowsGroupName, string IdentityType)
            {
                this.RoleName = RoleName;
                this.WindowsGroupName = WindowsGroupName;
                this.IdentityType = 
                     (IdentityType)Enum.Parse(typeof(IdentityType), IdentityType);
            }
        }
 
        private static IList<RoleMapping> RoleGroupsCache = new List<RoleMapping>();
 
        public static IEnumerable<RoleMapping> GetRoleGroups()
        {
            if (RoleGroupsCache.Count == 0)
            {
                try
                {
                    var sections = WebConfigurationManager.OpenWebConfiguration("/");
                    foreach (ConfigurationSection section in sections.Sections)
                    {
                        if (section is RoleWindowsGroupSection)
                        {
                            IList<RoleMapping> RoleGroups = new List<RoleMapping>();
 
                            foreach (GroupConfigElement RoleGroup in 
                                    (section as RoleWindowsGroupSection).Groups)
                            {
                                RoleGroups.Add(new RoleMapping
                                (RoleGroup.RoleName, RoleGroup.WindowsGroupName, 
                                RoleGroup.IdentityType));
                            }
 
                            RoleGroupsCache = RoleGroups;
                        }
                    }
                }
                catch (Exception ex)
                {
                    throw new ConfigurationErrorsException
                          ("Failed to load RoleWindowsGroupSection section", ex);
                }
            }
 
            return RoleGroupsCache;
        }
    }

The mapping is often accessed, thus I decided to keep it in memory as a list rather than parsing it many times.

And now...

The Role Provider

All custom providers I have encountered are implementing GetRolesForUser method (and everything else) by taking the user entity, parsing its group affiliations and returning it in an array. First of all, this won't handle nested groups. Second, it will return (and finally cache) a lot of useless groups. So I decided to go in the opposite direction: even with many application roles, they will be much less in number than the groups a user might belong to. As I have all groups I am interested in defined in the mapping, I only need to touch those groups - anything else has no use from my application's point of view. This is not a hard task, but I needed to take care about the scope too.

As I mentioned, the scope of my provider(s) is either the machine or the domain. This is passed as an extra attribute when the provider is added in web.config:

XML
<roleManager cacheRolesInCookie="true"
enabled="true" defaultProvider="WindowsRoleProvider">
  <providers>
    <clear />
    <add name="WindowsRoleProvider"
    type="WinntSecurityProviders.WindowsRoleProvider,
    WinntSecurityProviders" scope="Machine" />
  </providers>
</roleManager>

The constructor is taking the attributes as a name-value collection. I have implemented some a helper methods, ToAuthenticationScope is one of them, validating and translating the value to the two defined enum values.

C#
public class WindowsRoleProvider : RoleProvider
{
 private SecurityProviderHelpers.AuthenticationScope scope;
 public override void Initialize
 (string name, System.Collections.Specialized.NameValueCollection config)
 {
  scope = SecurityProviderHelpers.ToAuthenticationScope(config["scope"]);
  base.Initialize(name, config);
 }

The following method is responsible of creating a UserPrincipal object based on its account name and the scope seen above. First of all, it needs to identify the context which is actually the actual meaning of the scope: either the domain itself or the machine itself. As users are authenticating themselves in DomainName\SamAccountName format (even for local machine), the friendly domain name has to be translated to LDAP path. After the context is ready, user object is grabbed by its account name. Please note that username parameter in this case is expected to be in the above format.

C#
private UserPrincipal AsUserPrincipal(string username, out PrincipalContext context)
{
 SecurityProviderHelpers.DomainUser dn = new SecurityProviderHelpers.DomainUser(username);

 if (scope == SecurityProviderHelpers.AuthenticationScope.Domain)
 {
  string domainName = SecurityProviderHelpers.FriendlyDomainToLdapDomain(dn.DomainMame);
  context = new PrincipalContext(ContextType.Domain, domainName);
 }
 else
 {
  context = new PrincipalContext(ContextType.Machine, dn.DomainMame);
 }

 return UserPrincipal.FindByIdentity(context, IdentityType.SamAccountName, dn.PrincipalName);
}

This method is for checking if a single user is in one single role. As I mentioned before - to be able to solve nested grouping, I am first identifying the group by its mapped role name, thank grabbing the group as security principal and only than trying to match the user with the group's members in deep. GroupPrincipal has a Members property. But this property contains only direct members. Luckily, there is a GetMembers method override, which can be instructed to do a recursive search. And that's what we need.

C#
public override bool IsUserInRole(string username, string roleName)
 {
  try
  {
   PrincipalContext ctx;
   UserPrincipal user = AsUserPrincipal(username, out ctx);
 
   var KnownRoles = RoleConfigurationHelper.GetRoleGroups();
 
   if (!KnownRoles.Any(x => string.Equals
      (x.RoleName, roleName, StringComparison.OrdinalIgnoreCase)))
   {
    throw new ArgumentException(String.Format
    ("Role '{0}' is not mapped to any windows group", roleName), "RoleName");
   }
 
   var role = KnownRoles.First(x => string.Equals
              (x.RoleName, roleName, StringComparison.OrdinalIgnoreCase));
 
   GroupPrincipal group = GroupPrincipal.FindByIdentity
                          (ctx, role.IdentityType, role.WindowsGroupName);
 
   return group.GetMembers(true).Any(p => p.Sid == user.Sid);
  }
  catch
  {
   return false;
  }
 }

For gathering a user's roles, I have used the same strategy: I am taking all roles, for each of them I am grabbing the corresponding group and if the user is found to be a member of it using a recursive search, than I am adding the role name to the result.

C#
public override string[] GetRolesForUser(string username)
{
 try
 {
  PrincipalContext ctx;
  UserPrincipal user = AsUserPrincipal(username, out ctx);
  List<string> result = new List<string>();

  foreach (var role in RoleConfigurationHelper.GetRoleGroups())
  {
   GroupPrincipal group = GroupPrincipal.FindByIdentity
                          (ctx, role.IdentityType, role.WindowsGroupName);
   if (group.GetMembers(true).Any(p => p.Sid == user.Sid))
   {
    result.Add(role.RoleName);
   }
   group.Dispose();
  }

  user.Dispose();

  return result.ToArray();
 }
 catch
 {
  return new string[0];
 }
}

As you could have noticed, the code of these two methods is not directly dependent on the scope, as the scope is embodied in the PrincipalContext, and the framework code is hiding the differences from us.

Let's take the following one. In most cases, it is not needed, but I implemented it because it is so elegant and simple according to my concept. How would this look like, if you didn't have any knowledge about the roles you are interested in? You would enumerate all possible security groups in the domain?

C#
public override string[] GetAllRoles()
 {
  return (from role in RoleConfigurationHelper.GetRoleGroups() select role.RoleName).ToArray();
 }

For getting the users in a role, I had to split the logic again according to the scope. First, I am identifying the group by the role name, than I am creating the context for it based on the scope. The rest is much the same as before.

C#
public override string[] GetUsersInRole(string roleName)
 {
  try
  {
   var roleGroup = RoleConfigurationHelper.GetRoleGroups().First
                   (x => string.Equals(x.RoleName, roleName, 
                   StringComparison.OrdinalIgnoreCase)).WindowsGroupName;
 
   SecurityProviderHelpers.DomainUser dn = new SecurityProviderHelpers.DomainUser(roleGroup);
   PrincipalContext ctx;
 
   if (scope == SecurityProviderHelpers.AuthenticationScope.Domain)
   {
    string domainName = SecurityProviderHelpers.FriendlyDomainToLdapDomain(dn.DomainMame);
    ctx = new PrincipalContext(ContextType.Domain, domainName);
   }
   else
   {
    ctx = new PrincipalContext(ContextType.Machine, dn.DomainMame);
   }
   GroupPrincipal group = new GroupPrincipal(ctx, dn.PrincipalName);
 
   return group.GetMembers(true).Select(x => x.SamAccountName).ToArray();
  }
  catch
  {
   return new string[0];
  }
 }
...
}

You might ask what happens with the nested groups themselves - are they also returned? Of course not, on the MSDN page, you can read the explanation:

The returned principal collection does not contain group objects when the recursive flag is set to true; only leaf nodes are returned.

The Membership Provider

It was created to satisfy the same needs, especially to be usable in both machine and domain context, by passing the same kind of parameter to the initializer. It incorporates code to authenticate user credentials against one or the other authority. But as mentioned before, most of the methods are not implemented because user management in an enterprise Active Directory environment is rarely performed via a business application.

Points of Interest

In my case, cookie based role caching was enabled because the business process supported by the application allowed it. But it would be interesting to add some other method, like session based (especially with state server) - supporting aging and adding the dynamicity that allows mapping to be altered during worker process runtime. This would allow supporting really large enterprise class solutions.

History

  • 21st February, 2014: Initial version

License

This article, along with any associated source code and files, is licensed under The Eclipse Public License 1.0


Written By
Technical Lead
Hungary Hungary
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
-- There are no messages in this forum --