驗證和授權是兩個獨立但又存在聯繫的過程。驗證是檢查訪問者的合法性,授權是校驗訪問者有沒有權限查看資源。它們之間的聯繫——先驗證再授權。
貫穿這兩過程的是叫 Claim 的東東,可以叫它“聲明”。沒什麼神祕的,就是由兩個字符串組成的對象,一曰 type,一曰 value。type 和 value 有着映射關係,類似字典結構的 key 和 value。Claim 用來收集用戶相關信息,比如
UserName = admin Age = 105 Birth = 1990,4,12 Address = 火星街130號
ClaimTypes 靜態類定義了一些標準的 type 值。如用戶名 Name,國家 Country,手機號 MobilePhone,家庭電話 HomePhone 等等。你也可以自己定義一個,反正就是個字符串。
另外,還有一個 ClaimValueTypes 輔助類,也是一組字符串,用於描述 value 的類型。如 Integer、HexBinary、String、DnsName 等。其實所有 value 都是用字符串表示的,ValueTypes 只是基於內容本身的含義而定義的分類,在查找和分析 Claim 時有輔助作用。比如,值是 “00:15:30”,可以認爲其 ValueType 是 Time,這樣在分析這些數據時可以方便一些。
一般,代碼會在 Sign in 前收集這些用戶信息。作用是爲後面的授權做準備。授權時會對這些用戶信息進行綜合評估,以決定該用戶是否有能力訪問某些資源。
回到本文主題。本文的重點是說授權,老周的想法是根據用戶的等級來授權。比如,用戶A的等級是2,如果某個URL要求4級以上的用戶才能訪問,那麼A就無權訪問了。
爲了簡單,老周就不建數據庫這麼複雜的東西了,直接寫個類就好了。
public class User { public string? UserName { get; set; } public string? Password { get; set; } /// <summary> /// 用戶等級,1-5 /// </summary> public int Level { get; set; } = 1; }
上面類中,Level 屬性表示的是用戶等級。然後,用下面的代碼來產生一些用戶數據。
public static class UserDatas { internal static readonly IEnumerable<User> UserList = new User[] { new(){UserName="admin", Password="123456", Level=5}, new(){UserName="kitty", Password="112211", Level=3}, new(){UserName="bob",Password="215215", Level=2}, new(){UserName="billy", Password="886600", Level=1} }; // 獲取所有用戶 public static IEnumerable<User> GetUsers() => UserList; // 根據用戶名和密碼校對後返回的用戶實體 public static User? CheckUser(string username, string passwd) { return UserList.FirstOrDefault(u => u.UserName!.Equals(username, StringComparison.OrdinalIgnoreCase) && u.Password == passwd); } }
這樣的功能,對於咱們今天要說的內容,已經夠用了。
關於驗證,這裏不是重點。所以老周用最簡單的方案——Cookie。
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(opt => { opt.LoginPath = "/UserLog"; opt.LogoutPath = "/Logout"; opt.AccessDeniedPath = "/Denied"; opt.Cookie.Name = "ck_auth_ent"; opt.ReturnUrlParameter = "backUrl"; });
這個驗證方案是結合 Session 和 Cookie 來完成的,也是Web身份驗證的經典方案了。上述代碼中我配置了一些選項:
LoginPath——當 SessionID 和 Cookie 驗證不成功時,自動轉到些路徑,要求用戶登錄。
LogoutPath——退出登錄(註銷)時的路徑。
AccessDeniedPath——訪問被拒絕後轉到的路徑。
ReturnUrlParameter——回調URL,就是驗證失敗後會轉到登錄URL,然後會在URL參數中加一個回調URL。這個選項就是配置這個參數的名稱的。比如這裏我配置爲backUrl。假如我要訪問/home,但是,驗證失敗,跳轉到 /UserLog 登錄,這時候會在URL後面加上 /UserLog?backUrl=/home。如果登錄成功且驗證也成功了,就會跳轉回 backUrl指定的路徑(/home)。
這裏要注意的是,我們不能把要求輸入用戶名和密碼作爲驗證過程。驗證由內置的 CookieAuthenticationHandler 類去處理,它只驗證 Session 和 Cookie 中的數據是否匹配,而不是檢查用戶名/密碼對不對。你想想,如果把檢查用戶名和密碼作爲驗證過程,那豈不是每次都要讓用戶去輸入一次?說不定每訪問一個URL都要驗證一次的,那用戶不累死?所以,輸入用戶名/密碼登錄只在 LoginPath 選項中配置,只在必要時輸入一次,然後配合 session 和 cookie 把狀態記錄下來,下次再訪問,只驗證此狀態即可,不用再輸入了。
LogoutPath 和 AccessDeniedPath 我就不弄太複雜了,直接這樣就完事。
app.MapGet("/Denied", () => "訪問被拒絕"); app.MapGet("/Logout", async (HttpContext context) => { await context.SignOutAsync(); });
對於 LoginPath,我用一個 Razor Pages 來處理。
@page @using MyApp @using Microsoft.AspNetCore.Authentication @using Microsoft.AspNetCore.Authentication.Cookies @using System.Security.Claims @addTagHelper *,Microsoft.AspNetCore.Mvc.TagHelpers <form method="post"> <style> label{ display:inline-block; min-width:100px; } </style> <div> <label for="userName">用戶名:</label> <input type="text" name="userName" /> </div> <div> <label for="passWord">密碼:</label> <input type="password" name="passWord" /> </div> <div> <button type="submit">登入</button> </div> </form> @functions{ //[IgnoreAntiforgeryToken] public async void OnPost(string userName, string passWord) { var u = UserDatas.CheckUser(userName, passWord); if(u != null) { Claim[] cs = new Claim[] { new Claim(ClaimTypes.Name, u.UserName!), new Claim("level", u.Level.ToString()) //注意這裏,收集重要情報 }; ClaimsIdentity id = new(cs, CookieAuthenticationDefaults.AuthenticationScheme); ClaimsPrincipal p = new(id); await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, p); //HttpContext.Response.Redirect("/"); } } }
其他的各位可以不關注,重點是 OnPost 方法,首先用剛纔寫的 UserDatas.CheckUser 靜態方法來驗證用戶名和密碼(這個是要我們自己寫代碼來完成的,CookieAuthenticationHandler 可不負責這個)。用戶名和密碼正確後,咱們就要收集信息了。收集啥呢?這個要根據你稍後在授權時要用到什麼來決定的。就拿今天的主題來講,我們需要知道用戶等級,所以要收集 Level 屬性的值。這裏 ClaimType 我直接用“level”,Value 就是 Level 屬性的值。
收集完用戶信息後,要彙總到 ClaimsPrincipal 對象中,隨後調用 HttpContext.SignInAsync 擴展方法,會觸發 CookieAuthenticationHandler 去保存狀態,因爲它實現了 IAuthenticationSignInHandler 接口,從而帶有 SignInAsync 方法。
var ticket = new AuthenticationTicket(signInContext.Principal!, signInContext.Properties, signInContext.Scheme.Name); // 保存 Session if (Options.SessionStore != null) { if (_sessionKey != null) { // Renew the ticket in cases of multiple requests see: https://github.com/dotnet/aspnetcore/issues/22135 await Options.SessionStore.RenewAsync(_sessionKey, ticket, Context, Context.RequestAborted); } else { _sessionKey = await Options.SessionStore.StoreAsync(ticket, Context, Context.RequestAborted); } var principal = new ClaimsPrincipal( new ClaimsIdentity( new[] { new Claim(SessionIdClaim, _sessionKey, ClaimValueTypes.String, Options.ClaimsIssuer) }, Options.ClaimsIssuer)); ticket = new AuthenticationTicket(principal, null, Scheme.Name); } // 生成加密後的 Cookie 值 var cookieValue = Options.TicketDataFormat.Protect(ticket, GetTlsTokenBinding()); // 追加 Cookie 到響應消息中 Options.CookieManager.AppendResponseCookie( Context, Options.Cookie.Name!, cookieValue, signInContext.CookieOptions); ……
----------------------------------------------------------------------------------------
好了,上面的都是周邊工作,下面我們來幹正事。
授權大體上分爲兩種模式:
1、基於角色授權。即“你是誰就給你相應的權限”。你是狼人嗎?你是預言家嗎?你是女巫嗎?你是好人嗎?是狼人就賦予你殺人的權限。
2、基於策略。老周覺得這個靈活性高一點(純個人看法)。一個策略需要一定數量的約束條件,是否賦予用戶權限就看他能否滿足這些約束條件了。約束實現 IAuthorizationRequirement 接口。這個接口未包含任何成員,因此你可以自由發揮了。
這裏咱們需要的約束條件是用戶等級,所以,咱們實現一個 LevelAuthorizationRequirement。
public class LevelAuthorizationRequirement : IAuthorizationRequirement { public int Level { get; private set; } public LevelAuthorizationRequirement(int lv) { Level = lv; } }
授權處理有兩個接口:
1、IAuthorizationHandler:處理過程,一個授權請求可以執行多個 IAuthorizationHandler。一般用於授權過程中的某個階段(或針對某個約束條件)。一個授權請求可以由多 IAuthorizationHandler 參與處理。
2、IAuthorizationEvaluator:綜合評估是否決定授權。評估一般在各種 IAuthorizationHandler 之後進行收尾工作。所以只執行一次就可以了,用於總結整個授權過程的情況得出最終結論(放權還是不放權)。
ASP.NET Core 內置了 DefaultAuthorizationEvaluator,這是默認實現,如無特殊需求,我們不會重新實現。
public class DefaultAuthorizationEvaluator : IAuthorizationEvaluator { public AuthorizationResult Evaluate(AuthorizationHandlerContext context) => context.HasSucceeded ? AuthorizationResult.Success() : AuthorizationResult.Failed(context.HasFailed ? AuthorizationFailure.Failed(context.FailureReasons) : AuthorizationFailure.Failed(context.PendingRequirements)); }
所以,咱們的代碼可以選擇實現一個抽象類:AuthorizationHandler<TRequirement>,其中,TRequirement 需要實現 IAuthorizationRequirement 接口。這個抽象類已經滿足咱們的需求了。
public class LevelAuthorizationHandler : AuthorizationHandler<LevelAuthorizationRequirement> { // 策略名稱,寫成常量方便使用 public const string POLICY_NAME = "Level"; protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LevelAuthorizationRequirement requirement) { // 查找聲明 Claim? clm = context.User.Claims.FirstOrDefault(c => c.Type == "level"); if(clm != null) { // 讀出用戶等級 int lv = int.Parse(clm.Value); // 看看用戶等級是否滿足條件 if(lv >= requirement.Level) { // 滿足,標記此階段允許授權 context.Succeed(requirement); } } return Task.CompletedTask; } }
在授權請求啓動時,AuthorizationHandlerContext (上下文)對象會把所有 IAuthorizationRequirement 對象添加到一個哈希表中(HashSet<T>),表示一大串正等着授權處理的約束條件。
當我們調用 Succeed 方法時,會把已滿足要求的 IAuthorizationRequirement 傳遞給方法參數。在 Success 方法內部會從哈希表中刪除此 IAuthorizationRequirement,以表示該條件已滿足了,不必再證。
public virtual void Succeed(IAuthorizationRequirement requirement) { _succeedCalled = true; _pendingRequirements.Remove(requirement); }
記得要在服務容器中註冊,否則咱們寫的 Handler 是不起作用的。
builder.Services.AddSingleton<IAuthorizationHandler, LevelAuthorizationHandler>();
builder.Services.AddAuthorizationBuilder().AddPolicy(LevelAuthorizationHandler.POLICY_NAME, pb => { pb.AddAuthenticationSchemes(CookieAuthenticationDefaults.AuthenticationScheme); pb.AddRequirements(new LevelAuthorizationRequirement(3)); });
策略的名稱我們前面以常量的方式定義了,記得否?
public const string POLICY_NAME = "Level";
AddAuthenticationSchemes 是把此授權策略與一個驗證方案關聯,當進行鑑權時順便做一次驗證。上述代碼我們關聯 Cookie 驗證即可,這個在文章前面已經設置了。AddRequirements 方法添加我們自定義的約束條件,這裏我設置的用戶等級是 3 —— 用戶等級要 >= 3 才允許訪問。
下面寫個 MVC 控制器來檢驗一下是否能正確授權。
public class HomeController : Controller { [HttpGet("/")] [Authorize(Policy = LevelAuthorizationHandler.POLICY_NAME)] public IActionResult Index() { return View(); } }
這裏咱們用基於策略的授權方式,所以[Authorize]特性要指定策略名稱。
好,運行。本來是訪問根目錄 / 的,但由於驗證不通過,自動跳到登錄頁了。
注意URL上的 backUrl 參數:?backUrl=/。本來要訪問 / 的,所以登錄後再跳回 / 。我們選一個用戶等級爲 5 的登錄。
由於用戶等級爲 5,是 >=3 的存在,所以授權通過。
現在,把名爲 ck_auth_ent 的Cookie刪除。
這個 ck_auth_ent 是在代碼中配置的,還記得嗎?
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(opt => { opt.LoginPath = "/UserLog"; opt.LogoutPath = "/Logout"; opt.AccessDeniedPath = "/Denied"; opt.Cookie.Name = "ck_auth_ent"; opt.ReturnUrlParameter = "backUrl"; });
現在咱們找個用戶等級低於 3 的登錄。
登錄後被拒絕訪問。
到此爲止,好像、貌似、似乎已大功告成了。但是,老周又發現問題了:如果我一個控制器內或不同控制器之間有的操作方法要讓用戶等級 3 以上的用戶訪問,有些操作方法只要等級在 2 以上的用戶就可以訪問。這該咋整呢?有大夥伴可以會說了,那就多弄幾個策略,每個策略代表一個等級。
builder.Services.AddAuthorizationBuilder() .AddPolicy("Level3", pb => { pb.AddAuthenticationSchemes(CookieAuthenticationDefaults.AuthenticationScheme); pb.AddRequirements(new LevelAuthorizationRequirement(3)); }) .AddPolicy("Level5", pb => { pb.AddAuthenticationSchemes(CookieAuthenticationDefaults.AuthenticationScheme); pb.AddRequirements(new LevelAuthorizationRequirement(5)); });
是的,這樣確實是可行的。不過不夠動態,要是我弄個策略從 Level1 到 Level10 呢,豈不要寫十個?
官方有個用 Age 生成授權策略的示例讓老周獲得了靈感——是的,咱們就是要動態生成授權策略。需要用到一個接口:IAuthorizationPolicyProvider。這個接口可以根據策略名稱返回授權策略,所以,咱們可以拿它做文章。
public class LevelAuthorizationPolicyProvider : IAuthorizationPolicyProvider { private readonly AuthorizationOptions _options; public LevelAuthorizationPolicyProvider(IOptions<AuthorizationOptions> opt) { _options = opt.Value; } public Task<AuthorizationPolicy> GetDefaultPolicyAsync() { return Task.FromResult(_options.DefaultPolicy); } public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() { return Task.FromResult(_options.FallbackPolicy); } public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName) { if(policyName.StartsWith(LevelAuthorizationHandler.POLICY_NAME,StringComparison.OrdinalIgnoreCase)) { // 比如,策略名 Level4,得到等級4 // 提取名稱最後的數字 int prefixLen = LevelAuthorizationHandler.POLICY_NAME.Length; if(int.TryParse(policyName.Substring(prefixLen), out int level)) { // 動態生成策略 AuthorizationPolicyBuilder plcyBd = new AuthorizationPolicyBuilder(); plcyBd.AddAuthenticationSchemes(CookieAuthenticationDefaults.AuthenticationScheme); plcyBd.AddRequirements(new LevelAuthorizationRequirement(level)); // Build 方法生成策略 return Task.FromResult(plcyBd.Build())!; } } // 未處理,交由選項類去返回默認的策略 return Task.FromResult(_options.GetPolicy(policyName)); } }
這樣可以根據給定的策略名稱,生成與用戶等級相關的配置。例如,名稱“Level3”,就是等級3;“Level5”就是等級5。
於是,在配置服務容器時,我們不再需要 AddAuthorizationBuilder 一大段代碼了,直接把 LevelAuthorizationPolicyProvider 註冊一下就行了。
builder.Services.AddSingleton<IAuthorizationHandler, LevelAuthorizationHandler>(); builder.Services.AddTransient<IAuthorizationPolicyProvider, LevelAuthorizationPolicyProvider>(); builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(opt => ……
然後,在MVC控制器上咱們就可以666地玩了。
public class HomeController : Controller { [HttpGet("/")] [Authorize(Policy = $"{LevelAuthorizationHandler.POLICY_NAME}3")] public IActionResult Index() { return View(); } [HttpGet("/music")] [Authorize(Policy = $"{LevelAuthorizationHandler.POLICY_NAME}2")] public IActionResult Foo() => Content("2星級用戶擾民音樂俱樂部"); [HttpGet("/movie")] [Authorize(Policy = $"{LevelAuthorizationHandler.POLICY_NAME}5")] public IActionResult Movies() => Content("5星級鬼畜影院"); }
這樣一來,配置不同等級的授權就方便多了。