Monday, November 2, 2009

Customizing authorization in ASP.NET MVC

I've seen, and answered, a few questions on StackOverflow about specific authorization scenarios that fall outside the bounds of what the standard AuthorizeAttribute can handle. I've run into a couple of different situations where I've needed to do this with my own apps as well. One specific case is where you have a relationship between the entity being referred to by the controller action and the user. In this case, the user is the owner of the data and should have rights regardless of their role relationship with the data. To allow access under these circumstances, I extended the AuthorizeAttribute, creating a RoleOrOwnerAuthorizationAttribute class that takes this ownership relation into account. I use this attribute to control access to the user's own data (stored in my user table).

Pre-requisites



In order for this attribute to work it needs a few things. First, it needs to know what parameter in the route data corresponds with the id found in the table we are looking at. Second, it needs a way to access your database to find if there is a row that matches both the username of the current user and the id specified in the request. For the former, I use a property on the class that defaults to the string "id". For the latter, I use a ContextFactory which is constructor-injected, but defaults to a factory for the app's data context.

How it works


Derive from AuthorizeAttribute

The class derives from AuthorizeAttribute. I do apply a different set of AttributeUsage properties to the class as I don't want to support multiple instances of this method. Note that you can use it in conjunction with the standard AuthorizeAttribute and achieve similar effects where you scope the class at one authorization level and narrow it down. You need to be careful, though, as class-level attributes may prevent this attribute from allowing owner access. Note that it also doesn't make sense to apply this attribute at the class level itself since it requires a specific id parameter for each method.

[AttributeUsage( AttributeTargets.Method, Inherited = true, AllowMultiple = false )]
public class RoleOrOwnerAuthorizationAttribute : AuthorizeAttribute
{
private string routeParameter = "id";
/// <summary>
/// The name of the routing parameter to use to identify the owner of the data (participant id) in question. Default is "id".
/// </summary>
public string RouteParameter
{
get { return this.routeParameter; }
set { this.routeParameter = value; }
}

...
}

Constructor Injection


For the factory implementation and to allow easier unit testing I use constructor injection with a default instance.

public RoleOrOwnerAuthorizationAttribute()
: this( null )
{
}

public RoleOrOwnerAuthorizationAttribute( IDataContextFactory factory )
{
this.ContextFactory = factory ?? new MyDataContextFactory();
}

The IDataContextFactory actually produces a DataContextWrapper, which I wrote about earlier when discussing how to mock and fake the LINQtoSQL data context.

public interface IDataContextFactory
{
IDataContextWrapper GetDataContextWrapper();
}

public class MyDataContextFactory : IDataContextFactory
{
public virtual IDataContextWrapper GetDataContextWrapper()
{
return new DataContextWrapper<MyDataContext>();
}
}

Override OnAuthorization


Note I've refactored some code from the original AuthorizeAttribute that will be used when handling caching. The original attribute doesn't provide a mechanism to tie into this code directly so I've provided an implementation that is used when overriding OnAuthorization so that caching is handled properly. This code is copied almost verbatim from the AuthorizeAttribute -- it would be nice if they'd refactor so I could simply use it.


protected void CacheValidateHandler( HttpContext context, object data, ref HttpValidationStatus validationStatus )
{
validationStatus = OnCacheAuthorization( new HttpContextWrapper( context ) );
}

protected void SetCachePolicy( AuthorizationContext filterContext )
{
// ** IMPORTANT **
// Since we're performing authorization at the action level, the authorization code runs
// after the output caching module. In the worst case this could allow an authorized user
// to cause the page to be cached, then an unauthorized user would later be served the
// cached page. We work around this by telling proxies not to cache the sensitive page,
// then we hook our custom authorization code into the caching mechanism so that we have
// the final say on whether a page should be served from the cache.
HttpCachePolicyBase cachePolicy = filterContext.HttpContext.Response.Cache;
cachePolicy.SetProxyMaxAge( new TimeSpan( 0 ) );
cachePolicy.AddValidationCallback( CacheValidateHandler, null /* data */);
}

Now, all that remains is to provide the implementation. The implementation first runs AuthorizeCore from the parent class. If it succeeds, then we don't need to check for ownership. It then checks if the user is authenticated, if not, we return a redirect to the login page. Finally it checks if the user is the owner of the related data. If that succeeds, we continue on, otherwise we deliver an error message to the user. This latter is unlike the normal AuthorizeAttribute, which would redirect to the login page. In this case, though, the user is authenticated and simply does not have enough privilege.

