Monday, October 7, 2019

[ASP.NET Core] Identity Server 4 – Policy based authorization


 ASP.NET Core   Identity Server 4   Policy based  


▌Introduction


In the previous article, we learned how to apply Role-based authorization to Backend APIs (Web API) by adding Role claim(s) to the access token.
Here we will go further to apply Policy-based authorization to our APIs for more flexible security-control.

By applying policy-based authorization, we can have the following rules:
·   If the client who want to access the API is in Role A OR/AND B (policy: check role)
·   If the client who want to access the API is in department X OR/AND Y (policy: check department)
·   If the client who want to access the API is in certain Role OR/AND certain Department (policy: check role and department)

The flow is similar to role-based authorization, we

1.  Add the user’s claim(s) when issuing a JWT Token
2.  Define the Policies for APIs to protect them with specified required permissions








▋docker-openldap 1.2.4 (OpenLDAP 2.4.47)
▋ASP.NET Core 2.2.402
▋IdentityServer4 2.4.0
▋Nordes/IdentityServer4.LdapExtension 2.1.8
▋IdentityModel 3.10.10





▌Implement


The source code is on my Github.


▋Set Claim(s) for Client

We will add the claims to the Access Token on design time:

·         Role claim
·         Department claim (Customized claim)

Let’s define a few Custom Claim Types,

▋CustomClaimTypes.cs

 public static class CustomClaimTypes
 {
        public const string Department = "department";
 }


Then we can add the claims when defining the Client configuration.

▋CustomClaimTypes.cs

public class InMemoryInitConfig
    {
        public static IEnumerable<IdentityResource> GetIdentityResources()
        {
            return new IdentityResource[]
            {
                new IdentityResources.OpenId
            };
        }

        public static IEnumerable<ApiResource> GetApiResources()
        {
            return new ApiResource[]
            {
                new ApiResource("MyBackendApi2", "My Backend API 2")
            };
        }

        public static IEnumerable<Client> GetClients()
        {
            return new[]
            {
                new Client
                {
                    Enabled = true,
                    ClientId = "PolicyBasedBackend",
                    ClientName = "MyBackend Client",
                    AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
                    AccessTokenType = AccessTokenType.Jwt,
                    AllowedScopes = {
                        "MyBackendApi2",
                        IdentityServerConstants.StandardScopes.OpenId,
                    },
                    AlwaysSendClientClaims = true,
                    UpdateAccessTokenClaimsOnRefresh = true,
                    AlwaysIncludeUserClaimsInIdToken = true,
                    AllowAccessTokensViaBrowser = true,
                    IncludeJwtId = true,
                    ClientSecrets = { new Secret("secret".Sha256()) },
                    AllowOfflineAccess = true,
                    AccessTokenLifetime = 3600,

                    RefreshTokenUsage = TokenUsage.OneTimeOnly, // Or ReUse
                    RefreshTokenExpiration = TokenExpiration.Sliding,
                    AbsoluteRefreshTokenLifetime = 360000,
                    SlidingRefreshTokenLifetime = 36000,
                     
                    ClientClaimsPrefix = string.Empty,
                    Claims = new Claim[]
                    {
                        // Assign const roles
                        new Claim(ClaimTypes.Role, "admin"), // or new Claim(JwtClaimTypes.Role, "admin")
                        new Claim(ClaimTypes.Role, "user"),
                        // Assign const department
                        new Claim(CustomClaimTypes.Department, "Sales")
                    }
                }

            };
        }
    }

The potential settings,

ClientClaimsPrefix = string.Empty, 

is to make sure the role claim is of right type, see the chapter: Set Role Claim(s) for Client, in my previous article.

Now the user can get the JWT token with the required claims as following,




▋Secure Backend API with Policy(s)

We can create authorization policies when configuring services in Startup.cs, for example,

▋Startup.cs: ConfigureServices

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

         // Enable policy-based authorization
         services.AddAuthorization(options => options.AddPolicy("AdminPolicy", policy => policy.RequireRole("admin")));
         services.AddAuthorization(options => options.AddPolicy("SalesCrmDepartmentPolicy", policy => policy.RequireClaim(CustomClaimTypes.Department, "Sales")));         
}

As you can see, we have tow policies that one checks the Role Claim and the other checks the Department Claim.

Now we apply them to our APIs like this,

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

 [HttpGet]
 [Route("Sales/Get")]
 [Authorize(Policy = "SalesDepartmentPolicy")]
 public ActionResult<string> SalesGet()
 {
      return "Yes, only Sales Department can access this API!";
 }

 [HttpGet]
 [Route("Sales/Admin/Get")]
 [Authorize(Policy = "SalesDepartmentPolicy")]
 [Authorize(Policy = "AdminPolicy")]
 public ActionResult<string> SalesAdminGet()
 {
      return "Yes, only an Admin of Sales Deparment can access this API!";
 }


