在上文《
Keycloak中授权的实现
》中,以一个实际案例介绍了Keycloak中用户授权的设置方法。现在回顾一下这个案例:
于是,基于这个需求,我们在Keycloak的一个Client下,进行了如下与授权有关的配置:
接下来的一步,就是在应用程序中实现一套机制,通过这套机制来控制用户(资源访问方)对API(资源)的访问。
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来实现。
仍然以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进行比较,即可判定是否应该授予相关权限。
上面已经分析得比较彻底了,现在直接上代码。首先就是定义并实现
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用户组中。于是,就当前这个租户而言,在整个权限系统的模型设计中,我们已经实现了无需修改代码的灵活的授权管理,而且这种模式可以被其它租户重用。