Quantcast
Channel: Andrew Lock | .NET Escapades
Viewing all articles
Browse latest Browse all 743

Using an IActionFilter to read action method parameter values in ASP.NET Core MVC

$
0
0

In this post I shown how you can use an IActionFilter in ASP.NET Core MVC to read the method parameters for an action method before it executes. I'll show two different approaches to solve the problem, depending on your requirements.

In the first approach, you know that the parameter you're interested in (a string parameter called returnUrl for this post) is always passed as a top level argument to the action, e.g.

public class AccountController
{
    public IActionResult Login(string returnUrl)
    {
        return View();
    }
}

In the second approach, you know that the returnUrl parameter will be in the request, but you don't know that it will be passed as a top-level parameter to a method. For example:

public class AccountController
{
    public IActionResult Login(string returnUrl)
    {
        return View();
    }

    public IActionResult Login(LoginInputModel model)
    {
        var returnUrl = model.returnUrl
        return View();
    }
}

The action filters I describe in this post can be used for lots of different scenarios. To give a concrete example, I'll describe the original use case that made me investigate the options. If you're just interested in the implementation, feel free to jump ahead.

Background: why would you want to do this?

I was recently working on an IdentityServer 4 application, in which we wanted to display a slightly different view depending on which tenant a user was logging in to. OpenID Connect allows you to pass additional information as part of an authentication request as acr_values in the querystring. One of the common acr_values is tenant - it's so common that IdentityServer provides specific methods for pulling the tenant from the request URL.

When an unauthenticated user attempts to use a client application that relies on IdentityServer for authentication, the client app calls the Authorize endpoint, which is part of the IdentityServer middleware. As the user is not yet authenticated, they are redirected to the login page for the application, with the returnUrl parameter pointing back to the middleware authorize endpoint:

Login workflow for OpenID Connect

After the user has logged in, they'll be redirected to the IdentityServer Authorize endpoint, which will return an access/id token back to the original client.

In my scenario, I needed to determine the tenant that the original client provided in the request to the Authorize endpoint. That information is available in the returnUrl parameter passed to the login page. You can use the IdentityServer Interaction Service (IIdentityServerInteractionService) to decode the returnUrl parameter and extract the tenant with code similar to the following:

public class AccountController
{
    private readonly IIdentityServerInteractionService _service;
    public AccountController(IIdentityServerInteractionService  service)
    {
        _service = service;
    }

    public IActionResult Login(string returnUrl)
    {
        var context = await _service.GetAuthorizationContextAsync(returnUrl);
        ViewData["Tenant"] = context?.Tenant;
        return View();
    }
}

You could then use the ViewData in a Razor view to customise the display. For example, in the following _Layout.cshtml, the Tenant name is added to the page as a class on the <body> tag.

@{
    var tenant = ViewData["Tenant"] as string;
    var tenantClass = "tenant-" + (string.IsNullOrEmpty(tenant) ? "unknown" : tenant);
}
<!DOCTYPE html>
<html>
  <head></head>
  <body class="@tenantClass">
    @RenderBody
  </body>
</html>

This works fine, but unfortunately it means you need to duplicate the code to extract the tenant in every action method that has a returnUrl - for example the GET and POST version of the login methods, all the 2FA action methods, the external login methods etc.

var context = await _service.GetAuthorizationContextAsync(returnUrl);
ViewData["Tenant"] = context?.Tenant;

Whenever you have a lot of duplication in your action methods, it's worth thinking whether you can extract that work into a filter (or alternatively, push it down into a command handler using a mediator).

Now we have the background, lets look at creating an IActionFilter to handle this for us.

Creating an IActionFilter that reads action method parameters

One of the good things about using an IActionFilter (as opposed to some other MVC Filter/) is that it executes after model binding, but before the action method has been executed. That gives you a ton of context to work with.

The IActionFilter below reads an action method's parameters, looks for one called returnUrl and sets it as an item in ViewData. There's a bunch of assumptions in this code, so I'll walk through it below.

public class SetViewDataFilter : IActionFilter
{
    public void OnActionExecuting(ActionExecutingContext context)
    {
        if (context.ActionArguments.TryGetValue("returnUrl", out object value))
        {
            // NOTE: this assumes all your controllers derive from Controller.
            // If they don't, you'll need to set the value in OnActionExecuted instead
            // or use an IAsyncActionFilter
            if (context.Controller is Controller controller)
            {
                controller.ViewData["ReturnUrl"] = value.ToString();
            }
        }
    }

    public void OnActionExecuted(ActionExecutedContext context) { }
}

The ActionExecutingContext object contains details about the action method that's about to be executed, model binding details, the ModelState - just about anything you could want! In this filter, I'm calling ActionArguments and looking for a parameter named returnUrl. This is a case-insensitive lookup, so any method parameters called returnUrl, returnURL, or RETURNURL would all be a match. If the action method has a match, we extract the value (as an object) into the value variable.

Note that we are getting the value after it's been model bound to the action method's parameter. We didn't need to inspect the querystring, form data, or route values; however the MVC middleware managed it, we get the value.

We've extracted the value of the returnUrl parameter, but now we need to store it somewhere. ASP.NET Core doesn't have any base-class requirements for your MVC controllers, so unfortunately you can't easily get a reference to the ViewData collection. Having said that, if all your controllers derive from the Controller base class, then you could cast to the type and access ViewData as I have in this simple example. This may work for you, it depends on the conventions you follow, but if not, I show an alternative later.

