MVC Windows Authentication with AD Groups

When working with Windows auth and using AD groups to manage authorization in an MVC application, performance can be terrible and changing environments is just not friendly with the out of the box authorization attributes in MVC. Below are a couple tricks I use to make things works faster and more dynamically.

Performance

The most important part of any application (after it works correctly) is speed. Companies spend a lot of money on their employees and don't need them sitting there waiting for their apps to run. This might not be an issue in all environments, but in the one I was working, users were a part of over 400 AD groups on average. When using Authorize on the controllers and specifying the AD group, it would take 5-10 seconds to finish authorization, and then 50ms to run the operation the user requested. The default Authorize attribute uses the WindowsTokenRoleProvider for checking the user's membership, in particular a call to GetRolesForUser. This method iterates over every group in the user's membership, checking to see if it matched the group I was checking for. I am sure there is a reason for doing so, but with 400-600 groups its just too slow. To fix that, I created a custom WindowsTokenRoleProvider to override that method and only check if the user is a member of the groups I am using in my application:

using System;
using System.Collections.Generic;
using System.Web;
using System.Web.Caching;
using System.Web.Security;

namespace Common
{
    public class CustomWindowsTokenRoleProvider : WindowsTokenRoleProvider
    {
        public override string[] GetRolesForUser(string username)
        {
            // Will contain the list of roles that the user is a member of
            List<string> roles = null;

            // Create unique cache key for the user
            string key = string.Concat(username, ":", base.ApplicationName);

            // Get cache for current session
            Cache cache = HttpContext.Current.Cache;

            // Obtain cached roles for the user
            if (cache[key] != null)
            {
                roles = new List<string>(cache[key] as string[]);
            }

            // Was the list of roles for the user in the cache?
            if (roles == null)
            {
                roles = new List<string>();
                // For each system role, determine if the user is a member of that role
                foreach (string role in AppSettings.APPLICATION_GROUPS)
                {
                    if (base.IsUserInRole(username, role))
                    {
                        roles.Add(role);
                    }
                }
                
                // Cache the roles for 1 hour
                cache.Insert(key, roles.ToArray(), null, DateTime.Now.AddHours(1), Cache.NoSlidingExpiration);
            }
            
            // Return list of roles for the user
            return roles.ToArray();
        }
    }
}

Make sure the above class is in your project (or in a required assembly), and then just add this next section to the <system.web> section of your web.config:

<rolemanager defaultprovider="WindowsProvider" enabled="true" cacherolesincookie="false" domain="">
    <providers>
        <add name="WindowsProvider" type="Common.CustomWindowsTokenRoleProvider" applicationname="/" />
    </providers>
</rolemanager>

Instead of iterating through all of the roles in AD and checking if the user is a member, I just check if the user is a member of my application's groups and return that list. I also added some caching so the look ups only have to happen once per hour for the user (internal application, permissions don't really change that often).

With this change, authorization went from 5-10 seconds per request to under 100ms for the cold look up, even less once the user's cache is primed. Now that it's faster, lets look at extensibility options that allows moving between environments without code changes.

Extensibility

The issue of moving between environments came up when I started building out a dev domain for testing application changes before production. Another downfall of the Authorize attribute is that it requires a const string for the group, which works great until you need to go from dev to prod. The workaround I cam up with has a couple steps, but it's 100x easier than remembering everywhere you had a hard coded group name in your project. First, make the security groups a section of your web.config:

<roles>
    <add key="SecurityGroupOne" value="PROD\Application-GroupOne" />
    <add key="SecurityGroupTwo" value="PROD\Application-GroupTwo" />
    <add key="AdminSecurityGroup" value="PROD\Application-Admin" />
</roles>

You could add these as appSettings, but as you will see in a moment, it is much easier adding your own section. You will have to add this line in your to register the custom section "roles":

<section name="roles" type="System.Configuration.NameValueFileSectionHandler,System, Version=1.0.3300.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" />

Now that the groups are in our web.config, it is very simple to update the groups when switching environments. I won't go into detail, but you should use the web.config transforms to take care of environment specific changes in your web.cong (take a look here for more details).

I like to create a static class called AppSettings for loading any properties from my web.configs since it adds an element of static typing to using properties and aids in finding uses of your appSettings if you ever need to change them. Here is what I made the class look like for these roles:

using System.Collections.Generic;
using System.Collections.Specialized;
using System.Configuration;
using System.Linq;
    
namespace Common
{
    public static class AppSettings
    {
        private static NameValueCollection _cachedRoles;
        private static NameValueCollection _roles
        {
            get
            {
                if (_cachedRoles == null)
                {
                    _cachedRoles = ConfigurationManager.GetSection("roles") as NameValueCollection;
                }
                return _cachedRoles;
            }
        }
        public static string SECURITY_GROUP_ONE
        {
            get
            {
                return _roles.GetValues("SecurityGroupOne").First();
            }
        }
        public static string SECURITY_GROUP_TWO
        {
            get
            {
                return _roles.GetValues("SecurityGroupTwo").First();
            }
        }
        public static string ADMIN_SECURITY_GROUP
        {
            get
            {
                return _roles.GetValues("AdminSecurityGroup").First();
            }
        }
        public static IEnumerable<string> APPLICATION_GROUPS
        {
            get
            {
                return _roles.Cast<string>().Select(e => _roles[e]);
            }
        }
        }
}

By having the roles in it's own section, we can load the entire section into a collection and the just do look ups to match the role we need. You probably noticed the AppSettings.APPLICATION_GROUPS back in the CustomWindowsTokenProvider, here is where that comes from. It makes keep track of all your groups easier as well!

So, authorization is now faster, and the actual groups are now easily changeable. The last piece is fixing the Authorize attribute. Since we have to use a const for Authorize, I decided to create my own Authorize attribute that takes role names as parameters:

using System.Collections.Generic;
using System.Collections.Specialized;
using System.Configuration;
using System.Web.Mvc;

namespace Common.Attributes
{
    public class GroupAuthorize : AuthorizeAttribute
    {
        public GroupAuthorize(params string[] roleKeys)
        {
            List<string> roles = new List<string>(roleKeys.Length);
            NameValueCollection allRoles = ConfigurationManager.GetSection("roles") as NameValueCollection;
            foreach (string roleKey in roleKeys)
            {
                roles.Add(allRoles[roleKey]);
            }
            this.Roles = string.Join(",", roles);
        }
    }
}

Using this is pretty simple, just pass in the role name that is defined in your web.config section and it will load the actual group name into the "Roles" property for the base AuthorizeAttribute class to use:

[GroupAuthorize("AdminSecurityGroup", "SecurityGroupOne")]
public class FileController 
{
    
}

Hopefully this helps someone else make AD groups work better in MVC!