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 )
{
...
}