You can register your action filter as a global filter when you call AddMvc in Startup.ConfigureServices. Be sure to also register the filter as a service with the DI container

public void ConfigureServices(IServiceCollection services)
{
    services.AddTransient<SetViewDataFilter>();
    services.AddMvc(options =>
    {
        options.Filters.AddService<SetViewDataFilter>();
    });
}

In this example, I chose to not make the filter an attribute. If you want to use SetViewDataFilter to decorate specific action methods, you should derive from ActionFilterAttribute instead.

In this example, SetViewDataFilter implements the synchronous version of IActionFilter, so unfortunately it's not possible to use IdentityServer's interaction service to obtain the Tenant from the returnUrl (as it requires an async call). We can get round that by implementing IAsyncActionFilter instead.

Converting to an asynchronous filter with IAsyncActionFilter

If you need to make async calls in your action filters, you'll need to implement the asynchronous interface, IAsyncActionFilter. Conceptually, this combines the two action filter methods (OnActionExecuting() and OnActionExecuted()) into a single OnActionExecutionAsync().

When your filter executes, you're provided the ActionExecutingContext as before, but also an ActionExecutionDelegate delegate, which represents the rest of the MVC filter pipeline. This lets you control exactly when the rest of the pipeline executes, as well as allowing you to make async calls.

Lets rewrite the action filter, and extend it to actually lookup the tenant with IdentityServer:

public class SetViewDataFilter : IAsyncActionFilter
{
    readonly IIdentityServerInteractionService _service;
    public SetViewDataFilter(IIdentityServerInteractionService service)
    {
        _service = service;
    }

    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        var tenant = await GetTenant(context);

        // Execute the rest of the MVC filter pipeline
        var resultContext = await next();

        if (resultContext.Result is ViewResult view)
        {
            view.ViewData["Tenant"] = tenant;
        }
    }

    async Task<string> GetTenant(ActionExecutingContext context)
    {
        if (context.ActionArguments.TryGetValue("returnURl", out object value)
            && value is string returnUrl)
        {
            var authContext = await _service.GetAuthorizationContextAsync(returnUrl);
            return authContext?.Tenant;
        }

        // no string parameter called returnUrl
        return null;
    }
}

I've moved the code to extract the returnUrl parameter from the action context into it's own method, in which we also use the IIdentityServerInteractionService to check the returnUrl is valid, and to fetch the provided tenant (if any).

I've also used a slightly different construct to pass the value in the ViewData. Instead of putting requirements on the base class of the controller, I'm checking that the result of the action method was a ViewResult, and setting the ViewData that way. This seems like a better option - if we're not returning a ViewResult then ViewData is a bit pointless anyway!

This action filter is very close to what I used to meet my requirements, but it makes one glaring assumption: that action methods always have a string parameter called returnUrl. Unfortunately, that may not be the case, for example:

public class AccountController
{
    public IActionResult Login(LoginInputModel model)
    {
        var returnUrl = model.ReturnUrl
        return View();
    }
}

Even though the LoginInputModel has a ReturnUrl parameter that would happily bind to a returnUrl parameter in the querystring, our action filter will fail to retrieve it. That's because we're looking specifically at the action arguments for a parameter called returnUrl, but we only have model. We're going to need a different approach to satisfy both action methods.

Using the ModelState to build an action filter

It took me a little while to think of a solution to this issue. I toyed with the idea of introducing an interface IReturnUrl, and ensuring all the binding models implemented it, but that felt very messy to me, and didn't feel like it should be necessary. Alternatively, I could have looked for a parameter called model and used reflection to check for a ReturnUrl property. That didn't feel right either.

I knew the model binder would treat string returnUrl and LoginInputModel.ReturnUrl the same way: they would both be bound correctly if I passed a querystring parameter of ?returnUrl=/the/value. I just needed a way of hooking into the model binding directly, instead working with the final method parameters.

The answer was to use context.ModelState. ModelState contains a list of all the values that MVC attempted to bind to the request. You typically use it at the top of an MVC action to check that model binding and validation was successful using ModelState.IsValid, but it's also perfect for my use case.

Based on the async version of our attribute you saw previously, I can update the GetTenant method to retrieve values from the ModelState instead of the action arguments:

async Task<string> GetTenantFromAuthContext(ActionExecutingContext context)
{
    if (context.ModelState.TryGetValue("returnUrl", out var modelState)
        && modelState.RawValue is string returnUrl
        && !string.IsNullOrEmpty(returnUrl))
    {
        var authContext = await _interaction.GetAuthorizationContextAsync(returnUrl);
        return authContext?.Tenant;
    }

    // reutrnUrl wasn't in the request
    return null;
}

And that's it! With this quick change, I can retrieve the tenant both for action methods that have a string returnUrl parameter, and those that have a model with a ReturnUrl property.

Summary

In this post I showed how you can create an action filter to read the values of an action method before it executes. I then showed how to create an asynchronous version of an action filter using IAsyncActionFilter, and how to access the ViewData after an action method has executed. Finally, I showed how you can use the ModelState collection to access all model-bound values, instead of only the top-level parameters passed to the action method.


Viewing all articles
Browse latest Browse all 743

Trending Articles