上次老周和大夥伴們分享了有關按使用者Level授權的技巧,本文我們們聊聊以使用者角色來授權的事。
按使用者角色授權其實更好弄,畢竟這個功能是內部整合的,多數場景下我們不需要擴充套件,不用自己寫處理程式碼。從功能語義上說,授權分為按角色授權和按策略授權,而從程式碼本質上說,角色權授其實是包含在策略授權內的。怎麼說呢?往下看。
角色授權主要依靠 RolesAuthorizationRequirement 類,來看一下原始碼精彩片段回放。
public class RolesAuthorizationRequirement : AuthorizationHandler<RolesAuthorizationRequirement>, IAuthorizationRequirement { public RolesAuthorizationRequirement(IEnumerable<string> allowedRoles) { …… AllowedRoles = allowedRoles; } public IEnumerable<string> AllowedRoles { get; } protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, RolesAuthorizationRequirement requirement) { if (context.User != null) { var found = false; foreach (var role in requirement.AllowedRoles) { // 重點在這裡 if (context.User.IsInRole(role)) { found = true; //說明是符合角色要求的 break; } } if (found) { // 滿足要求 context.Succeed(requirement); } } return Task.CompletedTask; } …… }
這個是不是有點熟悉呢?對的,上一篇博文里老周介紹過,實現 IAuthorizationRequirement 介面表示一個用於授權的必備條件(或者叫必備要素),AuthorizationHandler 負責驗證這些必備要素是否滿足要求。上一篇博文中,老周是把實現 IAuthorizationRequirement 介面和重寫抽象類 AuthorizationHandler<TRequirement> 分成兩部分完成,而這裡,RolesAuthorizationRequirement 直接一步到位,兩個一起實現。
好,理論先說到這兒,下面我們們來過一把程式碼癮,後面我們們回過頭來再講。我們們的主題是說授權,不是驗證。當然這兩者通常是一起的,因為授權的前提是要驗證透過。所以為了方便簡單,老周還是選擇內建的 Cookie 驗證方案。不過這一回不搞使用者名稱、密碼什麼的了,而是直接用 Claim 設定角色就行了,畢竟我們的主題是角色授權。
public class LoginController : Controller { [HttpGet("/login")] public IActionResult Login() => View(); [HttpPost("/login")] public async void Login(string role) { Claim c = new(ClaimTypes.Role, role); ClaimsIdentity id = new(new[] { c }, CookieAuthenticationDefaults.AuthenticationScheme); ClaimsPrincipal p = new ClaimsPrincipal(id); await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, p); } [HttpGet("/denied")] public IActionResult DeniedAcc() => Content("不好意思,你無權訪問");
[HttpGet("/logout")]
public async void Logout()=> await HttpContext.SignOutAsync();
}
無比聰明的你一眼能看出,這是 MVC 控制器,並且實現登入有關的功能:
/login:進入登入頁
/logout:登出
/denied:表白失敗被拒絕,哦不,授權失敗被拒絕後訪問
Login 方法有兩個,沒引數的是 GET 版,有引數的是 POST 版。當以 POST 方式訪問時,會有一個 role 引數,表示被選中的角色。這裡為了簡單,不用輸使用者名稱密碼了,直接選個角色就登入。
Login 檢視如下:
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers <div> <p>登入角色:</p> <form method="post"> <select name="role"> <option value="admin">管理員</option> <option value="svip" selected>超級會員</option> <option value="gen">普通客戶</option> </select> <button type="submit">登入</button> </form> </div>
select 元素的名稱為 role,正好與 Login 方法(post)的引數 role 相同,能進行模型繫結。
admin 角色表示管理員,svip 角色表示超級VIP客戶,gen 角色表示普通客戶。假設這是一家大型紙尿褲批發店的客戶管理系統。這年頭,連買紙尿褲也要分三六九等了。
下面是該紙尿褲批發店為不同客戶群提供的服務。
[Route("znk")] public class 紙尿褲Controller : Controller { [Route("genindex")] [Authorize(Roles = "gen")] public IActionResult IndexGen() { return Content("普通客戶瀏覽頁"); } [Route("adminindex")] [Authorize(Roles = "admin")] public IActionResult IndexAdmin() { return Content("管理員專場"); } [Route("svipindex")] [Authorize(Roles = "svip")] public IActionResult IndexSVIP() => Content("超級會員殺熟通道"); }
注意上面授權特性,不需要指定策略名稱,只需指定你要求的角色名稱即可。
在應用程式的初始化配置上,我們們設定 Cookie 驗證。
var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllersWithViews(); builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(opt => { opt.LoginPath = "/login"; opt.AccessDeniedPath = "/denied"; opt.LogoutPath = "/logout"; opt.ReturnUrlParameter = "url"; opt.Cookie.Name = "_XXX_FFF_"; }); var app = builder.Build();
那幾個路徑就是剛才 Login 控制器上的訪問路徑。
因為不需要配置授權策略,所以不需要呼叫 AddAuthorization 擴充套件方法。主要是這個方法你在呼叫 AddControllersWithViews 方法時會自動呼叫,所以,如無特殊配置,我們們不用手動開啟授權功能。像 MVC、RazorPages 等這些功能,預設會配置授權的。
假如我要訪問紙尿褲批發店的超級會員通道,訪問 /znk/svipindex,這時候會跳轉到登入介面,並且 url 引數包含要回撥的路徑。
預設是選中“超級會員”的,此時點選“登入”,就能獲取授權。
如果選擇“普通客戶”,就會授失敗,拒絕訪問。
----------------------------------------------------------------------------------------
雖然角色授權功能我們們輕鬆實現了,可是,隨之而來的會產生一些疑問。不知道你有沒有這些疑問,反正老周有。
1、既然在程式碼上角色授權是包含在策略授權中的,那我們們沒配置策略啊,為啥不出錯?
AuthorizationPolicy 類有個靜態方法—— CombineAsync,這個方法的功能是合併已有的策略。但,我們們重點看這一段:
AuthorizationPolicyBuilder? policyBuilder = null; if (!skipEnumeratingData) { foreach (var authorizeDatum in authorizeData) { if (policyBuilder == null) { policyBuilder = new AuthorizationPolicyBuilder(); } var useDefaultPolicy = !(anyPolicies); // 如果有指定策略名稱,就合併 if (!string.IsNullOrWhiteSpace(authorizeDatum.Policy)) { var policy = await policyProvider.GetPolicyAsync(authorizeDatum.Policy).ConfigureAwait(false); if (policy == null) { throw new InvalidOperationException(Resources.FormatException_AuthorizationPolicyNotFound(authorizeDatum.Policy)); } policyBuilder.Combine(policy); useDefaultPolicy = false; } // 如果指定了角色名稱,呼叫 RequireRole 方法新增必備要素 var rolesSplit = authorizeDatum.Roles?.Split(','); if (rolesSplit?.Length > 0) { var trimmedRolesSplit = rolesSplit.Where(r => !string.IsNullOrWhiteSpace(r)).Select(r => r.Trim()); policyBuilder.RequireRole(trimmedRolesSplit); useDefaultPolicy = false; } // 同理,如果指定的驗證方案,新增之 var authTypesSplit = authorizeDatum.AuthenticationSchemes?.Split(','); if (authTypesSplit?.Length > 0) { foreach (var authType in authTypesSplit) { if (!string.IsNullOrWhiteSpace(authType)) { policyBuilder.AuthenticationSchemes.Add(authType.Trim()); } } } ……
原來,在合併策略過程中,會根據 IAuthorizeData 提供的內容動態新增 IAuthorizationRequirement 物件。這裡出現了個 IAuthorizeData 介面,這廝哪來的?莫急,你看看我們們剛才在 紙尿褲 控制器上應用了啥特性。
[Route("adminindex")] [Authorize(Roles = "admin")] public IActionResult IndexAdmin() { return Content("管理員專場"); }
對,就是它!AuthorizeAttribute,你再看看它實現了什麼介面。
public class AuthorizeAttribute : Attribute, IAuthorizeData
再回憶一下剛剛這段:
var rolesSplit = authorizeDatum.Roles?.Split(','); if (rolesSplit?.Length > 0) { var trimmedRolesSplit = rolesSplit.Where(r => !string.IsNullOrWhiteSpace(r)).Select(r => r.Trim()); policyBuilder.RequireRole(trimmedRolesSplit); …… }
原來這裡面還有玄機,Role 可以指定多個角色的喲,用逗號(當然是英文的逗號)隔開。如
[Route("adminindex")] [Authorize(Roles = "admin, svip")] public IActionResult IndexAdmin() { …… }
2、我沒有在中介軟體管道上呼叫 app.UseAuthorization(),為什麼能執行授權處理?
你會發現,在 app 上不呼叫 UseAuthorization 擴充套件方法也能使授權生效。因為像 RazorPages、MVC 這些東東還有一個概念,叫 Filter,可以翻譯為“篩選器”或“過濾器”。老周比較喜歡叫過濾器,因為這叫法生動自然,篩選器感覺是機器翻譯。
在過濾器裡,有專門用在授權方面的介面。
同步:IAuthorizationFilter
非同步:IAsyncAuthorizationFilter
在過濾器中,同步介面和非同步介面只實現其中一個即可。如果你兩個都實現了,那隻執行非同步介面。所以,你兩個都實現純屬白淦,畢竟非同步優先。為啥?你看看 ResourceInvoker 類的原始碼就知道了。
switch (next) { case State.InvokeBegin: { goto case State.AuthorizationBegin; } case State.AuthorizationBegin: { _cursor.Reset(); goto case State.AuthorizationNext; } case State.AuthorizationNext: { var current = _cursor.GetNextFilter<IAuthorizationFilter, IAsyncAuthorizationFilter>(); if (current.FilterAsync != null) // 執行非同步方法 { if (_authorizationContext == null) { _authorizationContext = new AuthorizationFilterContextSealed(_actionContext, _filters); } state = current.FilterAsync; goto case State.AuthorizationAsyncBegin; } else if (current.Filter != null) // 執行同步方法 { if (_authorizationContext == null) { _authorizationContext = new AuthorizationFilterContextSealed(_actionContext, _filters); } state = current.Filter; goto case State.AuthorizationSync; } else {
// 如果都不是授權過濾器,直接 End goto case State.AuthorizationEnd; } } case State.AuthorizationAsyncBegin: { Debug.Assert(state != null); Debug.Assert(_authorizationContext != null); var filter = (IAsyncAuthorizationFilter)state; var authorizationContext = _authorizationContext; _diagnosticListener.BeforeOnAuthorizationAsync(authorizationContext, filter); _logger.BeforeExecutingMethodOnFilter( FilterTypeConstants.AuthorizationFilter, nameof(IAsyncAuthorizationFilter.OnAuthorizationAsync), filter); var task = filter.OnAuthorizationAsync(authorizationContext); if (!task.IsCompletedSuccessfully) { next = State.AuthorizationAsyncEnd; return task; } goto case State.AuthorizationAsyncEnd; } case State.AuthorizationAsyncEnd: { Debug.Assert(state != null); Debug.Assert(_authorizationContext != null); var filter = (IAsyncAuthorizationFilter)state; var authorizationContext = _authorizationContext; _diagnosticListener.AfterOnAuthorizationAsync(authorizationContext, filter); _logger.AfterExecutingMethodOnFilter( FilterTypeConstants.AuthorizationFilter, nameof(IAsyncAuthorizationFilter.OnAuthorizationAsync), filter); if (authorizationContext.Result != null) { goto case State.AuthorizationShortCircuit; } // 完成後直接下一個授權過濾器 goto case State.AuthorizationNext; } case State.AuthorizationSync: { Debug.Assert(state != null); Debug.Assert(_authorizationContext != null); var filter = (IAuthorizationFilter)state; var authorizationContext = _authorizationContext; _diagnosticListener.BeforeOnAuthorization(authorizationContext, filter); _logger.BeforeExecutingMethodOnFilter( FilterTypeConstants.AuthorizationFilter, nameof(IAuthorizationFilter.OnAuthorization), filter); filter.OnAuthorization(authorizationContext); _diagnosticListener.AfterOnAuthorization(authorizationContext, filter); _logger.AfterExecutingMethodOnFilter( FilterTypeConstants.AuthorizationFilter, nameof(IAuthorizationFilter.OnAuthorization), filter); if (authorizationContext.Result != null) { goto case State.AuthorizationShortCircuit; } // 完成後直接一下授權過濾器 goto case State.AuthorizationNext; } case State.AuthorizationShortCircuit: { Debug.Assert(state != null); Debug.Assert(_authorizationContext != null); Debug.Assert(_authorizationContext.Result != null); Log.AuthorizationFailure(_logger, (IFilterMetadata)state); // This is a short-circuit - execute relevant result filters + result and complete this invocation. isCompleted = true; _result = _authorizationContext.Result; return InvokeAlwaysRunResultFilters(); } case State.AuthorizationEnd: { goto case State.ResourceBegin; }
程式碼很長,老周總結一下它的執行軌跡:
1、AuthorizationBegin 授權開始
2、AuthorizationNext 下一個過濾器
3、如果是非同步,走 AuthorizationAsyncBegin
如果同步,走 AuthorizationSync
如果都不是,直接走到 AuthorizationEnd
4、非同步:AuthorizationAsyncBegin --> AuthorizationAsyncEnd --> AuthorizationNext(回第2步,有請下一位過濾俠)
同步:AuthorizationSync --> AuthorizationNext(回第2步,有請下一位)
5、AuthorizationEnd 退場,進入 ResourceFilter 主會場
6、在2、3、4步過程中,如果授權失敗或出錯,直接短路,走 AuthorizationShortCircuit
你瞧,是不是同步和非同步只執行一個?
預設的授權過濾器實現 IAsyncAuthorizationFilter,即 AuthorizeFilter 類。所以,授權處理就是在這裡被觸發了。
var policyEvaluator = context.HttpContext.RequestServices.GetRequiredService<IPolicyEvaluator>(); // 先進行驗證 var authenticateResult = await policyEvaluator.AuthenticateAsync(effectivePolicy, context.HttpContext); // 如果允許匿名訪問,後面的工作就免了 if (HasAllowAnonymous(context)) { return; } // 驗證過了,再評估授權策略 var authorizeResult = await policyEvaluator.AuthorizeAsync(effectivePolicy, authenticateResult, context.HttpContext, context); if (authorizeResult.Challenged) //沒登入呢,去登入 { context.Result = new ChallengeResult(effectivePolicy.AuthenticationSchemes.ToArray()); } else if (authorizeResult.Forbidden) //授權失敗,拒絕訪問 { context.Result = new ForbidResult(effectivePolicy.AuthenticationSchemes.ToArray()); }
但是,這個授權過濾器在 MvcOptions 的 Filters 中沒有啊,它是啥時候弄進去的?這貨不是在 Filters 中配置的,而是在 Application Model 初始化時透過 AuthorizationApplicationModelProvider 類弄進去的。AuthorizationApplicationModelProvider 類實現了 IApplicationModelProvider 介面,但不對外公開。
public void OnProvidersExecuting(ApplicationModelProviderContext context) { if (context == null) { throw new ArgumentNullException(nameof(context)); } if (_mvcOptions.EnableEndpointRouting) { // When using endpoint routing, the AuthorizationMiddleware does the work that Auth filters would otherwise perform. // Consequently we do not need to convert authorization attributes to filters. return; } foreach (var controllerModel in context.Result.Controllers) { var controllerModelAuthData = controllerModel.Attributes.OfType<IAuthorizeData>().ToArray(); if (controllerModelAuthData.Length > 0) { controllerModel.Filters.Add(GetFilter(_policyProvider, controllerModelAuthData)); } foreach (var attribute in controllerModel.Attributes.OfType<IAllowAnonymous>()) { controllerModel.Filters.Add(new AllowAnonymousFilter()); } foreach (var actionModel in controllerModel.Actions) { var actionModelAuthData = actionModel.Attributes.OfType<IAuthorizeData>().ToArray(); if (actionModelAuthData.Length > 0) { actionModel.Filters.Add(GetFilter(_policyProvider, actionModelAuthData)); } foreach (var _ in actionModel.Attributes.OfType<IAllowAnonymous>()) { actionModel.Filters.Add(new AllowAnonymousFilter()); } } } }
而 filter 是在 GetFilter 方法生成的。
public static AuthorizeFilter GetFilter(IAuthorizationPolicyProvider policyProvider, IEnumerable<IAuthorizeData> authData) { // The default policy provider will make the same policy for given input, so make it only once. // This will always execute synchronously. if (policyProvider.GetType() == typeof(DefaultAuthorizationPolicyProvider)) { var policy = AuthorizationPolicy.CombineAsync(policyProvider, authData).GetAwaiter().GetResult()!; return new AuthorizeFilter(policy); } else { return new AuthorizeFilter(policyProvider, authData); } }
3、RolesAuthorizationRequirement 實現了 IAuthorizationHandler 介面,可是它又沒註冊到服務容器中,HandlerAsync 方法又是怎麼呼叫的?
RolesAuthorizationRequirement 一步到位,既實現了 IAuthorizationRequirement 介面又實現抽象類 AuthorizationHandler<TRequirement>。它雖然沒有在服務容器中註冊,可服務容器中註冊了 PassThroughAuthorizationHandler 類,有它在,各種實現 IAuthorizationHandler 介面的 Requirement 都能順利執行,看看原始碼。
public class PassThroughAuthorizationHandler : IAuthorizationHandler { …… public async Task HandleAsync(AuthorizationHandlerContext context) { foreach (var handler in context.Requirements.OfType<IAuthorizationHandler>()) { await handler.HandleAsync(context).ConfigureAwait(false); if (!_options.InvokeHandlersAfterFailure && context.HasFailed) { break; } } } }
看,這不就執行了嗎。
至此,我們們就知道這角色授權的流程是怎麼走的了。