在開始之前,老周先祝各個次元的夥伴們新春快樂、生活愉快、萬事如意。
在上一篇水文中,老周介紹了角色授權的一些內容。本篇我們們來聊一個比較實際的問題——把用於授權的角色名稱放到外部配置,不要硬編碼,以方便後期修改。
由於要配置的東西比較簡單,我們們並不需要存在資料庫,而是用 JSON 檔案配置就可以了。將授權策略和角色列表關聯起來。比如,老周這裡有個 authorRoles.json 檔案,它的內容如下:
{ "cust1": { "roles": ["admin", "supperuser"] }, "cust2": { "roles": ["user", "web", "logger"] } }
其中,cust1、cust2 是策略名稱,所以上面就配置了兩個授權策略。每個策略下有個 roles 屬性,它的值是陣列,這個陣列用來指定此策略下允許的角色列表。故:cust1 策略下允許admin、supperuser兩種角色的使用者訪問;cust2 策略下允許 user、web、logger 角色的使用者訪問。
在 WebApplicationBuilder 的配置中,我們們可以單獨載入 authorRoles.json 檔案中的內容,然後根據配置檔案內容動態新增授權策略。
1、先把配置檔案中的內容讀出來。
// 配置檔名 const string roleConfigFile = "authorRoles.json"; // 單獨載入配置 IConfigurationBuilder configBuilder = new ConfigurationBuilder(); // 新增配置源,此處是JSON檔案 configBuilder.AddJsonFile(roleConfigFile); // 生成配置樹物件 IConfiguration myconfig = configBuilder.Build();
此時,myconfig 變數中就包含了 authorRoles.json 檔案的內容了。
2、動態新增授權策略。
var builder = WebApplication.CreateBuilder(args); // 根據配置檔案的內容來設定授權策略 builder.Services.AddAuthorization(opt => { foreach (IConfigurationSection cc in myconfig.GetChildren()) { var policyName = cc.Key; opt.AddPolicy(policyName, pbd => { // 獲取子節點 var roles = cc.GetSection("roles"); // 取出角色名稱列表 string[]? roleslist = roles.Get<string[]>(); if (roleslist is not null) { // 新增角色 pbd.RequireRole(roleslist); // 關聯驗證架構 pbd.AddAuthenticationSchemes(CustAuthenticationSchemeDefault.SchemeName); } }); } });
在讀配置的時候,GetChildren 方法會返回兩個節點:cust1 和 cust2。然後用 GetSection 再讀下一層,即 roles。接著用 Get 方法就能把字串陣列型別的角色列表讀出來了。
這裡關聯了一個驗證架構(或叫驗證方案),這個驗證架構是老周自己寫的,主要是為了簡單。老周這個示例是用 Web API 的形式呈現的,所以,不用 Cookie,而是用一個簡單的 Token,呼叫時附加在 URL 的查詢字串中傳遞給伺服器。
如果你的專案的 Token 只是在自己專案中用,不用遵守通用標準,你完全可以自己生成。生成方式你看著辦,比如用隨機位元組什麼的都行。在 Token 中不要帶密碼等安全資訊。畢竟,Token 這種東西你說安全,也不見得多安全,別人只要拿到你的 Token 就可以代替你訪問伺服器。當然你會說,我把 Token 加密再傳輸。其實別人盜你的 Token 根本不需要知道明文,人家只要按照正確的傳遞方式(如 Query String、Cookies 等),把你加密後的 Token 放上去,也可以冒用你身份的。所以,很多開放平臺都會分配給你 App Key 和金鑰,並且強調你的金鑰必須保管好,不能讓別人知道。
下面看看老周自己寫的驗證。
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using System.Threading.Tasks; public class CustAuthenticationHandler : IAuthenticationHandler { #pragma warning disable CS8618 private HttpContext HttpContext { set; get; } private AuthenticationScheme Scheme { get; set; } #pragma warning restore CS8618 public Task<AuthenticateResult> AuthenticateAsync() { // 獲取配置的Token IConfiguration appconfig = HttpContext.RequestServices.GetRequiredService<IConfiguration>(); string[]? tks = appconfig.GetSection("custAuthen:tokens").Get<string[]>(); if (tks != null && tks.Length > 0 && HttpContext.Request.Query.TryGetValue("token", out var reqToken)) { // 看看有沒有效 if (!tks.Any(t => t == reqToken)) { return Task.FromResult(AuthenticateResult.Fail("未提供有效的Token")); } // 成功 var tickit = new AuthenticationTicket(HttpContext.User, Scheme.Name); return Task.FromResult(AuthenticateResult.Success(tickit)); } return Task.FromResult(AuthenticateResult.NoResult()); } public Task ChallengeAsync(AuthenticationProperties? properties) { HttpContext.Response.StatusCode = StatusCodes.Status401Unauthorized; return Task.CompletedTask; } public Task ForbidAsync(AuthenticationProperties? properties) { HttpContext.Response.StatusCode = StatusCodes.Status403Forbidden; return Task.CompletedTask; } public Task InitializeAsync(AuthenticationScheme scheme, HttpContext context) { if (context == null) throw new ArgumentNullException("context"); HttpContext = context; Scheme = scheme; // 看看驗證架構是否一致 if (!scheme.Name.Equals(CustAuthenticationSchemeDefault.SchemeName, StringComparison.OrdinalIgnoreCase)) { throw new Exception("驗證架構不一致"); } return Task.CompletedTask; } } public static class CustAuthenticationSchemeDefault { public readonly static string SchemeName = "CustToken"; }
這裡老周沒有用什麼高階演算法生成 Token,而是四個字串(字串也是隨便輸入的),表示四個 Token,只要有一個匹配就算是驗證成功了。這些 Token 全寫在 appsettings.json 裡面。
{ "Logging": { …… } }, "AllowedHosts": "*", "custAuthen": { "tokens": [ "662CV08Y4GHXOP3", "BI4C68DLO2HOS0D", "7GSEJ0J8F0246K5", "O9FG6V974KWO9G8" ] } }
所以,訪問這四個 Token 的配置路徑就是 custAuthen:tokens。
在實現 ForbidAsync 和 ChallengeAsync 方法時,不要呼叫 HttpContext 的擴充套件方法 ForbidAsync、ChallengeAsync,因為這些擴充套件方法內部是透過呼叫 AuthenticationService 類的 ForbidAsync、ChallengeAsync 方法實現的。最終又會回過頭來呼叫 CustAuthenticationHandler 類的 ChallengeAsync、ForbidAsync 方法。這等於轉了一圈,到頭來自己呼叫自己,易造成無限遞迴。所以這裡我只設定一個 Status Code 就好了。
在服務容器上註冊一下自定義的驗證處理方案。
var builder = WebApplication.CreateBuilder(args); // 新增驗證功能 builder.Services.AddAuthentication(opt => { // 註冊驗證架構(方案) opt.AddScheme<CustAuthenticationHandler>(CustAuthenticationSchemeDefault.SchemeName, displayName: null); });
所以,整個應用程式的初始化程式碼就是這樣。
// 配置檔名 const string roleConfigFile = "authorRoles.json"; // 單獨載入配置 IConfigurationBuilder configBuilder = new ConfigurationBuilder(); // 新增配置源,此處是JSON檔案 configBuilder.AddJsonFile(roleConfigFile); // 生成配置樹物件 IConfiguration myconfig = configBuilder.Build(); var builder = WebApplication.CreateBuilder(args); // 新增驗證功能 builder.Services.AddAuthentication(opt => { // 註冊驗證架構(方案) opt.AddScheme<CustAuthenticationHandler>(CustAuthenticationSchemeDefault.SchemeName, displayName: null); }); // 根據配置檔案的內容來設定授權策略 builder.Services.AddAuthorization(opt => { foreach (IConfigurationSection cc in myconfig.GetChildren()) { var policyName = cc.Key; opt.AddPolicy(policyName, pbd => { // 獲取子節點 var roles = cc.GetSection("roles"); // 取出角色名稱列表 string[]? roleslist = roles.Get<string[]>(); if (roleslist is not null) { // 新增角色 pbd.RequireRole(roleslist); // 關聯驗證架構 pbd.AddAuthenticationSchemes(CustAuthenticationSchemeDefault.SchemeName); } }); } }); builder.Services.AddControllers(); var app = builder.Build();
之後,是配置中介軟體管道。為了簡單演示,老周沒有寫用於身份驗證的 Web API,而是直接透過 URL 引數來提供當前訪問者的角色。實際開發中不能這樣做,而應該從資料庫中根據使用者查詢出使用者的角色。但此處是為了演示的簡單,也是為了延長鍵盤壽命,就不建資料庫了,不然完成這個示例需要一坤年的時間。
不過,我們們知道,授權是用 Claim 來收集資訊的,所以,要在授權執行之前收集好資訊。我這裡用一箇中介軟體,在授權和呼叫 API 之前執行。
app.Use((context, next) => { var val = context.Request.Query["role"]; string? role = val.FirstOrDefault(); if(role != null) { ClaimsIdentity id = new(new[] { new Claim(ClaimTypes.Role, role) }/*, CustAuthenticationSchemeDefault.SchemeName*/); ClaimsPrincipal p = new(id); context.User = p; } return next(); });
由於 WebApplication 物件預設幫我們呼叫了 UseRouting 和 UseEndpoints 方法。Web API 在訪問時路由的是 MVC 控制器,直接走 End point 路線,會導致我們們上面的 Use 方法設定使用者角色的中介軟體不執行。所以要重新呼叫 UseRouting 和 UseAuthorization 方法。
app.UseRouting();
app.UseAuthorization();
app.MapControllers();
用一個名為 Demo 的控制器來做驗證。
[Route("api/[controller]")] [ApiController] public class DemoController : ControllerBase { [HttpGet("backup")] [Authorize("cust1")] public string Backup() => "備份完成"; [HttpGet("hello/{name}")] [Authorize("cust2")] public string Hello(string name) { return $"你好,{name}"; } }
cust1、cust2 正是我們們前面配置裡的節點名稱,即策略名稱。例如,呼叫 Hello 方法使用 cust2 授權策略,它配置的角色為 user、web、loggor。
在呼叫這些 API 時,URL需要攜帶兩個引數:
1、role:使用者角色;
2、token:用於驗證。
用 http-repl 工具先測試 demo/backup 方法的呼叫。
get /api/demo/backup?role=web&token=O9FG6V974KWO9G8
上述呼叫提供的使用者角色為 web,根據前面的配置,web 角色應使用 cust2 策略。但 Backup 方法應用的授權策略是 cust1,因此無權訪問,返回 403。
我們們改一下,使用角色為 admin 的使用者。
get /api/demo/backup?role=admin&token=O9FG6V974KWO9G8
此時,授權透過,返回 200。
訪問 Hello 方法也一樣,授權策略是 cust2,允許的角色是 user、web、logger。
get /api/demo/hello/小紅?role=web&token=BI4C68DLO2HOS0D
授權透過,返回 200 狀態碼。
用配置檔案來設定角色,算是一種簡單方案。如果授權需要的角色有變化,只要修改配置檔案中的角列表就行。當然,像 cust1、cust2 等策略名稱要事先規劃好,策略名稱不隨便改。
有大夥伴會說,乾脆連MVC控制器或其方法上應用哪個授權策略也轉到配置檔案中,豈不美哉!好是好,但不好弄。可以要自己寫個授權的 Filter,主要問題是自己寫有時候沒有官方內建的程式碼嚴謹,容易出“八阿哥”。
所以,綜合複雜性與靈活性的平衡,在不擴充套件現有介面的前提下,我們們這個示例是比較好的,至少,我們們可以在配置檔案中修改角色列表。