Use ActionFilters to lock down ASP.NET WebAPI
Action filters are one of those little used but powerful features of the ASP.NET MVC and ASP.NET WebAPI stack. Most people have heard of them or are at the very least aware of them, but few have taken the time to learn them. Outside of exception handling, the majority of the posts I have read don't give a good concrete example of how they can be used. I recently ran into a business requirement where an action filter made perfect sense. Locking down specific records from being edited when a certain set of conditions is met.
Business Requirement
I am the Lead Developer on the consumer loan application for Farm Credit Services of America. The latest and greatest version has a Single Page App (SPA) front-end that talks to a ASP.NET WebApi backend. This is different than the previous version where everything was one large ASP.NET web forms app.
One advantage to the web forms application was it did a pretty good job of making an loan read only once it was completed. This gets a little more tricky with a WebApi backend due to its stateless nature. Our risk team really doesn't like it when a loan changes after it is completed (crybabies).
There are two ways a loan can be updated, either through the WebApi or through a direct SQL statement on the database. We have a good policy in place for locking down the database from unauthorized writes. The WebApi is really where I wanted to focus.
When you think about it, this is really a security requirement. Let me explain.
Authentication vs Authorization
When it comes to security, there are two major areas we worry about, Authentication and Authorization.
- Authentication is ensuring the person is who they say they are. This wheel has been invented and re-invented. If I were creating a site from scratch I would use one of the many frameworks out there.
- Authorization is determining if the user is allowed to do the action they are trying to do. Such as updating a loan after it has been completed.
This post is concerned with Authorization.
Macro Authorization vs Micro Authorization
Claims are the first line of defense for Authorization. Does the user have the correct claim to access the site or certain features on the site. This is Macro level security. They either have the claim or they don't. You can get fairly granular with claims, but in a lot of instances they are used at the Macro level.
The second line of defense are Feature Flags. They are used to determine if a certain feature is available to users. An example for our loan application was if control to let users enter in a customer's financials was available. This is at the Macro level as well. The feature is either enabled for everyone or disabled for everyone.
Our requirement is concerned with the Micro level. The user has the claim to access the site, the feature is enabled, but should they be allowed to update a very specific record in the database.
Action Filters Introduction
The best way to handle this rule is to add it at the global level. Writing a specific check and asking developers to remember that specific check each time they write to the database is asking for trouble. It won’t get remembered. We’re human. We make mistakes (what?!?! no!).
But where at the global level? Right before something is written to the database is an option. Maybe in extend out the DBContext in entity framework or add a trigger. But that is at a very low level. The code may do a lot of work before it is ready to write an update to the database. A lot of expensive queries may have to run before the code determines what needs to be updated.
A better approach is to throw the error as early as possible in the pipeline.
The solution to all this??? Action Filters!!!
Action Filters are one of the three possible filters in the ASP.NET WebApi framework. The three filter types are
- Authorization Filter - authorize filters determine if the user is authenticated and has access to this controller.
- Exception Filter - only fires when an exception occurs
- Action Filter - fires after the authorization filter and before action filter
In terms of pipeline order the filters and controller action are run in this order
- Authorization Filter
- Action Filter - OnActionExecuting method
- Controller Action
- Action Filter - OnActionExecuted method
Fun facts about Action Filters
- They can run before the controller action runs or after the controller action runs
- It has full access to the request
- It knows all the variables being sent in
- It has full access to the response
- It has both async and sync methods - you should only implement one, both the async and sync methods run before the controller action runs
- You can use Dependency Injection so you can get access to the database or any special business logic
- They can be added globally or to a specific controller
- You can set the response. Once the response is set all subsequent actions are skipped.
Alright, let’s get to some code!
Completed Action Filter Code
Rules of the action filter
- Only Run when Action is Post, Put, Delete
- When Action is Post and method name begins with "get" don’t run
- At Farm Credit Services of America we use Post as a Get when we need to send across large objects or sensitive data
- If there are no arguments don’t run
- Only check when the identifier of the loan is included in the request
- Don’t let the user proceed if the loan is in a state which it can’t be updated.
First, create your new class and have it inherit from the ActionFilterAttribute.
namespace MySite.LoanApplication.WebAPI.Filters
{
public sealed class CompletedFilterAttribute : ActionFilterAttribute {
Next, we need to override the OnActionExecuting method.
{
if (ShouldRunStatusCheck(actionContext))
{
var loanId = GetLoanId(actionContext);
if (loanId > 0)
{
var calculator = (ILoanStatusCalculator) actionContext.ControllerContext.Configuration.DependencyResolver.GetService(typeof(ILoanStatusCalculator));
var results = calculator.CalculateStatus(loanId);
SetFailureResponse(actionContext, results);
}
}
base.OnActionExecuting(actionContext);
}
The method ShouldRunStatusCheck determines if anything should actually be done.
{
if (actionContext.Request.Method.NotIn(HttpMethod.Delete, HttpMethod.Post, HttpMethod.Put))
{
return false;
}
if (actionContext.ActionDescriptor.ActionName.StartsWith("get", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (actionContext.ActionArguments.Keys.Count == 0)
{
return false;
}
return true;
}
The GetLoanId method is one of the more tricky parts of the request. It needs to check both the object as well as the action arguments. And it gets to use reflection. Note: EqualsIgnoreCase is a custom extension method we wrote for string that will compare the string but ignore the case using OrdinalIgnoreCase.
{
var key = actionContext.ActionArguments.Keys.FirstOrDefault(x => x.EqualsIgnoreCase("GetLoanId"));
if (key != null)
{
return long.Parse(actionContext.ActionArguments[key].ToString());
}
var type = actionContext.ActionArguments[actionContext.ActionArguments.Keys.First()].GetType();
var property = type.GetProperties().FirstOrDefault(x => x.Name.EqualsIgnoreCase("GetLoanId"));
if (property != null)
{
return long.Parse(type.GetProperty(property.Name).GetValue(actionContext.ActionArguments[actionContext.ActionArguments.Keys.First()], null).ToString());
}
return 0;
}
In the OnActionExecuting method if all the checks pass then we need to run the calculator to determine if the action can proceed. That is injected using Dependency Injection.
if (loanId > 0)
{
var calculator = (ILoanStatusCalculator) actionContext.ControllerContext.Configuration.DependencyResolver.GetService(typeof(ILoanStatusCalculator));
var results = calculator.CalculateStatus(loanId);
SetFailureResponse(actionContext, results);
}
The class itself is pretty simple. It queries the database and runs through a series of checks to determine the status of the loan.
public class LoanStatusCalculator : ILoanStatusCalculator
{
private readonly IdbEntities _dbEntities;
private readonly ISecurityContext _securityContext;
public LoanStatusCalculator(IAglDbEntities dbEntities, ISecurityContext securityContext)
{
_dbEntities = dbEntities;
_securityContext = securityContext;
}
public CompletedFilterDTO CalculateStatus(long loanId)
{
var results = _dbEntities.GetLoanId(loanId);
return GetCompletedFilterDTO(results);
}
private CompletedFilterDTO GetCompletedFilterDTO(LoanStatusDTO results)
{
if (results == null)
{
return new CompletedFilterDTO {CanProceed = false, ErrorMessage = string.Format("{0} The loan does not exist.", GetSorryMessage())};
}
if (results.CurrentStatusEnum == CurrentStatus.Completed)
{
return new CompletedFilterDTO {CanProceed = false, ErrorMessage = string.Format("{0} The loan is completed.", GetSorryMessage())};
}
if (results.CurrentStatusEnum == CurrentStatus.ReadOnly)
{
return new CompletedFilterDTO {CanProceed = false, ErrorMessage = string.Format("{0} You are in read-only mode and asked me to write to the database. I'm afraid that's something I cannot allow to happen.", GetSorryMessage())};
}
return new CompletedFilterDTO {CanProceed = true};
}
private string GetSorryMessage()
{
return string.Format("I'm sorry {0}, I'm afraid I can't do that.", _securityContext.FirstName);
}
}
Finally, we can set the error response if the calculator returns a CanProceed = false.
{
if (results.CanProceed == false)
{
LogWarningMessage(actionContext, results.ErrorMessage);
actionContext.Response = actionContext.Request.CreateErrorResponse(HttpStatusCode.BadRequest, results.ErrorMessage);
}
}
private void LogWarningMessage(HttpActionContext actionContext, string errorMessage)
{
// Use your own logging here
}
In the UI we show error messages such as this to the user. When I wrote this Action Filter I made sure to make the message extra creepy. Because in the end that is what development is all about, creeping out our users.
Finally, wire it up in the global.asax file so all controllers can use it.
using MySite.LoanApplication.WebAPI.Filters;
using Newtonsoft.Json;
namespace MySite.LoanApplication.WebAPI
{
public class WebApiApplication : HttpApplication
{
protected void Application_Start()
{
GlobalConfiguration.Configuration.Filters.Add(new ExceptionHandleFilterAttribute());
GlobalConfiguration.Configuration.Filters.Add(new CompletedFilterAttribute());
GlobalConfiguration.Configure(WebApiConfig.Register);
GlobalConfiguration.Configuration.Formatters.JsonFormatter.SerializerSettings = new JsonSerializerSettings();
}
}
}
I should point out that this example is much simpler than the one that went into the actual code base. This was just to give you started on what you can do with this powerful tool.
Downsides of Action Filters
It is not all sunshine and lollypops with action filters. There are a number of considerations you need to take into account.
- This is a global change (once you put it in the Global.asax file). Have a solid regressive testing suite. If you add this to a large project already in flight you should have a large regressive suite to run so you don't introduce strange errors. Or, if you are lucky, you implement something like this early in the project life cycle.
- Write lots of unit tests for the action filter. The more complex the more tests. This is global, you don't want to mess it up.
- Be careful with the OnActionExecutingAsync method. Don't implement both the OnActionExecuting and OnActionExecutingAsync.
- If you run a database query then it needs to be as fast as possible. I wrote a stored proc and tweaked the query based on what the execution plan told me. I got it down to 2 reads and 1 millisecond from what SQL Profiler told me. It will run for every update/delete/insert. It can be a performance bottleneck fairly fast.
- This isn't a 100% solution, there could still be instances that slip through the cracks. Code reviews and auditing are a must if you are using this for security.
Conclusion
So there you have it, an (much simplified) example of how we made our loan application a little more secure. This may work for you or it may not. The idea is you can really write your own business rules to help enforce Micro level security. My real hope is this post got some ideas percolating on how to make your sites and APIs more secure.