2

ASP.NET Core Web API下基于Keycloak的多租户用户授权的实现 - dax.net

 1 week ago
source link: https://www.cnblogs.com/daxnet/p/18148133
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

ASP.NET Core Web API下基于Keycloak的多租户用户授权的实现

在上文《Keycloak中授权的实现》中,以一个实际案例介绍了Keycloak中用户授权的设置方法。现在回顾一下这个案例:

  1. 服务供应商(Service Provider)发布/WeatherForecast API供外部访问
  2. 在企业应用(Client)里有三个用户:super,daxnet,nobody
  3. 在企业应用里有两个用户组:administrators,users
  4. 在企业应用里定义了两个用户角色:administrator,regular user
  5. super用户同时属于users和administrators组,daxnet属于users组,nobody不属于任何组
  6. administrators组被赋予了administrator角色,users组被赋予了regular user角色
  7. 对于/WeatherForecast API,它支持两种操作:GET /WeatherForecast,用以返回天气预报数据;PATCH /WeatherForecast,用以调整天气预报数据
  8. 拥有administrator角色的用户/组,具有PATCH操作的权限;拥有regular user角色但没有administrator角色的用户/组,具有GET操作的权限;没有任何角色的用户,就没有访问/WeatherForecast API的权限

于是,基于这个需求,我们在Keycloak的一个Client下,进行了如下与授权有关的配置:

  1. 创建了weather-api的Resource
  2. 创建了weather.read、weather.update两个Scope
  3. weather-api具有weather.read、weather.update两个授权Scope
  4. 新建了三个用户:super, daxnet, nobody
  5. 新建了两个用户组:administrators,users
  6. 新建了两个角色:administrator,regular user
  7. super用户同时属于administrators和users两个用户组;daxnet用户仅属于users组,而nobody不属于任何组
  8. administrators用户组被赋予了administrator角色,users组被赋予了regular user角色
  9. 定义了两个基于角色的授权策略:
    1. require-admin-policy:期望资源访问方已被赋予administrator角色
    2. require-registered-user:期望资源访问方已被赋予regular user角色
  10. 定义了两个权限,表示对什么样的授权策略允许访问什么样的资源:
    1. weather-view-permission:对于require-registered-user策略,具有weather.read操作的权限
    2. weather-modify-permission:对于require-admin-policy策略,具有weather.update操作的权限

接下来的一步,就是在应用程序中实现一套机制,通过这套机制来控制用户(资源访问方)对API(资源)的访问。

思考:ASP.NET Core标准授权模型能满足需求吗?

ASP.NET Core已经提供了一套易学易用的授权组件,包括AuthorizeAttributeIAuthorizationHandlerIAuthorizationRequirementIAuthorizationFilter等,使用这些组件,可以方便地实现基于角色(Role)和基于策略(Policy)的授权机制。在使用AuthorizeAttribute特性来完成授权时,可以指定被赋予哪些角色的用户可以获得授权,也可以指定一个策略名称,只要是满足该策略下各条件的用户,就可以获得授权。

如果是基于角色,首先需要在AuthorizeAttribute上指定Roles属性,然后在配置JwtBearer Authentication的时候,在TokenValidationParameter上,设置RoleClaimType,这样一来,框架就会从认证用户的access token中获得由RoleClaimType指定的Claim中所包含的角色信息,然后判断它是否已在AuthorizationAttribute.Roles属性上指定,从而进一步判断该用户是否可以获得授权。

如果是基于策略,那么就需要自己实现IAuthorizationHandlerIAuthorizationRequirement接口,在这些接口的实现中,基于Claims来判断该用户是否可以获得授权,所以在ASP.NET Core中,这种授权也称作“基于Claim的授权”,只不过策略就是基于Claim数据的判定结果而已。具体实现方式可以参考这篇官方文档,这里不再赘述。

不管是基于角色,还是基于策略(或者基于Claim),一个用户是否可被授权,判断条件都是看这个用户是否已被赋予某个角色(超级管理员?管理员?普通用户?),或者它自身的属性是否满足某个或某几个条件(年龄?性别?是否诚信有问题?或者是这些条件的组合?)。当应用程序仅服务于一个客户时,基于角色的授权(RBAC)或者基于Claim的授权都是没有问题的,因为单针对这个客户而言,需求相对是比较简单的:该公司对用户的角色定义仅有超级管理员、管理员和普通用户三种,并且该公司下的所有用户的个人信息都包含年龄和性别两个字段,并且这两个字段始终有值。当然,如果需要扩展出新的角色,或者在用户个人信息上加入新的字段并使其成为判断条件,那么还是需要修改源代码并重新部署整个应用。

