One of the ways ASP.NET 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 provided by .NET, 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 custom Action Filter to show these ideas in action.

Setting Up the Demo App

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

Each user can create one or more Reports in this schema. What we are going to build is a class that inherits from ActionFilterAttribute which will restrict access to reports so that only the User that created t hem can access 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.

Let's get started!

Selecting the Current User

Let's start building our sample app by creating a HomeIndexVM model which holds a list of User objects and keeps track of the ID of the selected user.

public class HomeIndexVM
{
    public List<User> Users { get; set; }

    [DisplayName("Select a User:")]
    public int SelectedUser { get; set; }
}

Now we can implement a HomeController which uses our HomeIndexVM model:

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");
    }
}

Finally, let's create an Index.cshtml view for this controller action and model:

@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

In the next part of the app, we want to list and view the available reports. To do this, let's start with a new C# model class called ReportsIndexVM:

public class ReportsIndexVM
{
    public List<Report> Reports { get; set; }
}

We also need a corresponding ReportsController with two actions, Index() and View():

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);
    }
}

We need a view for the Reports Index which will display each report in the Model.

@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>

We also need a view for displaying the details of each individual report.

@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 Attribute Class

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. We are calling this class CanEditReport:

public class CanEditReport : ActionFilterAttribute { }

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

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 on the instance of ActionExecutingContext 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 the Session object:

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 the ActionExecutingContext instance.

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 property 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 attribute 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 ReportsController:

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!

exceptionnotfound/actionfilterdemo
Contribute to exceptionnotfound/actionfilterdemo development by creating an account on GitHub.

Happy Coding!