在上文《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,也还是会存在性能问题,所以,在真实场景中,应该考虑将获得的授权信息缓存起来,但这又带来新的问题:何时应该刷新缓存。不过现在我们暂时不考虑这些。

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

我们可以设计一个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));
}
}
}
};
}); // 不要忘记注册相关的Service
builder.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:

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

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

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

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

但是Post API仍然返回403:

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

ASP.NET Core Web API下基于Keycloak的多租户用户授权的实现的更多相关文章

  1. 重温.NET下Assembly的加载过程 ASP.NET Core Web API下事件驱动型架构的实现(三):基于RabbitMQ的事件总线

    重温.NET下Assembly的加载过程   最近在工作中牵涉到了.NET下的一个古老的问题:Assembly的加载过程.虽然网上有很多文章介绍这部分内容,很多文章也是很久以前就已经出现了,但阅读之后 ...

  2. ASP.NET Core Web API下事件驱动型架构的实现(三):基于RabbitMQ的事件总线

    在上文中,我们讨论了事件处理器中对象生命周期的问题,在进入新的讨论之前,首先让我们总结一下,我们已经实现了哪些内容.下面的类图描述了我们已经实现的组件及其之间的关系,貌似系统已经变得越来越复杂了. 其 ...

  3. ASP.NET Core Web API下事件驱动型架构的实现(一):一个简单的实现

    很长一段时间以来,我都在思考如何在ASP.NET Core的框架下,实现一套完整的事件驱动型架构.这个问题看上去有点大,其实主要目标是为了实现一个基于ASP.NET Core的微服务,它能够非常简单地 ...

  4. ASP.NET Core Web API下事件驱动型架构的实现(二):事件处理器中对象生命周期的管理

    在上文中,我介绍了事件驱动型架构的一种简单的实现,并演示了一个完整的事件派发.订阅和处理的流程.这种实现太简单了,百十行代码就展示了一个基本工作原理.然而,要将这样的解决方案运用到实际生产环境,还有很 ...

  5. Azure AD(二)调用受Microsoft 标识平台保护的 ASP.NET Core Web API 下

    一,引言 上一节讲到如何在我们的项目中集成Azure AD 保护我们的API资源,以及在项目中集成Swagger,并且如何把Swagger作为一个客户端进行认证和授权去访问我们的WebApi资源的?本 ...

  6. ASP.NET Core Web API下事件驱动型架构的实现(四):CQRS架构中聚合与聚合根的实现

    在前面两篇文章中,我详细介绍了基本事件系统的实现,包括事件派发和订阅.通过事件处理器执行上下文来解决对象生命周期问题,以及一个基于RabbitMQ的事件总线的实现.接下来对于事件驱动型架构的讨论,就需 ...

  7. ASP.NET Core Web API下事件驱动型架构的实现

    mvp 原创博文:http://www.cnblogs.com/daxnet/p/8082694.html

  8. 在Mac下创建ASP.NET Core Web API

    在Mac下创建ASP.NET Core Web API 这系列文章是参考了.NET Core文档和源码,可能有人要问,直接看官方的英文文档不就可以了吗,为什么还要写这些文章呢? 原因如下: 官方文档涉 ...

  9. Docker容器环境下ASP.NET Core Web API应用程序的调试

    本文主要介绍通过Visual Studio 2015 Tools for Docker – Preview插件,在Docker容器环境下,对ASP.NET Core Web API应用程序进行调试.在 ...

  10. Docker容器环境下ASP.NET Core Web API

    Docker容器环境下ASP.NET Core Web API应用程序的调试 本文主要介绍通过Visual Studio 2015 Tools for Docker – Preview插件,在Dock ...

随机推荐

  1. PlacementList must be sorted by first 8 bits of display_id 问题

    问题暂未解决 [37484:0811/103448.115:ERROR:display_layout.cc(551)] PlacementList must be sorted by first 8 ...

  2. centos 添加 公钥,root不用输入密码 ssh-keygen

    centos 添加 公钥,root不用输入密码 ssh-keygen -t rsa -C "yourEmail" 一通回车后,生成 C:\Users\Reciter/.ssh/id ...

  3. 基于ads1292的心率呼吸信号检测解决方案开发阶段总结

    前记 在医疗可穿戴领域,ads1292是一个无法绕过去的存在.今年几个项目产品都和这个芯片有关系. 从不了解到熟悉,算是踩了不少坑吧.对每次的项目进行复盘,是我这些年养成的最好的习惯了. ads129 ...

  4. html添加css样式的两种方法

      html添加css样式有三种方法,分别为行内式(使用style属性,在特定的HTML标签内使用).内嵌式(style标签把css代码放在特定页面的head部分中).外联式(使用link标签,将外部 ...

  5. libwebsockets支持外部eventloop变更

    早些年还在使用2.4+版本,现在最新版已经到4.1+,centos 7也使用3.+版本.对于使用外部eventloop相关的接口发生了大的变更.libev也应为早早对iouring支持,4+版本亲睐l ...

  6. 25_H.264编码

    本文主要介绍一种非常流行的视频编码:H.264. 计算一下:10秒钟1080p(1920x1080).30fps的YUV420P原始视频,需要占用多大的存储空间? (10 * 30) * (1920 ...

  7. Ubuntu 的源相关介绍(最近在配gstreamer的时候,紧急补充的知识)

    PS:要转载请注明出处,本人版权所有. PS: 这个只是基于<我自己>的理解, 如果和你的原则及想法相冲突,请谅解,勿喷. 前置说明   本文作为本人csdn blog的主站的备份.(Bl ...

  8. 引领文旅新体验!3DCAT实时云渲染助力打造“永不落幕”的湾区文采会元宇宙

    2022年11月25日至27日,2022年粤港澳大湾区公共文化和旅游产品(东莞)采购会(简称"湾区文采会")在广东省东莞市文化馆举行. 文采会期间,文采会元宇宙线上虚拟展厅全新亮相 ...

  9. 记录--CSS 如何实现羽化效果?

    这里给大家分享我在网上总结出来的一些知识,希望对大家有所帮助 最近碰到这样一个问题,在一张封面上直接显示书名,可能会存在书名看不太清楚的情况(容易受到背景干扰),如下 为了解决这个问题,设计师提了一个 ...

  10. #后缀数组,单调队列#洛谷 2852 [USACO06DEC]Milk Patterns G

    题目 给定一个长度为\(n\)的字符串,求出现至少\(k\)次的最长子串长度 分析 由于后缀排序后的LCP才是最长的,既然要求至少\(k\)次, 实际上也就是维护长度为\(k\)的height数组最小 ...