2020年2月23日 星期日

[ASP.NET Core] Identity Server 4 – Policy based authorization with custom Authorization handler


 ASP.NET Core   Identity Server 4   Policy based   Authorization Handler  








Introduction


The Policy-based authorization can be done by using AuthorizationPolicyBuilder‘s methods (RequireAssertion, RequireRole … etc) in Client side as following code.

services.AddAuthorization(options => options.AddPolicy("SalesDepartmentOrAdminPolicy"policy => policy.RequireAssertion(
    context => context.User.Claims.Any(
                    x => (x.Type.Equals(CustomClaimTypes.Department) && x.Value.Equals("Sales")) || 
                          (x.Type.Equals(ClaimTypes.Role) && x.Value.Equals("admin"))
               ))));


Furthermore, if Client would like to do advanced authorization-checking by itself, we can add one or more custom Authorization Handlers and put the validation logics on them.


Custom Authorization Handler is often used when the user’s required information is kept on the client side not on the JWT, so that we can reject the request (response 401 Unauthorized) depends on the information not on JWT even if the JWT is validated ok.


I will make a simple example by creating 2 Authorization Handlers to

l  Check the user’s email domain is expected
l  Check the user’s name is certain user


PS. Notice we will use the claim (email, user name) on JWT in the following sample codes to validate the user in the 2 Authorization Handlers.

Environment


Docker 18.05.0-ce
ASP.NET Core 3.0.100
IdentityServer4 3.0.1
IdentityModel 3.10.10



Implement


The source code is on my Github.

Install Microsoft.AspNetCore.Authorization

We need to install Microsoft.AspNetCore.Authorization for implementing Authorization Requirement with IAuthorizationRequirement and customizing Authorization Handler with AuthorizationHandler<TRequirement>.







The related namespace:

using Microsoft.AspNetCore.Authorization;



Authorization Requirement

Before creating Authorzation Handler, we have to create Authorization Requirement that specified the requirement to meet.
In other words, the Authorization Handler will be succeed only if the user’s information suits the requirement.


Here are the Authorization Requirements:

EmailDomainRequirement.cs

public class EmailDomainRequirement : IAuthorizationRequirement
    {
        /// <summary>
        /// The domain name
        /// </summary>
        public string Domain { getset; }

         public EmailDomainRequirement(string domain)
        {
            this.Domain = domain;
        }
    }


UserNameRequirement.cs

public class UserNameRequirement : IAuthorizationRequirement
    {
        /// <summary>
        /// User's name
        /// </summary>
        public string UserName { getset; }
        
public UserNameRequirement(string username)
        {
            this.UserName = username;
        }
    }




Authorization Handler

Now we can create Authorization Handler with the Authorization Requirement as the generic type like this,

using Microsoft.AspNetCore.Authorization;

public class CusomAuthorizationHandler : AuthorizationHandler<CustomRequirement>
 {
        protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, CustomRequirement requirement)
        {
            // … Validation logic here
 }           


Notice that we can use multiple Authorization Handlers on a single authorization policy, they will be executed one by one just like as a chain, no matter one of them validates OK or NG. (We can disable the option that will be mentioned at the later part of this article!)




Since the Authorization Handlers are validate one by one, we have to check the previous validation result at the next one!
The AuthorizationHandlerContext object contains the 2 useful properties:

Property
Type
Description
Default value
Boolean
Flag indicating whether the current authorization processing has succeeded.
False
Boolean
Flag indicating whether the current authorization processing has failed.
False


And the 2 methods to set the value of above property:

Mthod
Return type
Description
Boolean
Called to mark the specified requirement as being successfully evaluated.
Boolean
Called to indicate HasSucceeded will never return true, even if all requirements are met.


So what we have to do in an Authorization Handler:

l   To check if the user’s JWT is valid or not, use context.User Identity’s IsAuthenticated flag instead of context.HasFailed or context.HasSucceeded.
That is because the Default JWT Authorization handler DOES NOT set values on context.HasFailed and context.HasSucceeded.
l   Check context.HasFailed if the authorization process had been already failed and there is no need to do the current validation.
l   Call context.Succeed(requirement) when current validation is OK or call context.Fail() when current validation fails.


using Microsoft.AspNetCore.Authorization;

