Why can't I combine [Authorize] and [OutputCache] attributes when using Azure cache (.NET MVC3 app)?
Using Windows Azure's Microsoft.Web.DistributedCache.DistributedCacheOutputCacheProvider
as the outputCache provider for an MVC3 app. Here is the relevant action method:
[ActionName("sample-cached-page")]
[OutputCache(Duration = 300, VaryByCustom = "User",
Location = OutputCacheLocation.Server)]
[Authorize(Users = "[email protected],[email protected]")]
public virtual ActionResult SampleCachedPage()
{
return View();
}
I get the following exception when loading this view from a web browser:
System.Configuration.Provider.ProviderException: When using a custom output cache provider like 'DistributedCache', only the following expiration policies and cache features are supported: file dependencies, absolute expirations, static validation callbacks and static substitution callbacks.
System.Configuration.Provider.ProviderException: When using a custom output cache provider like 'DistributedCache', only the following expiration policies and cache features are supported: file dependencies, absolute expirations, static validation callbacks and static substitution callbacks.
at System.Web.Caching.OutputCache.InsertResponse(String cachedVaryKey, CachedVary cachedVary, String rawResponseKey, CachedRawResponse rawResponse, CacheDependency dependencies, DateTime absExp, TimeSpan slidingExp)
at System.Web.Caching.OutputCacheModule.OnLeave(Object source, EventArgs eventArgs)
at System.Web.HttpApplication.SyncEventExecutionStep.System.Web.HttpApplication.IExecutionStep.Execute()
at System.Web.HttpApplication.ExecuteStep(IExecutionStep step, Boolean& completedSynchronously)
If I remove the [Authorize] attribute, the view caches as would be expected. Does this mean I cannot put [OutputCache] on an action method that must have [Authorize]? Or, do I need to override AuthorizeAttribute with a custom implementation that uses a static validation callback method for the cache?
Update 1
After Evan's answer, I tested the above action method in IIS Express (outside of Azure). Here is my override for the VaryByCustom = "User" property on the OutputCache attribute:
public override string GetVaryByCustomString(HttpContext context, string custom)
{
return "User".Equals(custom, StringComparison.OrdinalIgnoreCase)
? Thread.CurrentPrincipal.Identity.Name
: base.GetVaryByCustomString(context, custom);
}
When I visit the sample cached page as [email protected], the output of the page is cached, and the view displays "This page was cached at 12/31/2011 11:06:12 AM (UTC)". If I then sign out and sign in as [email protected] and visit the page, it displays "This page was cached at 12/31/2011 11:06:38 AM (UTC)". Signing back in as [email protected] and revisiting the page causes the cache to display "This page was cached at 12/31/2011 11:06:12 AM (UTC)" again. Further sign in/out attempts show that different output is being cached & returned depending on the user.
This is leading me to believe that the output is being cached separately based on the user, which is the intention with my VaryByCustom = "User" setting & override. The problem is that it doesn't work with Azure's distributed cache provider. Evan, does you answer about only caching public content still stand?
Update 2
I dug up the source, and found that the out-of-box AuthorizeAttribute does in fact have a non-static validation callback. Here is an excerpt from OnAuthorization
:
if (AuthorizeCore(filterContext.HttpContext)) {
// ** 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 */);
}
else {
HandleUnauthorizedRequest(filterContext);
}
CacheValidationHandler
delegates the cache validation to protected virtual HttpValidationStatus OnCacheAuthorization(HttpContextBase)
, which of course is not static. One reason why it is not static is because, as noted in the IMPORTANT comment above, it invokes protected virtual bool AuthorizeCore(HttpContextBase)
.
In order to do any of the AuthorizeCore logic from a static cache validation callback method, it would need to know the Users and Roles properties of the AuthorizeAttribute instance. However there doesn't seem to be an easy way to plug in. I would have to override OnAuthorization to put these 2 values into the HttpContext (Items collection?) and then override OnCacheAuthorization to get them back out. But that smells dirty.
If we are careful to use the VaryByCustom = "User" property in the OutputCache attribute, can we just override OnCacheAuthorization to always return HttpValidationStatus.Valid? When the action method does not have an OutputCache attribute, we would not need to worry about this callback ever being invoked, correct? And if we do have an OutputCache attribute without VaryByCustom = "User", then it should be obvious that the page could return any cached version regardless of which user request created the cached copy. How risky is this?
Solution 1:
Caching happens before the Action. You will likely need to customize your authorization mechanics to handle cache scenarios.
Check out a question I posted a while back - MVC Custom Authentication, Authorization, and Roles Implementation.
The part I think would help you is a custom Authorize Attribute who's OnAuthorize()
method deals with caching.
Below is a code block for example:
/// <summary>
/// Uses injected authorization service to determine if the session user
/// has necessary role privileges.
/// </summary>
/// <remarks>As authorization code runs at the action level, after the
/// caching module, our authorization code is hooked into the caching
/// mechanics, to ensure unauthorized users are not served up a
/// prior-authorized page.
/// Note: Special thanks to TheCloudlessSky on StackOverflow.
/// </remarks>
public void OnAuthorization(AuthorizationContext filterContext)
{
// User must be authenticated and Session not be null
if (!filterContext.HttpContext.User.Identity.IsAuthenticated || filterContext.HttpContext.Session == null)
HandleUnauthorizedRequest(filterContext);
else {
// if authorized, handle cache validation
if (_authorizationService.IsAuthorized((UserSessionInfoViewModel)filterContext.HttpContext.Session["user"], _authorizedRoles)) {
var cache = filterContext.HttpContext.Response.Cache;
cache.SetProxyMaxAge(new TimeSpan(0));
cache.AddValidationCallback((HttpContext context, object o, ref HttpValidationStatus status) => AuthorizeCache(context), null);
}
else
HandleUnauthorizedRequest(filterContext);
}
}
/// <summary>
/// Ensures that authorization is checked on cached pages.
/// </summary>
/// <param name="httpContext"></param>
/// <returns></returns>
public HttpValidationStatus AuthorizeCache(HttpContext httpContext)
{
if (httpContext.Session == null)
return HttpValidationStatus.Invalid;
return _authorizationService.IsAuthorized((UserSessionInfoViewModel) httpContext.Session["user"], _authorizedRoles)
? HttpValidationStatus.Valid
: HttpValidationStatus.IgnoreThisRequest;
}
Solution 2:
I've come back to this issue and, after a bit of tinkering, have concluded that you cannot use the out of the box System.Web.Mvc.AuthorizeAttribute
along with the out of the box System.Web.Mvc.OutputCacheAttribute
when using the Azure DistributedCache. The main reason is because, as the error message in the original question states, the validation callback method must be static in order to use it with Azure's DistributedCache. The cache callback method in the MVC Authorize attribute is an instance method.
I went about trying to figure out how to make it work by making a copy of the AuthorizeAttribute from the MVC source, renaming it, hooking it up to an action with OutputCache connected to Azure, and debugging. The reason the cache callback method is not static is because, in order to authorize, the attribute needs to check the HttpContext's User against the Users and Roles property values that are set when the attribute is constructed. Here is the relevant code:
OnAuthorization
public virtual void OnAuthorization(AuthorizationContext filterContext) {
//... code to check argument and child action cache
if (AuthorizeCore(filterContext.HttpContext)) {
// 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 */);
}
else {
HandleUnauthorizedRequest(filterContext);
}
}
Cache Validation Callback
private void CacheValidateHandler(HttpContext context, object data,
ref HttpValidationStatus validationStatus) {
validationStatus = OnCacheAuthorization(new HttpContextWrapper(context));
}
// This method must be thread-safe since it is called by the caching module.
protected virtual HttpValidationStatus OnCacheAuthorization
(HttpContextBase httpContext) {
if (httpContext == null) {
throw new ArgumentNullException("httpContext");
}
bool isAuthorized = AuthorizeCore(httpContext);
return (isAuthorized)
? HttpValidationStatus.Valid
: HttpValidationStatus.IgnoreThisRequest;
}
As you can see, the cache validation callback ultimately invokes AuthorizeCore, which is another instance method (protected virtual). AuthorizeCore, which was also called during OnAuthorization, does 3 main things:
Checks that the HttpContextBase.User.Identity.IsAuthenticated == true
If the attribute has a non-empty Users string property, checks that the HttpContextBase.User.Identity.Name matches one of the comma-separated values.
If the attribute has a non-empty Roles string property, checks that the HttpContextBase.User.IsInRole for one of the comma-separated values.
AuthorizeCore
// This method must be thread-safe since it is called by the thread-safe
// OnCacheAuthorization() method.
protected virtual bool AuthorizeCore(HttpContextBase httpContext) {
if (httpContext == null) {
throw new ArgumentNullException("httpContext");
}
IPrincipal user = httpContext.User;
if (!user.Identity.IsAuthenticated) {
return false;
}
if (_usersSplit.Length > 0 && !_usersSplit.Contains
(user.Identity.Name, StringComparer.OrdinalIgnoreCase)) {
return false;
}
if (_rolesSplit.Length > 0 && !_rolesSplit.Any(user.IsInRole)) {
return false;
}
return true;
}
When you simply try to make the validation callback method static, the code won't compile because it needs access to these _rolesSplit and _usersSplit fields, which are based on the public Users and Roles properties.
My first attempt was to pass these values to the callback using the object data
argument of the CacheValidateHandler
. Even after introducing static methods, this still did not work, and resulted in the same exception. I was hoping that the object data would be serialized, then passed back to the validate handler during the callback. Apparently this is not the case, and when you try to do this, Azure's DistributedCache still considers it a non-static callback, resulting in the same exception & message.
// this won't work
cachePolicy.AddValidationCallback(CacheValidateHandler, new object() /* data */);
My second attempt was to add the values to the HttpContext.Items
collection, since an instance of HttpContext
is automatically passed to the handler. This didn't work either. The HttpContext
that is passed to the CacheValidateHandler
is not the same instance that existed on the filterContext.HttpContext
property. In fact, when the CacheValidateHandler executes, it has a null Session and always has an empty Items collection.
// this won't work
private void CacheValidateHandler(HttpContext context, object data,
ref HttpValidationStatus validationStatus) {
Debug.Assert(!context.Items.Any()); // even after I put items into it
validationStatus = OnCacheAuthorization(new HttpContextWrapper(context));
}
However...
Even though there seems to be no way to pass the Users & Roles property values back to the cache validation callback handler, the HttpContext
passed to it does in fact have the correct User Principal. Also, none of the actions where I currently want to combine [Authorize] and [OutputCache] ever pass a Users or Roles property to the AuthorizeAttribute constructor.
So, it is possible to create a custom AuthenticateAttribute which ignores these properties, and only checks to make sure the User.Identity.IsAuthenticated == true
. If you need to authenticate against a specific role, you could also do so and combine with OutputCache... however, you would need a distinct attribute for each (set of) Role(s) in order to make the cache validation callback method static. I will come back and post the code after I've polished it a bit.
Solution 3:
You are correct olive. Caching works by caching the entire output of the Action (including all attributes) then returning the result to subsequent calls without actually calling any of your code.
Because of this you cannot cache and check authorization because by caching you are not going to call any of your code (including authorization). Therefore anything that is cached must be public.