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

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

我们可以设计一个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用户组中。于是,就当前这个租户而言,在整个权限系统的模型设计中,我们已经实现了无需修改代码的灵活的授权管理,而且这种模式可以被其它租户重用。

 

posted @ 2024-04-22 22:33  dax.net  阅读(522)  评论(4编辑  收藏  举报