ASP.NET Core Web API下基於Keycloak的多租戶用戶授權的實現

来源:https://www.cnblogs.com/daxnet/p/18148133
-Advertisement-
Play Games

在上文《Keycloak中授權的實現》中,以一個實際案例介紹了Keycloak中用戶授權的設置方法。現在回顧一下這個案例: 服務供應商(Service Provider)發佈/WeatherForecast API供外部訪問 在企業應用(Client)里有三個用戶:super,daxnet,nobo ...


在上文《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用戶組中。於是,就當前這個租戶而言,在整個許可權系統的模型設計中,我們已經實現了無需修改代碼的靈活的授權管理,而且這種模式可以被其它租戶重用。

 


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • Python的下載安裝和環境搭建,以及python的IDE工具PyCharm搭建及常用配置教程,可以讓新人快速上手python的使用。 ...
  • 以下是個人總結的Java常用的十大開源工具類庫,根據具體需求和項目,可以選擇合適的工具類庫來提高開發效率。1. Apache Commons:Apache Commons是一個開源的工具類庫,提供了大量常用的工具類和組件,如StringUtils、IOUtils、CollectionUtils等。2 ...
  • 1.為什麼使用索引 索引是存儲引擎用於快速找到數據記錄的一種數據結構,就好比一本書的目錄部分,通過目錄中找到對應文章的頁碼,便可快速定位到需要的文章。MySQL中的索引也是一樣的道理,進行數據查找時,首先查看查詢條件是否命中某條索引,符合則通過索引查找相關數據,如果不符合則需要全表掃描,即需要一條條 ...
  • 1.前言: 第一次作業難度較大,從無到有的設計,涉及到的主要類有Paper,Question,AnswerPaper,Main,主要題目方向為字元串判斷與字元串處理(提取有效信息),判斷對錯算總分,配合一些Java自帶的數據結構如ArrayList即可快速解決問題,第一次作業是後面作業的基礎,需自行 ...
  • oop三次pta總結 前言 在這學期的java課程學習當中,我已經體會到了java這門語言的重要性了,就從這三次pta題目的設計與思路來說吧(還真的有點小難),特別是每一期pta的最後一題...... 《答題判題程式》,這道題目依次迭代,難度依次上升(如果沒有設計好,基本是寄了),題目不多看幾遍細節 ...
  • 前言 整理這個官方翻譯的系列,原因是網上大部分的 tomcat 版本比較舊,此版本為 v11 最新的版本。 開源項目 從零手寫實現 tomcat minicat 別稱【嗅虎】心有猛虎,輕嗅薔薇。 系列文章 web server apache tomcat11-01-官方文檔入門介紹 web serv ...
  • Spring Cache 是 Spring 提供的的緩存解決方案,它並非是一個具體的緩存實現,而是和 JSR107 類似的一套緩存規範,基於註解並與 Spring 的無縫集成。本文主要介紹其基本概念及簡單使用。 1、簡介 1.1、Spring Cache 概述 Spring Cache 是 Spri ...
  • AI大模型的相關的一些基礎知識,一些背景和基礎知識。 多模型強應用AI 2.0時代應用開發者的機會。 0 大綱 AI產業的拆解和常見名詞 應用級開發者,在目前這樣一個大背景下的一個職業上面的一些機會 實戰部分的,做這個agent,即所謂智能體的這麼一個虛擬項目,項目需求分析、技術選型等 1 大語言模 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...