ASP.NET Core Web API下基于Keycloak的多租户用户授权的实现
在上文《Keycloak中授权的实现》中,以一个实际案例介绍了Keycloak中用户授权的设置方法。现在回顾一下这个案例:
- 服务供应商(Service Provider)发布/WeatherForecast API供外部访问
- 在企业应用(Client)里有三个用户:super,daxnet,nobody
- 在企业应用里有两个用户组:administrators,users
- 在企业应用里定义了两个用户角色:administrator,regular user
- super用户同时属于users和administrators组,daxnet属于users组,nobody不属于任何组
- administrators组被赋予了administrator角色,users组被赋予了regular user角色
- 对于/WeatherForecast API,它支持两种操作:GET /WeatherForecast,用以返回天气预报数据;PATCH /WeatherForecast,用以调整天气预报数据
- 拥有administrator角色的用户/组,具有PATCH操作的权限;拥有regular user角色但没有administrator角色的用户/组,具有GET操作的权限;没有任何角色的用户,就没有访问/WeatherForecast API的权限
于是,基于这个需求,我们在Keycloak的一个Client下,进行了如下与授权有关的配置:
- 创建了weather-api的Resource
- 创建了weather.read、weather.update两个Scope
- weather-api具有weather.read、weather.update两个授权Scope
- 新建了三个用户:super, daxnet, nobody
- 新建了两个用户组:administrators,users
- 新建了两个角色:administrator,regular user
- super用户同时属于administrators和users两个用户组;daxnet用户仅属于users组,而nobody不属于任何组
- administrators用户组被赋予了administrator角色,users组被赋予了regular user角色
- 定义了两个基于角色的授权策略:
- require-admin-policy:期望资源访问方已被赋予administrator角色
- require-registered-user:期望资源访问方已被赋予regular user角色
- 定义了两个权限,表示对什么样的授权策略允许访问什么样的资源:
- weather-view-permission:对于require-registered-user策略,具有weather.read操作的权限
- weather-modify-permission:对于require-admin-policy策略,具有weather.update操作的权限
接下来的一步,就是在应用程序中实现一套机制,通过这套机制来控制用户(资源访问方)对API(资源)的访问。
思考:ASP.NET Core标准授权模型能满足需求吗?
ASP.NET Core已经提供了一套易学易用的授权组件,包括AuthorizeAttribute
、IAuthorizationHandler
、IAuthorizationRequirement
、IAuthorizationFilter
等,使用这些组件,可以方便地实现基于角色(Role)和基于策略(Policy)的授权机制。在使用AuthorizeAttribute
特性来完成授权时,可以指定被赋予哪些角色的用户可以获得授权,也可以指定一个策略名称,只要是满足该策略下各条件的用户,就可以获得授权。
如果是基于角色,首先需要在AuthorizeAttribute上指定Roles属性,然后在配置JwtBearer Authentication的时候,在TokenValidationParameter
上,设置RoleClaimType
,这样一来,框架就会从认证用户的access token中获得由RoleClaimType指定的Claim中所包含的角色信息,然后判断它是否已在AuthorizationAttribute.Roles
属性上指定,从而进一步判断该用户是否可以获得授权。
如果是基于策略,那么就需要自己实现IAuthorizationHandler
和IAuthorizationRequirement
接口,在这些接口的实现中,基于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用户组中。于是,就当前这个租户而言,在整个权限系统的模型设计中,我们已经实现了无需修改代码的灵活的授权管理,而且这种模式可以被其它租户重用。