在多租户的云服务中,情况就变得复杂,在《在Keycloak中实现多租户并在ASP.NET Core下进行验证》一文中,我介绍过如何基于Keycloak设计多租户的认证模型,其中有两个主要观点:1、租户间数据隔离;2、在Single Realm下使用不同的Client区分不同的租户。在Keycloak中,授权的设定是基于Client,这也就意味着,不同的租户可以选择使用完全不同的授权模型。不仅如此,用户角色(Role)的设计也是按Client区分的,所以,不同的租户可以有完全不同的用户角色定义:A租户下的用户不分角色,所有用户都是User角色;B租户下的用户分管理员和普通用户两种角色。更进一步,对于某个API,A租户希望只有年满18岁的用户才能访问,而B租户则指定仅有管理员才能访问。

如果在ASP.NET Core中单纯使用AuthorizeAttribute配合基于角色或者基于Claim的授权,你会发现,你无法在AuthorizeAttribute上指定角色的名称,因为不同租户不一定都会使用相同的角色名称;也无法在AuthorizeAttribute上指定一个Policy的名称,并正确地实现这个Policy的逻辑,因为不同租户下登录的用户ClaimsPrincipal中不一定会带上授权所需的Claim(因为该租户压根就没有定义这样的Claim)。

所以,在多租户环境下,授权应该基于应用本身能够提供什么,而不是租户或者租户下的用户能够提供什么。对于一个ASP.NET Core Web API应用来说,资源(Resource)和操作(Scope)是根据应用程序的API设计而设计的,与租户和租户下的用户没有关系。所以,在多租户应用中,授权应该基于Resource和Scope来实现。

设计:ASP.NET Core下基于Resource和Scope的授权

仍然以Weather API为例,在获取天气数据的时候,就会定义一个Get的API,这个API就是应用里的一个Resource,并且这个API能够提供的Scope为Read,表示这个Resource是可以被读取的。那么,很有可能这个Get Weather的API就有类似这样的定义(具体实现部分省略):

[ProtectedResource("weather-api", "weather.read")][HttpGet]public IEnumerable<WeatherForecast> Get(){ return Ok();}

ProtectedResourceAttribute特性指定了当前被修饰的方法为一个受保护的资源,该资源名为weather-api,它能提供的Scope为weather.read。因此,只要访问该API的User(ClaimsPrincipal)对weather-api这种Resource具有weather.read操作,就可以允许该用户访问此API。那么User如何才可以对weather-api这种Resource进行weather.read操作呢?这部分内容在上一篇文章中已经详细介绍过了,只需要在Keycloak中合理地配置策略(Policies)和授权(Permissions)即可。由于Policy不仅可以基于角色,而且可以基于用户、用户组、正则表达式等,甚至可以进行组合,因此,对于不同的Client(租户),可以定义非常灵活的授权策略,比如:定义一个策略,该策略指定用户需要满足的条件为:属于“销售科”用户组,并且工作年限大于10年,然后在授权的配置部分,指定对于weather-api Resource,满足该策略的访问方可以执行weather.read操作即可。

在ASP.NET Core中,ProtectedResourceAttribute需要实现为IAuthorizationFilter(或者IAsyncAuthorizationFilter),这样就可以使得API在被调用之前,可以检查访问者是否有权限访问。由于不需要使用基于角色或者基于标准Claims的授权,所以不需要继承于AuthorizeAttribute。在ProtectedResourceAttribute的实现逻辑中,判断当前ClaimsPrincipal是否具有对当前受保护资源的操作权限就行了,那么如何进行判断?就需要在ProtectedResourceAttribute执行前,将这些信息附加到ClaimsPrincipal上。

