Can I manually invoke the ClaimsAuthorizationManager for a given ActionResult?

1.7k views Asked by At

I have an ASP.NET MVC web app which is utilizing claims-based authorization via WIF and Thinktecture.IdentityModel. However, rather than just intercepting the unauthorized request, I'd like to trim my navigation menus to only display the links accessible to the current user.

My initial thoughts are to accept a list of actions (ActionResult, ActionLink, route value dictionary, not quite sure yet) and execute my custom ClaimsAuthorizationManger.CheckAccess routine. In order to do this, I need to generate an AuthorizationContext, however, I'm not sure if the framework utilities (or preferably abstractions) creating the context are accessible. Anyone know if this possible? Or, if I'm going about this all wrong, what do you suggest?

Thanks and happy holidays!

3

There are 3 answers

0
Vinney Kelly On BEST ANSWER

As indicated by Dominick Baier, I was able to create a nice solution using Thinktecture.IdentityModel. I've posted the code in a Gist. This specific functionality seems to be undocumented but I was able to piece together little bits and pieces from related blog posts and documentation. I'd appreciate any feedback as I'm sure there is some corner of IdentityModel or WIF that could alleviate come of my code. That said, this is working well as is. Let's start with an example usage:

Razor

// This would likely be a partial view with per-user output caching
@Html.BuildNavigation(new List<NavigationItem>
{
    new NavigationItem("Production", MVC.Production.Home.Index()),
    new NavigationItem("Inventory", MVC.Inventory.Home.Index()),
    new NavigationItem("Quality Control", MVC.QualityControl.Home.Index()),
    new NavigationItem("Customers", MVC.Sales.Home.Index()),
    new NavigationItem("Vendors", MVC.Vendors.Companies.Index()),
})

As depicted below, the BuildNavigation helper function will render a <ul> containing all the navigation items for which the user is authorized to view. In case you're wondering about the MVC.{Area}.{Controller}.{Action}() statements, those are T4MVC helpers. It is not necessary to use T4MVC. The NavigationItem provides an override accepting an ActionResult for convenience. In actuality, the NavigationItem only consists of a string to display and a RouteValueDictionary (see the Gist).

HTML Helper Extensions

In order to utilize the existing authorization utilities provided by Thinktecture.IdentityModel, you have to create a RequestContext. Notice we build the RouteData from the RouteValueDictionary and then create a new RequestContext for the authorization helper. Once important side note: if you're making use of Areas in your MVC project, as I am, the AddNamespaceInfo function is paramount. If you have duplicated controller names in separate areas, this is how the controller factory will know how to access the correct one. Otherwise, you'll receive an exception.

public static class NavigationHelper
{
    public static MvcHtmlString BuildNavigation(this HtmlHelper htmlHelper, IEnumerable<NavigationItem> navigationItems)
    {
        var container = new TagBuilder("ul");
        container.MergeAttribute("id", "menu");
        var innerHtmlBuilder = new StringBuilder();
        foreach (var item in navigationItems.Where(item => IsAuthorized(htmlHelper, item.RouteValueDictionary)))
        {
            innerHtmlBuilder.Append(
                new TagBuilder("li")
                {
                    InnerHtml = htmlHelper.ActionLink(
                        item.LinkText, 
                        item.RouteValueDictionary["action"] as string,
                        item.RouteValueDictionary["controller"] as string, 
                        item.RouteValueDictionary, null).ToHtmlString()
                });
        }
        container.InnerHtml = innerHtmlBuilder.ToString();
        return new MvcHtmlString(container.ToString());
    }

    private static bool IsAuthorized(this HtmlHelper htmlHelper, RouteValueDictionary routeValues)
    {
        var routeData = BuildRouteData(htmlHelper.RouteCollection, routeValues);
        var context = BuildRequestContext(htmlHelper, routeData);
        return ClaimsAuthorizationHelper.CheckAccess(context);
    }

    private static RouteData BuildRouteData(IEnumerable<RouteBase> routeCollection, RouteValueDictionary routeValues)
    {
        object controllerValue;
        routeValues.TryGetValue("controller", out controllerValue);
        var controllerName = controllerValue as string;

        object actionValue;
        routeValues.TryGetValue("action", out actionValue);
        var actionName = actionValue as String;

        object areaValue;
        routeValues.TryGetValue("area", out areaValue);
        var areaName = areaValue as String ?? "";

        var routeData = new RouteData();
        routeData.Values.Add("action", actionName);
        routeData.Values.Add("controller", controllerName);
        routeData.Values.Add("area", areaName);
        AddNamespaceInfo(routeData, routeCollection, areaName, controllerName, actionName);

        return routeData;
    }