public class CusomAuthorizationHandler : AuthorizationHandler<CustomRequirement>
 {
        protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, CustomRequirement requirement)
        {
            ClaimsPrincipal userClaim = context.User;

              // 1. Check if the Default Authenticate was failed (i.e. JWT is invalid)
            if (context.User.Identity == null || !context.User.Identities.Any(i => i.IsAuthenticated))
            {
                context.Fail();
                return;
            }

              // 2. Check if the previous Handler had been failed
              if (context.HasFailed)
            {
                context.Fail();
                return;
            }

            // 3. Do the custom validation by comparing the information from AuthorizationHandlerContext to Requirement
            bool isMeetRequirement = true; // Update the validation logic here …
            if (isMeetRequirement)
            {
               context.Succeed(requirement);
               return;
            }
               else
                context.Fail();

            await Task.CompletedTask;
 }



Now we know how to create an Authorization Handler, lets create one by validating the email’s domain name.

EmailDomainAuthHandler.cs

using Microsoft.AspNetCore.Authorization;

public class EmailDomainAuthHandler : AuthorizationHandler<EmailDomainRequirement>
 {

        protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, EmailDomainRequirement requirement)
        {
            ClaimsPrincipal userClaim = context.User;

            if (context.HasFailed || userClaim.Identity == null || !userClaim.Identities.Any(i => i.IsAuthenticated))
            {
                context.Fail();
                this.logger.LogWarning($"Skip validating with {nameof(EmailDomainAuthHandler)} cus the authorization process had been failed!");
                return;
            }

            var userEmailClaim = userClaim.Claims.FirstOrDefault(c => c.Type.Equals(ClaimTypes.Email));

            if (userEmailClaim != null)
            {
                // Get domain name from email
                var address = new MailAddress(userEmailClaim.Value);
                string userDomain = address.Host; // e.q. google.com

                // Verify domain
                if (userDomain.Equals(requirement.Domain))
                {
                    context.Succeed(requirement);
                    return;
                }
            }

            context.Fail();

            await Task.CompletedTask;
        }
 }


The code of the other Authorization Handler: UserNameAuthHandler.cs is here.


Inject Custom Authorization Handler and create a new policy for API

To use the custom Authorization Handler, we have to inject them on Startup: ConfigureServices.
Then we can create a new policy with specified Authorization Requirement(s).
Notice that the injection order of Authorization Handlers will be the order for invoking them!


Startup.cs

The below policy shows that the user must be with email domain as “fake.com” and the user name must be “jblin”.

public void ConfigureServices(IServiceCollection services)
{
            
            // … skip

            #region Enable custom Authorization Handlers (The registration order matters!)
            services.AddSingleton<IAuthorizationHandlerEmailDomainAuthHandler>();
            services.AddSingleton<IAuthorizationHandlerUserNameAuthHandler>();

            services.AddAuthorization(options =>
            {
                var emailDomainRequirement = new EmailDomainRequirement("fake.com");
                var userNameRequirement = new UserNameRequirement("jblin");

                options.InvokeHandlersAfterFailure = false// Default: true
                options.AddPolicy("DoaminAndUsernamePolicy", policy =>
                         policy.AddRequirements(emailDomainRequirement, userNameRequirement));
            }); 
            #endregion
}


Now we can use this new policy to secure our API!

[HttpGet]
[Route("AdminOrUserWithCusomHandler/Get")]
[Authorize(Policy = "DoaminAndUsernamePolicy")]
public ActionResult<string> AdminOrUserWithCusomHandlerGet()
{
    return "Yes, only an Admin or User can access this API!";
}



(Optional) Set authentication handlers should not be invoked after a failure.

If we would like to disable invoking the next Authorization Handler after the previous one fails, just set AuthorizationOptions.InvokeHandlersAfterFailure to be false.

services.AddAuthorization(options =>
 {
                var emailDomainRequirement = new EmailDomainRequirement("fake.com");
                var userNameRequirement = new UserNameRequirement("jblin");

                options.InvokeHandlersAfterFailure false// Default: true
                options.AddPolicy("DoaminAndUsernamePolicy", policy =>
                         policy.AddRequirements(emailDomainRequirement, userNameRequirement));
 }); 
            


So the chain will be stopped when one of the custom Authorization Handler fails!






Demo


Scenario 1. Valid JWT/Email domain/User name


Response 200:



Logs:






Scenario 2. Valid JWT/Email domain but invalid User name


Response 403:




Logs:





Scenario 3. Valid JWT/User name but invalid Email domain


Response 403:



Logs:






Scenario 3. Valid JWT/User name but invalid Email domain with InvokeHandlersAfterFailure=false


Logs:




Response 403 as the previous scenario but the logs did not show
“Skip validating with UserNmaeAuthHandler cus the authorization process had been failed!”

That is because UserNameAuthHandler didn’t be invoked by setting AuthorizationOptions.InvokeHandlersAfterFailure to be false!




Source Code





Reference








沒有留言:

張貼留言