Restricting Access to an Action Method in ASP.NET Core MVC
Introduction
This post talks about a few options for restricting access to a given action method - or to all of them - on an ASP.NET Core MVC controller. It won’t be a general article about security in ASP.NET Core, just this aspect of it.
Prerequisites
We must always add authentication (who we are) and authorisation (what can we do) support to the ASP.NET Core pipeline. Authorisation requires authentication, but authentication can exist on its own, as long as some authentication scheme is provided:
builder.Services.AddAuthentication()
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme);
builder.Services.AddAuthorization(); //can take options as we will see later on
Using Filters
ASP.NET Core filters are well known and popular, they are probably the easiest way to restrict access to an endpoint. Filters can be applied:
- Through an attribute, to an action method or a controller
- Globally for all controllers and actions
Custom Filters
IAuthorizationFilter or IAsyncAuthorizationFilter are interfaces that define authorization rules for a given endpoint; the latter is the asynchronous version of the former, but they are equivalent. They can be implemented by an attribute, which can then be applied to an action method or controller class, or they can be applied globally for all of the requests; this would be for custom access control.
One example that restricts access to a given day of the week:
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public class DayOfWeekFilterAttribute(params DayOfWeek [] daysOfWeek) : Attribute, IAsyncAuthorizationFilter
{public Task OnAuthorizationAsync(AuthorizationFilterContext context)
{if (!(daysOfWeek ?? []).Contains(DateTime.Today.DayOfWeek))
{context.Result = new ForbidResult();
}
}
}
The idea is: if we want to return something that denies access, we can do so from the Result property of the AuthorizationFilterContext. Possible candidate types include:
- ForbidResult: for returning a 401 Unauthorized HTTP status code
- RedirectResult, RedirectToActionResult, RedirectToPageResult, RedirectToRouteResult, LocalRedirectResult: different kinds of redirects (3xx status codes)
- EmptyResult: for returning nothing with 200 OK
- StatusCodeResult: for returning a custom status code
We apply it as:
[DaysOfWeek(DayOfWeek.Saturday, DayOfWeek.Sunday)]
public IActionResult Index() { ... }Authorize Attribute
There is also the built-in [Authorize] attribute that can be applied to controller classes or action methods, but does not implement any of these interfaces. It allows for couple restrictions:
- Policy: restrict by a named policy, which has to be defined
- Roles: one or more roles, separated by commas. If provided, the current user will need to have one of the given roles; roles come from request claims
Now, it is possible to have multiple [Authorize] attributes, and the differences are:
- If one [Authorize] has multiple Roles specified, the user needs to have one of them
- If there are multiple [Authorize] with roles, all of the [Authorize] attributes must be checked, for example: if one requires role "A", and the other requires role "B", then the current user must have both roles, "A" and "B"
- Same for policies: if two [Authorize] attributes are present, both with a Policy defined, say, "X" and "Z", then the current user must match both policies
The [AllowAnonymous] attribute, if present, will bypass any [Authorize] attribute. Here are a couple examples:
[Authorize(Roles = "Admin")]
public class AdminController : Controller //all actions require the "Admin" role
{[AllowAnonymous]
public IActionResult SignOut() { ... } //can be called by everyone public IActionResult Index() { ... } //requires the "Admin" role (inherited from controller)}
public class HomeController : Controller
{[Authorize]
public IActionResult Private() { ... } //can only be called by authenticated users, regardless of their roles[Authorize(Roles = "Restricted")]
[Authorize(Roles = "Manager,Admin")]
public IActionResult Restricted() { ... } //requires the role "Restricted" AND one of "Manager" or "Admin"}
Now, if we want to use named policies, we need to define them:
builder.Services.AddAuthorization(static options =>
{ options.AddPolicy("AdminPolicy", policy => { policy.RequireRole("Admin");policy.RequireAuthenticatedUser();
});
});
And now we can use the "AdminPolicy" policy on an [Authorize] attribute:
[Authorize(Policy = "AdminPolicy")]
public IActionResult Restricted() { ... } //requires whatever is specified on the "AdminPolicy"Roles and Policies
Roles and policies are two different ways to control access. Roles map directly to authentication claims or to user groups, depending on the kind of authentication in use. Policies on the other hand allow customisable requirements, which can include roles, but much more than that.
Inside a policy definition we can:
- Require a user to be authenticated: RequireAuthenticatedUser()
- Require a specific claim (a role is one example): RequireClaim()
- Require one of a list of roles: RequireRole()
- Require a specific username: RequireUserName()
- Require a custom condition based on the current user: RequireAssertion()
- Combine multiple policies together: Combine()
- Add requirements (more on this later on): AddRequirements()
Policy names must be unique and can be used in different places that deal with authorisation.
One example that only allows access on weekends:
builder.Services.AddAuthorization(static options =>{
options.AddPolicy("WeekendPolicy", policy => {policy.RequireAssertion(ctx =>
{return DateTime.Today.DayOfWeek == DayOfWeek.Saturday ||
DateTime.Today.DayOfWeek == DayOfWeek.Sunday;
});
});
});
It is also possible to have more structured and reusable access control, and this is what we are going to see next.
Using Authorization Handlers
An authorization handler is an implementation of IAuthorizationHandler, implemented by abstract class AuthorizationHandler<TRequirement>, which takes a requirement as a parameter. Inside of its HandleRequirementAsync we can implement any kind of logic we want to, with the requirement passed as a parameter:
public record DayOfWeekRequirement(DayOfWeek DayOfWeek) : IAuthorizationRequirement
{}
The IAuthorizationRequirement is just a marker interface which does not define any method, we can add properties to our particular implementation if we want to pass parameters to the handler. We wire it to an AuthorizationHandler<TRequirement> class:
public class DayOfWeekAuthenticationHandler : AuthorizationHandler<DayOfWeekRequirement>
{ protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, DayOfWeekRequirement requirement)
{if (DateTime.Today.DayOfWeek == requirement.DayOfWeek)
{
context.Succeed(requirement);}
else
{context.Fail(new AuthorizationFailureReason(this, "Wrong day of week"));
}
return Task.CompletedTask;}
}
Inside the HandleRequirementAsync we either fail (Fail) or succeed the request (Succeed) the request. If many handlers are registered, all must succeed for permission to access the endpoint to be granted. The same instance of AuthorizationHandlerContext parameter is called for all handlers, and from there one can inspect the current authenticated user (User) as well as the context (Resource), current status of the request (HasFailed, HasSucceeded), processed (Requirements) and pending requirements (PendingRequirements), and failure reasons (FailureReasons).
As you can see, this is a simple example that just takes a day of the week being passed as a parameter, but there are infinite options:
- Restrict by client IP address
- Restrict by some cookie that is present/not present on the request
- Restrict by some request header
- ...
We must add our requirements - as many as we want - to a named policy:
builder.Services.AddAuthorization(static options =>{
options.AddPolicy("DayOfWeekPolicy", policy => {policy.Requirements.Add(new DayOfWeekRequirement(DayOfWeek.Saturday)));
policy.Requirements.Add(new DayOfWeekRequirement(DayOfWeek.Sunday)));
});
});
ASP.NET Core locates the appropriate handler from Dependency Injection (DI) by inspecting the passed requirement. Don't forget that we must register the handlers with DI:
builder.Services.AddSingleton<IAuthorizationHandler, DayOfWeekAuthenticationHandler>();
With this, we can now add an [Authorize] attribute that refers to the new policy:
[Authorize(Policy = "DayOfWeekPolicy")]
public IActionResult Index() { ... }Comparing the Alternatives
With [Authorize] we can only use policies or direct roles. Specifying a policy allows far more flexibility, because we can specify exactly what you want in a decoupled way, which you can change whenever you want to. Still on this, having authorization handlers is a more resilient and reusable solution, which allows us to encapsulate multiple conditions with parameters, with the added benefit of being able to use DI.
Conclusion
And it's all for now. As always, hope you find this useful!
Comments
Post a Comment