    private static RequestContext BuildRequestContext(this HtmlHelper htmlHelper, RouteData routeData)
    {
        var claimsPrincipal = htmlHelper.ViewContext.HttpContext.User as ClaimsPrincipal;
        var requestContext = new RequestContext(htmlHelper.ViewContext.HttpContext, routeData);
        requestContext.HttpContext.User = claimsPrincipal;

        return requestContext;
    }

    private static void AddNamespaceInfo(RouteData routeData, IEnumerable<RouteBase> routeCollection, string areaName, string controllerName, string actionName)
    {
        var route = routeCollection.GetRoute(areaName, controllerName, actionName);

        if (route != null)
        {
            routeData.DataTokens.Add("Namespaces", route.DataTokens["Namespaces"]);
        }
    }
}

Thinktecture.IdentityModel ClaimsAuthorizeAttribute Wrapper

Another stumbling block I encountered was the closed nature of the ClaimsAuthorizeAttribute. This is one area that I suspect may be eliminated by a deeper understanding of WIF. However, for the time, I've created a wrapper around the ClaimsAuthorizeAttribute which allows me to convert the attribute into claims.

public class ClaimsAuthorizeAttribute : Thinktecture.IdentityModel.Authorization.Mvc.ClaimsAuthorizeAttribute
{
    private readonly string _action;
    private readonly string[] _resources;

    public ClaimsAuthorizeAttribute(string action, params string[] resources)
        :base(action, resources)
    {
        _action = action;
        _resources = resources;
    }

    public IEnumerable<Claim> GetClaims()
    {
        return _resources.Select(r => new Claim(_action, r));
    }
}

Claims Authorization Helper

Finally, here is the ClaimsAuthorizationHelper which is responsible for resolving the necessary controller and action method, retrieving the resource claims, and calling into the ClaimsAuthorization utility provided by Thinktecture IdentityModel.

public static class ClaimsAuthorizationHelper
{
    public static bool CheckAccess(RequestContext requestContext)
    {
        var routeData = requestContext.RouteData;
        var controllerName = routeData.Values["controller"] as string;
        var actionName = routeData.Values["action"] as string;

        var controller = GetControllerByName(requestContext, controllerName);
        var controllerDescriptor = new ReflectedControllerDescriptor(controller.GetType());
        var controllerContext = new ControllerContext(requestContext, controller);
        var actionDescriptor = controllerDescriptor.FindAction(controllerContext, actionName);

        var resourceClaims = actionDescriptor.ControllerDescriptor.GetCustomAttributes(typeof (ClaimsAuthorizeAttribute), false)
            .Cast<ClaimsAuthorizeAttribute>()
            .SelectMany(auth => auth.GetClaims()).ToList();

        resourceClaims.AddRange(actionDescriptor.GetCustomAttributes(typeof(ClaimsAuthorizeAttribute), false).Cast<ClaimsAuthorizeAttribute>()
            .SelectMany(c => c.GetClaims()));

        var hasAccess = ClaimsAuthorization.CheckAccess(actionName, resourceClaims.ToArray());
        return hasAccess;
    }

    public static ControllerBase GetControllerByName(RequestContext requestContext, string controllerName)
    {
        var factory = ControllerBuilder.Current.GetControllerFactory();
        var controller = factory.CreateController(requestContext, controllerName);
        if (controller == null)
        {
            throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "The current controller factory, \"{0}\", did not return a controller for the name \"{1}\".", factory.GetType(), controllerName));
        }

        return (ControllerBase)controller;
    }
}

Other Code

There are some other helpers and classes which have been omitted for brevity. Please see the Gist for the full code.

0
Jim Mischel On

In our system, we have custom claims defined in the user database. The custom claims define the menu items that the user has access to. When the user logs in (using Thinktecture Identity Server), those claims get added to the user token as additional claims.

When our application displays the user's menu, it gets the claims from the current principal and iterates that list looking for menu claims, and creates links only for those claims.

For example, a user might have rights to add new transactions and view existing transactions, but no rights to modify or delete. So his claims would be:

name = "http://schemas.mycompany.com/2013/10/identity/claims/newTransaction" value = "true"
name = "http://schemas.mycompany.com/2013/10/identity/claims/viewTransaction" value = "true"

To check the claims:

var cp = (ClaimsPrincipal)Thread.CurrentPrincipal;

if (cp.Claims.Contains(ClaimName))
{
    // enable that function
}

Note that our permissions are named for program functions, not MVC actions or links. That gives us the flexibility to rename the action in the code without having to change the claims themselves.

This appears to be the recommended way of using claims based authorization. At least, it's what I see in the MSDN examples and in the book Programming Windows Identity Foundation.

1
leastprivilege On

You can create an AuthorizationContext yourself and you can call the registered authorization manager via the FederatedAuthentication class.

Thinktecture.IdentityModel also has a static class called ClaimsAuthorization that helps in that process.