This is the next in series of posts about authentication and authorisation in ASP.NET Core. In the first post we introduced authentication in ASP.NET Core at a high level, introducing the concept of claims-based authentication. In the next two post, we looked in greater depth at the Cookie and JWT middleware implementations to get a deeper understanding of the authentication process. Finally, we looked at using OAuth 2.0 and OpenID Connect in your ASP.NET Core applications.
In this post we'll learn about the authorisation aspect of ASP.NET Core.
Introduction to Authorisation
Just to recap, authorisation is the process of determining if a given user has the necessary attributes/permissions to access a given resource/section of code. In ASP.NET Core, the user is specified by a ClaimsPrincipal
object, which may have one or more associated ClaimsIdentity
, which in turn may have any number of Claims
. The process of creating the ClaimsPrincipal
and assigning it the correct Claims
is the process of authentication. Authentication is independent and distinct from authorisation, but must occur before authorisation can take place.
In ASP.NET Core, authorisation can be granted based on a number of different factors. These may be based on the roles of the current user (as was common in previous version of .NET), the claims of the current user, the properties of the resource being accessed, or any other property you to care to think of. In this post we'll cover some of the most common approaches to authorising users in your MVC application.
Authorisation in MVC
Authorisation in MVC all centres around the AuthorizeAttribute
. In it's simplest form, applying it to an Action (or controller, or globally) marks that action as requiring an authenticated user. Thinking in terms of ClaimsPrincipal
and ClaimsIdentity
, that means that the current principal must contain a ClaimsIdentity
for which IsAuthenticated=true
.
This is the coarsest level of granularity - either you are authenticated, and you have access to the resource, or you aren't, and you do not.
You can use the AllowAnonymousAttribute
to ignore an AuthorizeAttribute
, so in the following example, only authorised users can call the Manage
method, while anyone can call the Logout
method:
[Authorize]
public class AccountController: Controller
{
public IActionResult Manage()
{
return View();
}
[AllowAnonymous]
public IActionResult Logout()
{
return View();
}
}
Under the hood
Before we go any further I'd like to take a minute to dig into what is actually happening under the covers here.
The AuthorizeAttribute
applied to your actions and controllers is mostly just a marker attribute, it does not contain any behaviour. Instead, it is the AuthorizeFilter
which MVC adds to its filter pipeline when it spots the AuthorizeAttribute
applied to an action. This filter implements IAsyncAuthorizationFilter
, so that it is called early in the MVC pipeline to verify the request is authorised:
public interface IAsyncAuthorizationFilter : IFilterMetadata
{
Task OnAuthorizationAsync(AuthorizationFilterContext context);
}
AuthorizeFilter.OnAuthorizationAsync
is called to authorise the request, which undertakes a number of actions. The method is reproduced below with some precondition checks removed for brevity - we'll dissect it in a minute:
public virtual async Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
var effectivePolicy = Policy;
if (effectivePolicy == null)
{
effectivePolicy = await AuthorizationPolicy.CombineAsync(PolicyProvider, AuthorizeData);
}
if (effectivePolicy == null)
{
return;
}
// Build a ClaimsPrincipal with the Policy's required authentication types
if (effectivePolicy.AuthenticationSchemes != null && effectivePolicy.AuthenticationSchemes.Count > 0)
{
ClaimsPrincipal newPrincipal = null;
for (var i = 0; i < effectivePolicy.AuthenticationSchemes.Count; i++)
{
var scheme = effectivePolicy.AuthenticationSchemes[i];
var result = await context.HttpContext.Authentication.AuthenticateAsync(scheme);
if (result != null)
{
newPrincipal = SecurityHelper.MergeUserPrincipal(newPrincipal, result);
}
}
// If all schemes failed authentication, provide a default identity anyways
if (newPrincipal == null)
{
newPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
}
context.HttpContext.User = newPrincipal;
}
// Allow Anonymous skips all authorization
if (context.Filters.Any(item => item is IAllowAnonymousFilter))
{
return;
}
var httpContext = context.HttpContext;
var authService = httpContext.RequestServices.GetRequiredService<IAuthorizationService>();
// Note: Default Anonymous User is new ClaimsPrincipal(new ClaimsIdentity())
if (!await authService.AuthorizeAsync(httpContext.User, context, effectivePolicy))
{
context.Result = new ChallengeResult(effectivePolicy.AuthenticationSchemes.ToArray());
}
}
First, it calculates the applicable AuthorizationPolicy
for the request. This sets the requirements that must be met for the request to be authorised. The next step is to attempt to authenticate the request by calling AuthenticateAsync(scheme)
on the AuthenticationManager
found at HttpContext.Authentication
. This will run through the authentication process I have discussed in previous posts, and if successful, returns an authenticated ClaimsPrincipal
back to the filter.
Once an authenticated principal has been obtained, the authorisation process can begin. First, the method is checked to see if it has an IAllowAnonymousFilter
applied (added when an AllowAnonymousAttribute
is used), and if it does, returns successfully without any further processing.
If authorisation is required, then the filter requests an instance of IAuthorizationService
from the HttpContext
. This service neatly encapsulates all the logic for deciding whether a ClaimsPrincipal
meets the requirements of the particular AuthorizationPolicy
. A call to IAuthorizationService.AuthorizeAsync()
returns a boolean, indicating if the result was successful.
If the IAuthorizationService
indicates the user was not successful, the AuthorizationFilter
returns a ChallengeResult
, bypassing the remainder of the MVC pipeline. When executed, this result calls ChallengeAsync
on the AuthenticationManager
, which in turn calls HandleUnauthorizedAsync
or HandleForbiddenAsync
on the underlying AuthenticationHandler
as covered previously.
The end result will be either a 403 indicating the user does not have permission, or a 401 indicating they are not logged in, which will generally be captured and converted to a redirect to the login page.
The details of how the AuthorizationFilter
works is rather tangential to this introduction in general, but it highlights the separation of concerns and abstractions used to facilitate easier testing, and the use of dumb marker attributes to act has hooks for other more complex services.
Authorising based on claims
Now that detour is over and we understand more of how authorisation works in MVC, we can look at creating some specific authorisation requirements, more than just 'you logged in'.
As I discussed in the introduction to authentication, identity in ASP.NET Core is really entirely focussed around Claims
. Given that fact, one of the most obvious modes of authentication is to check that a user has a given claim. For example, there may be a section of your site which is only available to VIPs. In order to authorise requests you could create a CanAccessVIPArea
policy, or more specifically an AuthorizationPolicy
.
To create a new policy, we configure them as part of the service configuration in the ConfigureServices
method of your Startup
class using an AuthorizationPolicyBuilder
. We provide a name for the policy, "CanAccessVIPArea"
, and add a requirement that the user has the VIPNumber
claim:
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
services.AddAuthorization(options =>
{
options.AddPolicy(
"CanAccessVIPArea",
policyBuilder => policyBuilder.RequireClaim("VIPNumber"));
});
}
This requirement ensures only that the ClaimsPrincipal
has the VIPNumber
claim, it does not make any requirements on the value of the claim. If we required the claim to have specific values, we can pass those to the RequireClaimMethod:
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
services.AddAuthorization(options =>
{
options.AddPolicy(
"CanAccessVIPArea",
policyBuilder => policyBuilder.RequireClaim("VIPNumber", "1", "2"));
});
}
With our policy configured, we can now apply it to our actions or controllers to protect them from the proletariat:
[Authorize(Policy = "CanAccessVIPArea")]
public class ImportantController: Controller
{
public IActionResult FancyMethod()
{
return View();
}
}
Note that if you have multiple AuthorizeAttribute
s applied to an action then all of the policies much be satisfied for the request to be authorised.
Authorising based on roles
Before claims based authentication was embraced, authorisation by role was a common approach. As shown previously, ClaimsPrincipal
still has an IsInRole(string role)
method that you can use if needed. In particular, you can specify required roles on AuthorizeAttribute
s, which will then verify the user is in the correct role before authorising the user:
[Authorize(Roles = "HRManager, CEO")]
public class AccountController: Controller
{
public IActionResult ViewUsers()
{
return View();
}
}
However, other than for simplicity in porting from ASP.NET 4.X, I wouldn't recommend using the Roles
property on the AuthorizeAttribute
. Instead, it is far better to use the same AuthorizationPolicy
infrastructure as for Claim
requirements. This provides far more flexibility than the previous approach, making it simpler to update when policies change, or they need to be dynamically loaded for example.
Configuring a role-based policy is much the same as for Claims
and allows you to specify multiple roles; membership in any of these will satisfy the policy requirement.
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
services.AddAuthorization(options =>
{
options.AddPolicy(
"CanViewAllUsers",
policy => policy. RequireRole("HRManager", "CEO"));
});
}
We can now update the previous method to use our new policy:
[Authorize(Policy = "CanViewAllUsers")]
public class AccountController: Controller
{
public IActionResult ViewUsers()
{
return View();
}
}
Later on, if we decide to take a claims based approach to our authorisation, we can just update the policies as appropriate, rather than having to hunt through all the Controllers in our solution to find usages of the magic role strings.
Behind the scenes, the roles of a ClaimsPrincipal
are actually just claims create with a type of ClaimsIdentity.RoleClaimType
. By default, this is given by ClaimType.Role
, which is the string http://schemas.microsoft.com/ws/2008/06/identity/claims/role
. When a user is authenticated appropriate claims are added for their roles which can be found later as required.
It's worth bearing this in mind if you have difficult with AuthorizeAttributes
not working. Most external identity providers will use a different set of claims representing role, name etc that do not marry up with the values used by Microsoft in the ClaimType
class. As Dominick Baier discusses on his blog, this can lead to situations where claims are not translated and so users can appear to not be in a given role. If you run into issues where your authorisation does not appear to working correctly, I strongly recommend you check out his post for all the details.
Generally speaking, unless you have legacy requirements, I would recommend against using roles - they are essentially just a subset of the Claims
approach, and provide limited additional value.
Summary
This post provided an introduction to authorisation in ASP.NET Core MVC, using the AuthorizeAttribute
. We touched on three simple ways you can authorise users - based on whether they are authenticated, by policy, and by role. We also went under the covers briefly to see how the AuthorisationFilter
works when called as part of the MVC pipeline.
In the next post we will explore policies further, looking at how you can create custom policies and custom requirements.