在OIDC的认证和授权体系中,通过authentication flow获得的access token往往不会包含授权相关的信息,这是出于性能考虑。在有些情况下,授权信息会比较复杂庞大,认证的时候将授权信息附加在token中,会大大增加token的大小,让authentication flow变得不是那么的轻量。在Keycloak中,通常都是首先获得access token,然后将access token用作Bearer token再次调用token API端点,并将grant_type设置为urn:ietf:params:oauth:grant-type:uma-ticket以获得授权信息,这个步骤在上一篇文章中也介绍过。因此,看上去我们不得不在获得access token之后的某个时间点,再次调用Keycloak的token API端点,也就是需要第二次的API调用来完成授权信息的获得。

我们当然可以考虑在ProtectedResourceAttribute的代码里调用这个API来获得授权信息,但这并不是推荐做法。通常情况下,IAuthorizationFilter中,应该只通过附加在ClaimsPrincipal上的Claims做判断,而不应该在其中又调用第二个API来获取信息。一个比较合理的做法是,在authorization flow中,当发生“token已被校验事件”(OnTokenValidated)时,调用API以获得授权信息,然后将获得的授权信息附加到当前ClaimsPrincipal的Claims上,进而就可以在ProtectedResourceAttribute里进行授权判定了。当然,即使是在OnTokenValidated事件中调用API,也还是会存在性能问题,所以,在真实场景中,应该考虑将获得的授权信息缓存起来,但这又带来新的问题:何时应该刷新缓存。不过现在我们暂时不考虑这些。

因此,整个模型的设计大概如下图所示:

119825-20240422220648071-1653704587.png

我们可以设计一个IPermissionService的接口,接口中有一个方法:ReadPermissionClaimsAsync,用于使用当前已认证过的access token换取授权信息,并以一组Claims的形式返回。单独设计这个接口的目的就在于方便今后加入缓存这样的逻辑。在OnTokenValidated事件中,通过ASP.NET Core的IoC/DI获得IPermissionService的实例,然后调用ReadPermissionClaimsAsync方法获得授权相关的Claims,并将这些Claims附加到ClaimsPrincipal上。另一方面,当ProtectedResourceAttribute执行授权逻辑时,将ClaimsPrincipal上与授权相关的Claims的值与当前Resource的名称和Scope进行比较,即可判定是否应该授予相关权限。

实现:ASP.NET Core中授权的实现

上面已经分析得比较彻底了,现在直接上代码。首先就是定义并实现IPermissionService接口:

public interface IPermissionService{ Task<IEnumerable<Claim>?> ReadPermissionClaimsAsync(string bearerToken, string audience, string requestUri);} public sealed class PermissionService(IHttpClientFactory httpClientFactory) : IPermissionService{ public async Task<IEnumerable<Claim>?> ReadPermissionClaimsAsync(string bearerToken, string audience, string requestUri) { var result = new List<Claim>(); using var httpClient = httpClientFactory.CreateClient("JwtTokenClient"); httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); var payload = new Dictionary<string, string> { { "grant_type", "urn:ietf:params:oauth:grant-type:uma-ticket" }, { "audience", audience } }; var request = new HttpRequestMessage(HttpMethod.Post, requestUri) { Content = new FormUrlEncodedContent(payload) }; try { var response = await httpClient.SendAsync(request); response.EnsureSuccessStatusCode(); var responseJson = await response.Content.ReadAsStringAsync(); var responseJsonObject = JObject.Parse(responseJson); var authTokenString = responseJsonObject["access_token"]?.Value<string>(); if (string.IsNullOrEmpty(authTokenString)) return null; var tokenHandler = new JwtSecurityTokenHandler(); var authToken = tokenHandler.ReadJwtToken(authTokenString); var authClaim = authToken.Claims.FirstOrDefault(a => a.Type == "authorization"); if (authClaim is null) return null; var authObject = JObject.Parse(authClaim.Value); if (authObject["permissions"] is not JArray permissionsArray) return null; foreach (var permissionObj in permissionsArray) { var accessibleResource = permissionObj["rsname"]?.Value<string>(); if (string.IsNullOrEmpty(accessibleResource)) continue; var allowedScopes = new List<string?>(); var scopesObj = permissionObj["scopes"]; if (scopesObj is JArray scopesArray) { allowedScopes.AddRange(scopesArray.Select(s => s.Value<string>()) .Where(val => !string.IsNullOrEmpty(val))); } result.Add(new Claim($"res:{accessibleResource}", string.Join(",", allowedScopes))); } return result; } catch { return null; } }}

