Logo designed by Pablo Iglesias .
The article describes the patterns and methods of authorization in ASP.NET Core MVC. I emphasize that only authorization is considered (verification of user rights) and not authentication, so the article will not use ASP.NET Identity, authentication protocols, etc. There will be many examples of server-side code, a brief digression into the depth of the Core MVC source, and a test project (link at the end of the article). I invite those interested in cat.
Content:
The principles of authorization and authentication in ASP.NET Core MVC have not changed compared with the previous version of the framework, differing only in details. One of the relatively new concepts is claim-based authorization, with which we will begin our journey. What is a claim? This is a pair of key-value lines, the key can be "FirstName", "EmailAddress", etc. Thus, a claim can be treated as a property of the user, as a string with data, or even as some kind of statement “ the user has something .” The one-dimensional role-based model familiar to many developers is organically contained in a multidimensional claim-based model: the role (the statement “you have the role X ”) is one of the claim and is contained in the list of predefined System.Security.Claims.ClaimTypes . It is not forbidden to create your own claims.
The next important concept is identity. This is a single statement containing a claim. So, identity can be interpreted as a solid document (passport, driver's license, etc.), in this case, the claim is a string in the passport (date of birth, last name ...). Core MVC uses the System.Security.Claims.ClaimsIdentity class.
Another level up is the concept of principal, denoting the user himself. As in real life, a person may have several documents on hand at the same time, and in Core MVC, a principal may contain several identities associated with a user. The well-known HttpContext.User property in Core MVC is of type System.Security.Claims.ClaimsPrincipal . Naturally, through the principal you can get all the claims of each identity. A set of more than one identity can be used to restrict access to different sections of the site / service.
The diagram shows only some of the properties and methods of classes from the System.Security.Claims namespace.
Why is all this necessary? When claim-based authorization, we explicitly indicate that the user needs to have the necessary claim (user property) to access the resource. In the simplest case, the fact of the presence of a specific claim is verified, although much more complex combinations are possible (set with the help of policy, requirements, permissions - we will look at these concepts in more detail below). A real-life example: to control a passenger car, a person must have a driver's license (identity) with an open category B (claim).
Here and further throughout the article, we will configure access for different pages of the website. To run the presented code, it is enough to create in Visual Studio 2015 a new application such as "ASP.NET Core Web Application", set the Web Application pattern and the authentication type "No Authentication".
When using "Individual User Accounts" authentication, a code would be generated to store and load users into the database using ASP.NET Identity, EF Core and localdb. What is completely redundant in this article, even though there is a lightweight EntityFrameworkCore.InMemory testing solution. Moreover, we basically do not need an ASP.NET Identity authentication library. Obtaining a principal for authorization can be self-emulated in-memory, and the principal can be serialized into a cookie using standard Core MVC tools. This is all that is needed for our testing.
To emulate a user repository, just open Startup.cs and register stub services in the built-in DI container:
public void ConfigureServices(IServiceCollection services) { // Identity services.AddIdentity<IdentityUser, IdentityRole>(); // services.AddTransient<IUserStore<IdentityUser>, FakeUserStore>(); services.AddTransient<IRoleStore<IdentityRole>, FakeRoleStore>(); }
By the way, we just did the same work that the AddEntityFrameworkStores <TContext> call would do :
services.AddIdentity<IdentityUser, IdentityRole>() .AddEntityFrameworkStores<IdentityDbContext>();
Let's start with the authorization of the user on the site: on GET /Home/Login
we will draw a stub form, add a button to send an empty form to the server. On POST /Home/Login
we will manually create a principal, identity and claim (in a real application, this data would be obtained from the database). The HttpContext.Authentication.SignInAsync
call serializes the principal and HttpContext.Authentication.SignInAsync
it into an encrypted cookie, which in turn will be attached to the response of the web server and saved on the client side:
[HttpGet] [AllowAnonymous] public IActionResult Login(string returnUrl = null) { ViewData["ReturnUrl"] = returnUrl; return View(); } [HttpPost] [AllowAnonymous] [ValidateAntiForgeryToken] public async Task<IActionResult> Login(LoginViewModel vm, string returnUrl = null) { //TODO: , , .. .. var claims = new List<Claim> { new Claim(ClaimTypes.Name, "Fake User"), new Claim("age", "25", ClaimValueTypes.Integer) }; var identity = new ClaimsIdentity("MyCookieMiddlewareInstance"); identity.AddClaims(claims); var principal = new ClaimsPrincipal(identity); await HttpContext.Authentication.SignInAsync("MyCookieMiddlewareInstance", principal, new AuthenticationProperties { ExpiresUtc = DateTime.UtcNow.AddMinutes(20) }); _logger.LogInformation(4, "User logged in."); return RedirectToLocal(returnUrl); }
Enable cookie authentication in the Startup.Configure (app) method:
app.UseCookieAuthentication(new CookieAuthenticationOptions() { AuthenticationScheme = "MyCookieMiddlewareInstance", CookieName = "MyCookieMiddlewareInstance", LoginPath = new PathString("/Home/Login/"), AccessDeniedPath = new PathString("/Home/AccessDenied/"), AutomaticAuthenticate = true, AutomaticChallenge = true });
This code with minor modifications will be the basis for all subsequent examples.
The [Authorize]
attribute has not gone anywhere from MVC. As before, when controller / action is marked with this attribute, only an authorized user will get access to it inside. Things become more interesting if you additionally specify the name of the policy (policy) - some requirements to claim the user:
[Authorize(Policy = "age-policy")] public IActionResult About() { return View(); }
Policies are created in the Startup.ConfigureServices
method already known to us:
services.AddAuthorization(options => { options.AddPolicy("age-policy", x => { x.RequireClaim("age"); }); });
This policy establishes that only an authorized user with the claim "age" can access the About page, and the value of the claim is not taken into account. In the next section, we will move on to examples more complicated (finally!), And now we will understand how this works inside?
[Authorize]
- a marker attribute that does not contain logic in itself. It is needed only in order to specify MVC, to which the controller / action AuthorizeFilter should be connected - one of Core MVC’s built-in filters. The concept of filters is the same as in previous versions of the framework: filters are executed sequentially and allow you to execute code before and after accessing the controller / action. An important difference from middleware: filters have access to the MVC-specific context (and, of course, they are executed after all middleware). However, the line between filter and middleware is very vague, since it is possible to integrate the middleware call into the filter chain using the [MiddlewareFilter] attribute.
Let's return to authorization and AuthorizeFilter. The most interesting thing happens in his OnAuthorizationAsync method:
I hope the links to the source code gave you an idea of ​​the internal design of filters in Core MVC.
Creating access policies through the fluent interface discussed above does not provide the flexibility that is required in real applications. Of course, you can explicitly specify the allowed values ​​of claim through a call to RequireClaim("x", params values)
, you can combine through logical AND several conditions by calling RequireClaim("x").RequireClaim("y")
. Finally, it is possible to hang different policies on the controller and action, which, however, will lead to the same combination of conditions through logical I. Obviously, a more flexible mechanism for creating policies is needed, and we have it: requirements and handlers.
services.AddAuthorization(options => { options.AddPolicy("age-policy", policy => policy.Requirements.Add(new AgeRequirement(42), new FooRequirement())); });
Requirement is no more than a DTO for passing parameters to the appropriate handler, which in turn has access to HttpContext.User and is free to impose any checks on the principal and the identity / claim contained in it. Moreover, the handler can receive external dependencies through the DI container built into Core MVC:
public class MinAgeRequirement : IAuthorizationRequirement { public MinAgeRequirement(int age) { Age = age; } public int Age { get; private set; } } public class MinAgeHandler : AuthorizationHandler<MinAgeRequirement> { public MinAgeHandler(IFooService fooService) { // fooService DI } protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, MinAgeRequirement requirement) { bool hasClaim = context.User.HasClaim(c => c.Type == "age"); bool hasIdentity = context.User.Identities.Any(i => i.AuthenticationType == "MultiPass"); string claimValue = context.User.FindFirst(c => c.Type == "age").Value; if (int.Parse(claimValue) >= requirement.Age) { context.Succeed(requirement); } else { context.Fail(); } return Task.CompletedTask; } }
We register the handler itself in Startup.ConfigureServices (), and it is ready for use:
services.AddSingleton<IAuthorizationHandler, MinAgeHandler>();
Handlers can be combined using both AND and OR. So, when registering several heirs of AuthorizationHandler<FooRequirement>
, all of them will be called. In this case, a call to context.Succeed()
not mandatory, and a call to context.Fail()
results in a general denial of authorization, regardless of the result of other handlers. In total, we can combine the access mechanisms as follows:
As mentioned earlier, the policy-based authorization is performed by the Core MVC in the filter pipeline, i.e. BEFORE calling the protected action. The success of authorization depends only on the user - either he has the necessary claim or not. But what if it is also necessary to take into account the protected resource and its properties, to get some data from external sources? Example from the life: we protect the action of the type GET /Orders/{id}
, which reads the id string with the order from the database. Let we can determine if the user has rights to a specific order only after receiving this order from the database. This automatically renders the previously uncovered aspect-oriented scenarios based on MVC filters, which are executed before the user code gets control, unsuitable. Fortunately, Core MVC has ways to authorize it manually.
For this, in the controller we will need an implementation of the IAuthorizationService
. We get it, as usual, through dependency injection into the constructor:
public class ResourceController : Controller { IAuthorizationService _authorizationService; public ResourceController(IAuthorizationService authorizationService) { _authorizationService = authorizationService; } }
Then create a new policy and handler:
options.AddPolicy("resource-allow-policy", x => { x.AddRequirements(new ResourceBasedRequirement()); }); public class ResourceHandler : AuthorizationHandler<ResourceBasedRequirement, Order> { protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, ResourceBasedRequirement requirement, Order order) { // TODO: , if (true) context.Succeed(requirement); return Task.CompletedTask; } }
Finally, we check the user + resource for compliance with the desired policy inside the action (note that the [Authorize]
attribute is no longer needed):
public async Task<IActionResult> Allow(int id) { Order order = new Order(); // if (await _authorizationService.AuthorizeAsync(User, order, "my-resource-policy")) { return View(); } else { // 401 403 return new ChallengeResult(); } }
The IAuthorizationService.AuthorizeAsync
method has an overload that takes a list from a requirement instead of a policy name:
Task<bool> AuthorizeAsync( ClaimsPrincipal user, object resource, IEnumerable<IAuthorizationRequirement> requirements);
That allows even more flexibility to configure access rights. For the demonstration, use the predefined OperationAuthorizationRequirement
(yes, this example migrated to the article directly from docs.microsoft.com ):
public static class Operations { public static OperationAuthorizationRequirement Create = new OperationAuthorizationRequirement { Name = "Create" }; public static OperationAuthorizationRequirement Read = new OperationAuthorizationRequirement { Name = "Read" }; public static OperationAuthorizationRequirement Update = new OperationAuthorizationRequirement { Name = "Update" }; public static OperationAuthorizationRequirement Delete = new OperationAuthorizationRequirement { Name = "Delete" }; }
which will allow you to do the following things:
_authorizationService.AuthorizeAsync( User, resource, Operations.Create, Operations.Read, Operations.Update);
In the HandleRequirementAsync(context, requirement, resource)
method HandleRequirementAsync(context, requirement, resource)
corresponding handler - you only need to check the rights of the operation, respectively, specified in requirement.Name
and do not forget to call context.Fail()
if the user failed authorization:
protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, OperationAuthorizationRequirement requirement, Order order) { string operationName = requirement.Name; // , if(true) context.Succeed(requirement); return Task.CompletedTask; }
The handler will be called as many times as the requirement you submitted to AuthorizeAsync
and will check each requirement separately. For a one-time check of all rights to operations in one call to the handler, pass the list of operations inside the requirement, for example:
new OperationListRequirement(new[] { Ops.Read, Ops.Update })
This is where the overview of resource-based authorization is complete, and it's time to cover our handlers with tests:
[Test] public async Task MinAgeHandler_WhenCalledWithValidUser_Succeed() { var requirement = new MinAgeRequirement(24); var user = new ClaimsPrincipal(new ClaimsIdentity(new List<Claim> { new Claim("age", "25") })); var context = new AuthorizationHandlerContext(new [] { requirement }, user, resource: null); var handler = new MinAgeHandler(); await handler.HandleAsync(context); Assert.True(context.HasSucceeded); }
User rights verification performed directly in the markup can be useful to hide UI elements to which the user should not have access. Of course, in the view, you can pass all the necessary flags through the ViewModel (other things being equal, I am for this option), or you can directly contact the principal via HttpContext.User:
<h4>: @User.GetClaimValue("age")</h4>
If you're interested, the view is inherited from the RazorPage class, and direct access to the HttpContext from the markup is possible through the property @Context
.
On the other hand, we can use the approach from the previous section: get the IAuthorizationService
implementation through DI (yes, right in the view) and check the user for compliance with the requirements of the desired policy:
@inject IAuthorizationService AuthorizationService @if (await AuthorizationService.AuthorizeAsync(User, "my-policy"))
Do not try to use the SignInManager.IsSignedIn(User)
call in our test project (used in the web application template with the Individual User Accounts authentication type). First of all, because we do not use the Microsoft.AspNetCore.Identity
authentication library to which this class belongs. The method itself doesn’t do anything inside, apart from checking that the user has an identity with a name in the library’s code.
Declarative listing of all requested operations (first of all from CRUD) when authorizing a user, such as:
var requirement = OperationListRequirement(new[] { Ops.FooAction, Ops.BarAction }); _authorizationService.AuthorizeAsync(User, resource, requirement);
... it makes sense if your project has a system of personal permissions (permissions): there is a certain set of a large number of high-level business logic operations, there are users (or groups of users) who were manually granted rights to specific operations with a specific resource. For example, Vasya has the right to "scrub the deck", "sleep in the cabin", and Petya can "turn the steering wheel." Good or bad, such a pattern is a topic for a separate article (I personally am not happy with it). The obvious problem of this approach: the list of operations easily grows to several hundred, even not in the largest system.
The situation is simplified if for authorization there is no need to take into account a specific instance of the protected resource, and our system has sufficient granularity to simply attach an attribute with a list of checked operations to the entire method, instead of hundreds of AuthorizeAsync
calls in the protected code. However, the use of authorization based on policies [Authorize(Policy = "foo-policy")]
will lead to a combinatorial explosion of the number of policies in the application. Why not use the good old role-based authorization? In the example code below, the user needs to be a member of all the specified roles to access the FooController:
[Authorize(Roles = "PowerUser")] [Authorize(Roles = "ControlPanelUser")] public class FooController : Controller { }
Such a solution may also not provide sufficient detail and flexibility for a system with a large number of permissions and their possible combinations. Additional problems start when both role-based and permission-based authorization is needed. Yes, and semantically, roles and operations are different things, I would like to process their authorization separately. Resolved: write your version of the attribute [Authorize]
! I will demonstrate the final result:
[AuthorizePermission(Permission.Foo, Permission.Bar)] public IActionResult Edit() { return View(); }
Start by creating an enum for operations, a requirement, and a handler for user verification:
public enum Permission { Foo, Bar } public class PermissionRequirement : IAuthorizationRequirement { public Permission[] Permissions { get; set; } public PermissionRequirement(Permission[] permissions) { Permissions = permissions; } } public class PermissionHandler : AuthorizationHandler<PermissionRequirement> { protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, PermissionRequirement requirement) { //TODO: , if (requirement.Permissions.Any()) { context.Succeed(requirement); } return Task.CompletedTask; } }
Earlier, I told you that the [Authorize]
attribute is purely marker and is needed for using AuthorizeFilter
. We will not fight with the existing architecture, so we will write by analogy our own authorization filter. Since the permissions list for each action is different, then:
Fortunately, in Core MVC, these problems are easily solved with the [TypeFilter] attribute:
[TypeFilter(typeof(PermissionFilterV1), new object[] { new[] { Permission.Foo, Permission.Bar } })] public IActionResult Index() { return View(); }
public class PermissionFilterV1 : Attribute, IAsyncAuthorizationFilter { private readonly IAuthorizationService _authService; private readonly Permission[] _permissions; public PermissionFilterV1(IAuthorizationService authService, Permission[] permissions) { _authService = authService; _permissions = permissions; } public async Task OnAuthorizationAsync(AuthorizationFilterContext context) { bool ok = await _authService.AuthorizeAsync( context.HttpContext.User, null, new PermissionRequirement(_permissions)); if (!ok) context.Result = new ChallengeResult(); } }
We got a fully working, but ugly looking solution. In order to hide the implementation details of our filter from the calling code, the [AuthorizePermission]
attribute is useful to us:
public class AuthorizePermissionAttribute : TypeFilterAttribute { public AuthorizePermissionAttribute(params Permission[] permissions) : base(typeof(PermissionFilterV2)) { Arguments = new[] { new PermissionRequirement(permissions) }; Order = Int32.MaxValue; } }
Result:
[AuthorizePermission(Permission.Foo, Permission.Bar)] [Authorize(Policy = "foo-policy")] public IActionResult Index() { return View(); }
Please note: authorization filters work independently, which allows you to combine them with each other. AuthorizePermissionAttribute.Order
.
( ):
Source: https://habr.com/ru/post/322566/
All Articles