The 3 APIs indicates 3 different authorization policies:

ð  /Admin/Get: The user must have the Role claim with value: "admin".
ð  /Sales/Get: The user must have the Department claim with value: "Sales".
ð  /Sales/Admin/Get: Since the Authorize attributes are superposed, so both 2 policies will be applied and indicates the user must have the Role claim with value: "admin" AND the Department claim with value: "Sales".

For example, if the user (access token) is in CRM department and is in role: "admin".
The access results will be:

ð  /Admin/Get: 200 OK
ð  /Sales/Get: 403 Forbidden
ð  /Sales/Admin/Get: 403 Forbidden


▋More Policy samples

Here are some policy samples for more flexible authorization checking.

▋Match any value in certain Claim type

ð  Those who are as role: "admin" OR "user" can access

services.AddAuthorization(options => options.AddPolicy("AdminOrUserPolicy", policy => policy.RequireRole("admin", "user")));


▋Match all the values for different Claim types

ð  Those who are in "Sales" department AND as role: "admin" can access

services.AddAuthorization(options => options.AddPolicy("SalesDepartmentAndAdminPolicy", policy => policy
    .RequireClaim(CustomClaimTypes.Department, "Sales")
    .RequireRole("admin")));



ð  Those who are in "Sales" department AND as role: "admin" OR "user" can access

services.AddAuthorization(options => options.AddPolicy("SalesDepartmentAndAdminOrUserPolicy", policy => policy
    .RequireClaim(CustomClaimTypes.Department, "Sales")
    .RequireRole("admin", "user")));


PS.
We can also use the superposition way for this match-all policy by creating multiple policies and place multiple Authorize attributes on the API.


▋Match one of the values for different Claim types


ð  Those who are in "Sales" department OR as role: "admin" can access

For the complex logics on one Policy, it is recommended to use AuthorizationPolicyBuilder.RequireAssertion method to check the user claim manually.


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"))
               ))));




▋Advanced: Dynamically load user’s claim(s)

At present, we defined the Role/Department claims on IdentityServer’s Client configuration on design time.
However, most time we keep the user’s role/department in database, so it’s common to have a way to generate the Claims on run time.  
IdentityServer defines an extensibility point for allowing claims to be dynamically loaded as needed for a user by implementing IProfileService.

For example, I store user’s information in Redis and will dynamically load the information to generate the necessary Claims.




▋(Auth Server) ProfileService.cs

public class ProfileService : IProfileService
    {
        private readonly ICacheService cache = null;
        private readonly CacheKeyFactory cacheKeys = null;

        public ProfileService(
            ICacheService cache,
            CacheKeyFactory cacheKeys)
        {
            this.cache = cache;
            this.cacheKeys = cacheKeys;
        }
        
        public async Task GetProfileDataAsync(ProfileDataRequestContext context)
        {

            //sub is your userId.
            var subClaim = context.Subject.Claims.FirstOrDefault(x => x.Type == "sub");

            if (!string.IsNullOrEmpty(subClaim?.Value))
            {
                context.IssuedClaims = await this.getClaims(subClaim.Value);
            }
        }

        public async Task IsActiveAsync(IsActiveContext context)
        {
            context.IsActive = true;
        }

        private async Task<List<Claim>> getClaims(string userName)
        {
            var claims = new List<Claim>();
            var cacheKey = this.cacheKeys.UserProfile(userName);
            (UserProfile user, bool isOK) = await this.cache.GetCacheAsync<UserProfile>(cacheKey);

            if (isOK)
            {
                // Role claim
                user.Roles.Split(',').Select(x => new Claim(ClaimTypes.Role, x.Trim())).ToList().ForEach(claim => claims.Add(claim));

                // Department claim
                claims.Add(new Claim(CustomClaimTypes.Department, user.Department));
            }

            return claims;
        }
    }


Don’t forget to remove the static Claims from Client configuration on IdentityServer.




Then add the custom ProfileService into service container.

▋(Auth Server) Startup.cs

public void ConfigureServices (IServiceCollection services) 
 {
       // … Skip
      
#region Identity Server
// Skip … Signing credential
// Skip … Set in-memory, code config
            
      builder.AddProfileService<ProfileService>();
#endregion
 }


Result:




▋Source Code




▌Reference












No comments:

Post a Comment