然后,在OnTokenValidated事件中,调用IPermissionService,并将获得的Claims附加到ClaimsPrincipal上:

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { // 其它配置省略 options.Events = new JwtBearerEvents { OnTokenValidated = async context => { if (context is { Principal.Identity: ClaimsIdentity claimsIdentity } and { SecurityToken: JsonWebToken jwt }) { var bearerToken = jwt.EncodedToken; var permissionService = context.HttpContext.RequestServices.GetService<IPermissionService>(); if (permissionService is not null) { var permissionClaims = await permissionService.ReadPermissionClaimsAsync(bearerToken, "weatherapiclient", "/realms/aspnetcoreauthz/protocol/openid-connect/token"); var permissionClaimsList = permissionClaims?.ToList(); permissionClaimsList?.ForEach(claim => claimsIdentity.AddClaim(claim)); } } } }; }); // 不要忘记注册相关的Servicebuilder.Services.AddSingleton<IPermissionService, PermissionService>();builder.Services.AddHttpClient("JwtTokenClient", client =>{ client.BaseAddress = new Uri("http://localhost:5600/"); });

 然后实现ProtectedResourceAttribute:

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]public class ProtectedResourceAttribute(string resourceName, params string[] allowedScopes) : Attribute, IAsyncAuthorizationFilter{ public string ResourceName { get; } = resourceName; public string[] AllowedScopes { get; } = allowedScopes; public Task OnAuthorizationAsync(AuthorizationFilterContext context) { var user = context.HttpContext.User; if (user is { Identity.IsAuthenticated: false }) { // 若未认证,返回403 context.Result = new ForbidResult(); } else { // 从user claims中获得与当前资源名称相同的permission claim var permissionClaim = user.Claims.FirstOrDefault(c => c.Type == $"res:{ResourceName}"); if (permissionClaim is not null) { // 若存在permission claim if (AllowedScopes.Length == 0) { // 并且在当前资源上并未定义所支持的scope,则说明任何scope都可以接受,直接返回 return Task.CompletedTask; } // 否则,检查permission claim中是否有包含当前资源所支持的scope var permittedScopes = permissionClaim.Value.Split(','); // 如果不存在,则返回403 if (permittedScopes.Length == 0 || !AllowedScopes.Intersect(permittedScopes).Any()) { context.Result = new ForbidResult(); } } else { // 如果user claims中不存在与当前资源对应的permission claim,则返回403 context.Result = new ForbidResult(); } } return Task.CompletedTask; }}

最后,在API上使用ProtectedResourceAttribute:

[ProtectedResource("weather-api", "weather.read")][HttpGet]public IEnumerable<WeatherForecast> Get(){ return Enumerable.Range(1, 5).Select(index => new WeatherForecast { Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), TemperatureC = Random.Shared.Next(-20, 55), Summary = Summaries[Random.Shared.Next(Summaries.Length)] }) .ToArray();} [ProtectedResource("weather-api", "weather.update")][HttpPost]public IActionResult Update(){ return Ok("Succeeded");}

现在来简单测试一下,就测一个case:nobody用户应该对weather.read和weather.update都不具有访问权限:

首先获得access token:

119825-20240422222022079-615387315.png

然后使用该token,调用Get请求,返回403 Forbidden:

119825-20240422222158500-302319704.png

然后调用Post请求,同样403:

119825-20240422222257467-484986354.png

现在Keycloak中,将nobody用户加入到Users组:

119825-20240422222430891-1023405915.png

然后重新生成Bearer token,再次调用Get API,发现现在可以正常访问了:

119825-20240422222537030-503209255.png

但是Post API仍然返回403:

119825-20240422222620460-1427102126.png

这是因为,Post API需要在weather-api这个Resource上具有weather.update Scope(操作),然而,在weather-modify-permission的定义中,weather.update Scope所依赖的策略为require-admin-policy,该策略要求用户具有administrator角色,但nobody只在users用户组中,它并不在已被赋予administrator角色的administrators用户组中。于是,就当前这个租户而言,在整个权限系统的模型设计中,我们已经实现了无需修改代码的灵活的授权管理,而且这种模式可以被其它租户重用。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK