2019年10月7日 星期一

[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<IdentityResourceGetIdentityResources()
        {
            return new IdentityResource[]
            {
                new IdentityResources.OpenId
            };
        }

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

        public static IEnumerable<ClientGetClients()
        {
            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<stringAdminGet()
 {
      return "Yes, only an Admin can access this API!";
 }

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

 [HttpGet]
 [Route("Sales/Admin/Get")]
 [Authorize(Policy = "SalesDepartmentPolicy")]
 [Authorize(Policy = "AdminPolicy")]
 public ActionResult<stringSalesAdminGet()
 {
      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 userbool isOK) = await this.cache.GetCacheAsync<UserProfile>(cacheKey);

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

                // Department claim
                claims.Add(new Claim(CustomClaimTypes.Departmentuser.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












沒有留言:

張貼留言