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