ASP.NET
Core Identity Server 4 Policy based
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
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
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
沒有留言:
張貼留言