One of the ways MVC allows us to fine-tune what an action does is via the use of Action Filter attributes. Action Filters are attributes which inherit from the ActionFilterAttribute class, and can execute either before or after a decorated action (or before/after every action in a controller) and modify how the action is handled. In this post we'll explore creating a custom Action Filter by inheriting from ActionFilterAttribute.

What is an ActionFilter?

In ASP.NET, an "Action Filter" in the loose sense applies to any action attribute. These attributes can implement one or more of the following interfaces:

Note that if a given class implements more than one of those interfaces, the corresponding methods from the interfaces will be executed in the order listed above.

In order to make creating these attributes easier, .NET exposes the ActionFilterAttribute class which implements IActionFilter and IResultFilter. This class exposes four methods that you can override:

  • OnActionExecuting: Fires before the action is executed.
  • OnActionExecuted: Fires after the action is executed.
  • OnResultExecuting: Fires before the action result is executed.
  • OnResultExecuted: Fires after the action result is executed.

Each of these methods have different use cases. For example, if you wanted to modify the result of the action based on some additional data, the best place to do that would be OnResultExecuting.

You've actually already seen ActionFilter attributes in action if you've ever seen HandleErrorAttribute or AuthorizeAttribute, as both of those classes derive from the base ActionFilterAttribute class.

Let's build a simple, custom Action Filter to show these ideas in action.

Setting Up the Demo App

We'll be using a database schema that looks like this:

Each user can create one or more Reports in this schema. What we are going to build is an Action Filter Attribute that restricts access to these reports to only the User that created them. In order to show how this works more transparently, we are also going to allow the current user of the app to "impersonate" the database-stored users by placing a User ID into Session, then using that ID to see whether or not that user can access a given Report.

Confused? It's not too bad. Let's get started!

Selecting the Current User

Let's start with the Home/Index view model, controller actions, and view; these allow us to select the current user and store it in Session.

ViewModels/Home/HomeIndexVM.cs
public class HomeIndexVM  
{
    public List<User> Users { get; set; }

    [DisplayName("Select a User:")]
    public int SelectedUser { get; set; }
}
Controllers/HomeController.cs
public class HomeController : Controller  
{
    [HttpGet]
    public ActionResult Index()
    {
        HomeIndexVM model = new HomeIndexVM();
        model.Users = UserManager.GetAll();
        return View(model);
    }

    [HttpPost]
    public ActionResult Index(HomeIndexVM model)
    {
        Session["CurrentUserID"] = model.SelectedUser;
        TempData["FlashMessage"] = "You are now " + UserManager.GetByID(model.SelectedUser).Name + ".";
        return RedirectToAction("Index");
    }
}
Views/Home/Index.cshtml (Simplified)
@model ActionFilterDemo.ViewModels.Home.HomeIndexVM

<h2>ActionFilter Demo</h2>

@if (TempData["FlashMessage"] != null)
{
    <span><strong>FLASH:</strong>@TempData["FlashMessage"].ToString()</span>
}

@using(Html.BeginForm())
{
    <div>
        <div>
            @Html.LabelFor(x => x.SelectedUser)
            @Html.DropDownListFor(x=>x.SelectedUser, new SelectList(Model.Users, "UserID", "Name"))
        </div>
        <div>
            <input type="submit" value="Go" />
        </div>
    </div>
}

Listing and Viewing the Reports

The next part of the app is pretty simple: listing and viewing the reports.

ViewModels/Reports/ReportsIndexVM.cs
public class ReportsIndexVM  
{
    public List<Report> Reports { get; set; }
}
Controllers/ReportsController.cs
public class ReportsController : Controller  
{
    [HttpGet]
    public ActionResult Index()
    {
        ReportsIndexVM model = new ReportsIndexVM();
        model.Reports = ReportsManager.GetAll();
        return View(model);
    }

    [HttpGet]
    public ActionResult View(int id)
    {
        var report = ReportsManager.GetByID(id);
        return View(report);
    }
}
Views/Reports/Index.cshtml
@model ActionFilterDemo.ViewModels.Reports.ReportsIndexVM

@{
    ViewBag.Title = "Reports";
}

<h2>Reports</h2>

<table>  
    <thead>
        <tr>
            <th>
                Report Name
            </th>
            <th>
                Created by
            </th>
        </tr>
    </thead>
    <tbody>
        @foreach(var report in Model.Reports)
        {
            <tr>
                <td>
                    @Html.ActionLink(report.Title, "View", new { id = report.ReportID })
                </td>
                <td>
                    @report.User.Name
                </td>
            </tr>
        }
    </tbody>
</table>  
Views/Reports/View.cshtml
@model ActionFilterDemo.DataAccess.Model.Report

@{
    ViewBag.Title = "View";
}

<h3>Report: @Model.Title</h3>  
<span>Created By: @Model.User.Name</span>  

Now we're ready to start building the Action Filter.

Building the Action Filter

We want our filter to check the Session["CurrentUserID"] value against the Report ID, and see if the current user created the specified report. If s/he did, we proceed as normal, and if not, we redirect back to Home/Index.

First, we need to create a class that inherits from ActionFilterAttribute:

Attributes/CanEditReport.cs
public class CanEditReport : ActionFilterAttribute  
{
}

What we want to do in this scenario is evaluate the CurrentUserID BEFORE the action executes, so we override OnActionExecuting:

public class CanEditReport : ActionFilterAttribute  
{
    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
    }
}

Notice the ActionExecutingContext parameter. That parameter is extremely important as it contains the information (such as route values, query string parameters, and controller data) that we need to access.

Now, for the first step, let's get the report ID from the route parameter "id":

public class CanEditReport : ActionFilterAttribute  
{
    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        var reportID = Convert.ToInt32(filterContext.ActionParameters["id"]);
        var report = ReportsManager.GetByID(reportID);
    }
}

The ActionParameters collection contains all of the parameters being passed to the action that this attribute decorates, so it occurs after the model binding has completed.

Next, let's check the current user in Session:

public class CanEditReport : ActionFilterAttribute  
{
    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        var reportID = Convert.ToInt32(filterContext.ActionParameters["id"]);
        var report = ReportsManager.GetByID(reportID);
        int userID = 0;
        bool hasID = int.TryParse(filterContext.HttpContext.Session["CurrentUserID"].ToString(), out userID);
        if (!hasID)
        {
            //What goes here?
        }
    }
}

Notice that we can access the Session through the HttpContext property of ActionExecutingContext.

Now the question becomes this: what goes inside that if clause? If they don't have a valid ID, we want to redirect back to Home/Index with a flash message, but we cannot call RedirectToAction from an Action Filter. Instead, we set the Result of the ActionExecutingContext to a new RedirectToRouteResult, like so:

public class CanEditReport : ActionFilterAttribute  
{
    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        var reportID = Convert.ToInt32(filterContext.ActionParameters["id"]);
        var report = ReportsManager.GetByID(reportID);
        int userID = 0;
        bool hasID = int.TryParse(filterContext.HttpContext.Session["CurrentUserID"].ToString(), out userID);
        if (!hasID)
        {
            filterContext.Controller.TempData["FlashMessage"] = "Please select a valid User to access their reports.";
                //Change the Result to point back to Home/Index
            filterContext.Result = new RedirectToRouteResult(new RouteValueDictionary(new { controller = "Home", action = "Index" }));
        }
    }
}

There's still one more scenario we need to handle: what if the user that's trying to access this report isn't the person that created it? In this scenario, just like the invalid-user-ID one, we want to redirect to Home/Index with a flash message.

Our final CanEditReport class looks like this:

public class CanEditReport : ActionFilterAttribute  
{
    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        var reportID = Convert.ToInt32(filterContext.ActionParameters["id"]);
        var report = ReportsManager.GetByID(reportID);
        int userID = 0;
        bool hasID = int.TryParse(filterContext.HttpContext.Session["CurrentUserID"].ToString(), out userID);
        if (!hasID)
        {
            filterContext.Controller.TempData["FlashMessage"] = "Please select a valid User to access their reports.";
            //Change the Result to point back to Home/Index
            filterContext.Result = new RedirectToRouteResult(new RouteValueDictionary(new { controller = "Home", action = "Index" }));
        }
        else //We have selected a valid user
        {
            if(report.UserID != userID)
            {
                filterContext.Controller.TempData["FlashMessage"] = "You cannot view Reports you have not created.";
                //Change the Result to point back to Home/Index
                filterContext.Result = new RedirectToRouteResult(new RouteValueDictionary(new { controller = "Home", action = "Index" }));
            }
        }
        base.OnActionExecuting(filterContext);
    }
}

All we have to do now is decorate the appropriate action in the controller:

Controllers/ReportsController.cs
public class ReportsController : Controller  
{
    [HttpGet]
    public ActionResult Index()
    {
        ReportsIndexVM model = new ReportsIndexVM();
        model.Reports = ReportsManager.GetAll();
        return View(model);
    }

    [HttpGet]
    [CanEditReport]
    public ActionResult View(int id)
    {
        var report = ReportsManager.GetByID(id);
        return View(report);
    }
}

Now, when we run the app, the system will look to see which user we currently are impersonating, and if we try to access a report that that user didn't create, we will get kicked back out to the Home/Index view.

As always, you can check out the sample project on GitHub, and please let me know what you think of this demo in the comments!

Happy Coding!