AuthorizationAttribute with Windows Authentication in MVC 4

Tags: WindowsTokenRoleProvider, AuthorizationAttribute, MVC, MVC 4

With MVC 4 the Visual Studio team released the SimpleMembershipProvider. I've used it and I'm not so sure "simple" is the word I'd use for it. :) In any case it works great for a forms authentication scenario. And if you really want to deep dive into it I highly recommend Long Le's blog.

But here I want to talk about Windows Integrated security:

In this intranet scenario you have end users with AD groups that you need to map onto MVC controllers and action methods. The AuthorizeAttribute supports this out of the box so that you can do something like this:

[Authorize(Roles = @"DOMAIN\OutOfLuck, DOMAIN\TryAgainLater")]
public class SecureController : Controller
{
    public ActionResult Index()
    {
        return View();
    }
}

If you decorate your controller with this attribute users who are in neither group will be challenged and kicked out with a HTTP 401. There is an obvious problem with this approach. The AD groups are hard-coded. In most enterprise scenarios group names will be different in each environment. For instance, developers (DOMAIN\AwesomeDevelopers) will have rights on the development server while QA (DOMAIN\QATesters) have rights on the QAT box. And in production neither of these groups should have rights because a totally different end user group does.

So in our code we want something more like this:

[Authorize(Roles = SystemRole.Administrators)]
public class SecureController : Controller
{
    public ActionResult Index()
    {
        return View();
    }
}

And add a class defining the SystemRole constant:

public class SystemRole
{
    public const string Administrators = "DOMAIN\OutOfLuck, DOMAIN\TryAgainLater";
}

But what have I accomplished here other than moving the problem downstream a bit? It's still hard-coded in the SystemRole class. What I really want is to get the roles out of my code and into my Web.config. So let's write a custom class that extends the AuthorizeAttribute class:

using System;
using System.Collections.Generic;
using System.Configuration;
using System.Diagnostics;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Web.Security;

namespace SecureWidget.Security
{
    [AttributeUsage(AttributeTargets.All, AllowMultiple = false, Inherited = true)]
    public class SquareWidgetAuthorizeAttribute : AuthorizeAttribute
    {
        protected override bool AuthorizeCore(HttpContextBase httpContext)
        {
            if (!httpContext.User.Identity.IsAuthenticated)
                return false;

            var roles = GetAuthorizedRoles();

            var provider = new WindowsTokenRoleProvider();
            if (roles.Any(role => provider.IsUserInRole(httpContext.User.Identity.Name, role)))
            {
                return true;
            }

            return base.AuthorizeCore(httpContext);
        }

        private IEnumerable<string> GetAuthorizedRoles()
        {
            var appSettings = ConfigurationManager.AppSettings[Roles];
            if (string.IsNullOrEmpty(appSettings))
            {
                Trace.TraceError("Missing AD groups in Web.config for Roles {0}", Roles);
                return new[] { "" };
            }
            return appSettings.Split(',');
        }
    }
}

Here I've overridden the AuthorizeCore method so that I can get AD groups from Web.config. This is accomplished in the GetAuthorizedRoles private method. This goes into the appSettings of my Web.config:

<add key="Administrators" value="DOMAIN\OutOfLuck, DOMAIN\TryAgainLater"/>

Now I can get those hard-coded AD roles out of my SystemRole class:

public class SystemRole
{
    public const string Administrators = "Administrators";
}

Last I have to change my SecureController to use my custom attribute:

[SquareWidgetAuthorize(Roles = SystemRole.Administrators)]
public class SecureController : Controller
{
    public ActionResult Index()
    {
        return View();
    }
}

To set up a complete sample project add an Index view to the Secure controller as well as a new menu item to the _Layout.cshtml file:

<li>@Html.ActionLink("Secure", "Index", "Secure")</li>

Now an end user can't get to the SecureController unless he's in the OutOfLuck or TryAgainLater AD groups. Now for some optional pro tips!

Pro Tip 1

If you're like me (be honest) you're never going to create a fancy 401 page like the designers do. Fortunately, there's a simple little change to redirect unauthorized users to a view. Add this action method to your HomeController:

public ActionResult Unauthorized()
{
    return View();
}

And the corresponding Unauthorized.cshtml view:

@{
    ViewBag.Title = "Unauthorized";
}

<h2>Unauthorized Access</h2>

<div>
    <p>
        You are not authorized to access that part of the system!
    </p>
</div>


One last thing. Go back to the SquareWidgetAuthorizeAttribute class and override the HandleUnauthorizedRequest method to redirect unauthorized users to the new view:

protected override void HandleUnauthorizedRequest(AuthorizationContext filterContext)
{
    base.HandleUnauthorizedRequest(filterContext);
    filterContext.Result = new RedirectResult("~/Home/Unauthorized");
}

Now you've got a consistent way to keep users in the app experience.

Pro Tip 2

Take a look at that AuthorizeCore method. I'm instantiating a WindowsTokenRoleProvider class to see if the end user's AD groups match any of the roles defined in my Administrators list. You could extend the role provider with your own implementation that caches all of the user's AD groups after the first call:

using System.Linq;
using System.Web;
using System.Web.Caching;
using System.Web.Security;

namespace SecureWidget.Security
{
    public class WindowsTokenCacheRoleProvider : WindowsTokenRoleProvider
    {
        public override string[] GetRolesForUser(string username)
        {
            var cacheKey = string.Format("{0}:{1}", username, base.ApplicationName);
            var cache = HttpContext.Current.Cache;
            var roles = cache[cacheKey] as string[];
            if (null == roles)
            {
                // allow the base implementation to load the list of roles.
                roles = base.GetRolesForUser(username);
                cache.Insert(cacheKey, roles, null, Cache.NoAbsoluteExpiration, Cache.NoSlidingExpiration);
            }
            return roles;
        }

        public override bool IsUserInRole(string username, string roleName)
        {
            var roles = GetRolesForUser(username).ToList();
            return roles.Any(r => r.Trim().ToUpper().Contains(roleName.Trim().ToUpper()));
        }
    }
}

If you do this you also have to define the new class in your Web.config:

<authentication mode="Windows" />
  <authorization>
    <deny users="?" />
  </authorization>
  <roleManager defaultProvider="WindowsProvider"
    enabled="true"
    cacheRolesInCookie="false">
    <providers>
      <add
        name="WindowsProvider"
        applicationName="SecureWidget"
        type="SecureWidget.Security.WindowsTokenCacheRoleProvider" />
    </providers>
  </roleManager>

Now in AuthorizeCore you can instantiate WindowsTokenCacheRoleProvider (rather than WindowsTokenRoleProvider) and get instant caching of the end user's AD groups after the first call to AD. There is a downside to this approach. A lot of end users sit on their browsers all day long. If the sysadmin adds them to a new group they still won't have access to the secured part of the system. You could play around with cache expiration. In my case, the sysadmin knows to tell the end user to close his browser and reopen it up again. That way we make that call out to AD and refresh the cache with the new group. You'll have to decide what's best in your environment.

1 Comment

  • Tony said

    James,

    This is a fantastic article, has really helped me on my way to figuring out how I want to roll my MVC4 security!

    Thank you. :)

Comments have been disabled for this content.