public override void OnAuthorization( AuthorizationContext filterContext )
{
if (filterContext == null)
{
throw new ArgumentNullException( "filterContext" );
}

if (AuthorizeCore( filterContext.HttpContext ))
{
SetCachePolicy( filterContext );
}
else if (!filterContext.HttpContext.User.Identity.IsAuthenticated)
{
// auth failed, redirect to login page
filterContext.Result = new HttpUnauthorizedResult();
}
else if (IsOwner( filterContext ))
{
SetCachePolicy( filterContext );
}
else
{
ViewDataDictionary viewData = new ViewDataDictionary();
viewData.Add( "Message", "You do not have sufficient privileges for this operation." );
filterContext.Result = new ViewResult { ViewName = "Error", ViewData = viewData };
}

}

private bool IsOwner( AuthorizationContext filterContext )
{
using (IAuditableDataContextWrapper dc = this.ContextFactory.GetDataContextWrapper())
{
int id = -1;
if (filterContext.RouteData.Values.ContainsKey( this.RouteParameter ))
{
id = Convert.ToInt32( filterContext.RouteData.Values[this.RouteParameter] );
}

string userName = filterContext.HttpContext.User.Identity.Name;

return dc.Table<Users>().Where( u => u.UserName == userName && u.UserID == id ).Any();
}
}

Usage


Now we have an attribute that allows the user or anyone in a suitable role to have access to a controller action based on the current user and the routing parameter used to specify which user's data should be operated on.

[RoleOrOwnerAuthorization( Roles = "Admin", RouteParameter = "userID" )]
public ActionResult UpdateContact( int userID )
{
...
}

7 comments :

  1. Just about perfect! I have a concern about the authorizations that would need access to RouteData (or other AuthorizationContext information) not having access to the properties during the OnCacheAuthorization callback.

    Would this be legal?

    // this has to replace the existing code, because we need to pass the entire
    // filterContext to the Allowed method and also pass it to the cache validation callback
    public override void OnAuthorization(AuthorizationContext filterContext)
    {
    if (filterContext == null)
    throw new ArgumentNullException("filterContext");

    if (IsOwner(filterContext))
    {
    HttpCachePolicyBase cache = filterContext.HttpContext.Response.Cache;
    cache.SetProxyMaxAge(new TimeSpan(0L));
    cache.AddValidationCallback(this.CacheValidateHandler, filterContext);
    }
    else
    {
    HandleUnauthorizedRequest(filterContext);
    }
    }

    Then having this for the callbacks:

    protected override HttpValidationStatus OnCacheAuthorization(HttpContextBase httpContext)
    {
    return OnCacheAuthorization(httpContext, null);
    }

    // this (non-override) version takes the AuthorizationContext (original filterContext) during callback
    protected HttpValidationStatus OnCacheAuthorization(HttpContextBase httpContext, AuthorizationContext filterContext)
    {
    if (httpContext != null
    && httpContext.User != null
    && httpContext.User.Identity != null
    && httpContext.User.Identity.IsAuthenticated)
    {
    if (filterContext != null && IsOwner(filterContext))
    return HttpValidationStatus.IgnoreThisRequest;
    else
    return HttpValidationStatus.Invalid;
    }
    else
    return HttpValidationStatus.Valid;
    }

    ReplyDelete
  2. I've posted an updated version that addresses the caching issue for owners. See http://farm-fresh-code.blogspot.com/2011/03/revisiting-custom-authorization-in.html

    ReplyDelete
  3. Hi, I know it is kind of old post, but It inspired me to implement this solution in my ASP.NET MVC 2 application. I have some problem however:

    http://stackoverflow.com/questions/5936490/custom-authenticationattribute-using-windows-authentication

    could You help?

    ReplyDelete
  4. Very helpful. Thanks for explaining how you made this work.

    I had a small problem with this code, though. Where OnAuthorization() is supposed to return an error message to the current view, the "this" object has no MasterName or ViewName member. Where do they come from?

    ReplyDelete
  5. @suncat2000 - I tried to simplify the example and must have not caught that. In my actual code this class doesn't derive directly from AuthorizeAttribute but from another class I use as a base class for all of my custom authorization attributes. That class has those properties. I've removed them here to avoid confusion. You may be interested in looking at an updated post on this: http://farm-fresh-code.blogspot.com/2011/03/revisiting-custom-authorization-in.html

    ReplyDelete
  6. I found your base class at http://stackoverflow.com/questions/977071/redirecting-unauthorized-controller-in-asp-net-mvc. Thanks for making this information available.

    ReplyDelete
  7. Nice post. I learn something totally new and challenging on sites I stumbleupon
    on a daily basis. It will always be exciting to read content from other writers and practice
    something from their web sites.

    Here is my web site: book of ra tricks kostenlos

    ReplyDelete

Comments are moderated.