ASP.NET應用并沒有對如何定義授權策略做硬性規定,所以我們完全根據用戶具有的任意特性(如性別、年齡、學歷、所在地區、宗教信仰、政治面貌等)來判斷其是否具有獲取目標資源或者執行目標操作的權限,但是針對角色的授權策略依然是最常用的。角色(或者用戶組)實際上就是對一組權限集的描述,將一個用戶添加到某個角色之中就是為了將對應的權限賦予該用戶。在《使用最簡潔的代碼實現登錄、認證和注銷》中,我們提供了一個用來演示登錄、認證和注銷的程序,現在我們在此基礎上添加基于“角色授權的部分”。(本文提供的示例演示已經同步到《ASP.NET Core 6框架揭秘-實例演示版》)
[S2801]基于“要求”的授權
[S2802]基于“策略”的授權
[S2803]將“角色”綁定到路由終結點
[S2804]將“授權策略”綁定到路由終結點
[S2801]基于“要求”的授權
我們提供的演示實例提供了IAccountService和IPageRenderer兩個服務,前者用用來進行校驗密鑰,后者用來呈現主頁和登錄頁面。為了在認證的時候一并將用戶擁有的角色提取出來,我們按照如下的方式為IAccountService接口的Validate方法添加了表示角色列表的輸出參數。對于實現類AccountService提供的三個賬號來說,只有“Bar”擁有一個名為“Admin”的角色。
public interface IAccountService { bool Validate(string userName, string password, out string[] roles); } public class AccountService : IAccountService { private readonly Dictionary<string, string> _accounts = new(StringComparer.OrdinalIgnoreCase) { { "Foo", "password" }, { "Bar", "password" }, { "Baz", "password" } }; private readonly Dictionary<string, string[]> _roles = new(StringComparer.OrdinalIgnoreCase) { { "Bar", new string[]{"Admin" } } }; public bool Validate(string userName, string password, out string[] roles) { if (_accounts.TryGetValue(userName, out var pwd) && pwd == password) { roles = _roles.TryGetValue(userName, out var value) ? value : Array.Empty<string>(); return true; } roles = Array.Empty<string>(); return false; } }
我們假設演示的應用是供擁有“Admin”角色的管理人員使用的,所以只能擁有該角色的用戶才能訪問應用的主頁,未授權訪問會自動定向到我們提供的“訪問拒絕”頁面。我們在另一個IPageRenderer服務接口中添加了如下這個RenderAccessDeniedPage方法,并在PageRenderer類型中完成了對應的實現。
public interface IPageRenderer { IResult RenderLoginPage(string? userName = null, string? password = null, string? errorMessage = null); IResult RenderAccessDeniedPage(string userName); IResult RenderHomePage(string userName); } public class PageRenderer : IPageRenderer { public IResult RenderAccessDeniedPage(string userName) { var html = @$" <html> <head><title>Index</title></head> <body> <h3>{userName}, your access is denied.</h3> <a href='/Account/Logout'>Change another account</a> </body> </html>"; return Results.Content(html, "text/html"); } ... }
在現有的演示程序基礎上,我們不需要作太大的修改。由于需要引用授權功能,我們調用了IServiceCollection接口的AddAuthorization擴展方法注冊了必要的服務。由于引入了“訪問決絕”頁面,我們注冊了對應的終結點,該終結點依然采用標準的路徑“Account/AccessDenied”,對應的處理方法DenyAccess直接調用上面這個RenderAccessDeniedPage方法將該頁面呈現出來。
using App; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization.Infrastructure; using System.Security.Claims; using System.Security.Principal; var builder = WebApplication.CreateBuilder(); builder.Services .AddSingleton<IPageRenderer, PageRenderer>() .AddSingleton<IAccountService, AccountService>() .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(); builder.Services.AddAuthorization(); var app = builder.Build(); app.UseAuthentication(); app.Map("/", WelcomeAsync); app.MapGet("Account/Login", Login); app.MapPost("Account/Login", SignInAsync); app.Map("Account/Logout", SignOutAsync); app.Map("Account/AccessDenied", DenyAccess); app.Run(); Task WelcomeAsync(HttpContext context, ClaimsPrincipal user, IPageRenderer renderer, IAuthorizationService authorizationService); IResult Login(IPageRenderer renderer); Task SignInAsync(HttpContext context, HttpRequest request, IPageRenderer renderer,IAccountService accountService); Task SignOutAsync(HttpContext context); IResult DenyAccess(ClaimsPrincipal user, IPageRenderer renderer) => renderer.RenderAccessDeniedPage(user?.Identity?.Name!);我們需要對用來認證請求的SignInAsync方法作相應的修改。如下的代碼片段所示,對于成功通過認證的用戶,我們會為它創建一個ClaimsPrincipal對象來表示當前用戶。這個對象也是授權的目標對象,授權的本質就是確定該對象是否攜帶了授權資源或者操作所要求的“資質”。由于我們采用的是基于“角色”的授權,所以我們將該用于擁有的角色以“聲明(Claim)”的形式添加到表示身份的ClaimsIdentity對象上。
Task SignInAsync(HttpContext context, HttpRequest request, IPageRenderer renderer,IAccountService accountService) { var username = request.Form["username"]; if (string.IsNullOrEmpty(username)) { return renderer.RenderLoginPage(null, null, "Please enter user name.").ExecuteAsync(context); } var password = request.Form["password"]; if (string.IsNullOrEmpty(password)) { return renderer.RenderLoginPage(username, null, "Please enter user password.").ExecuteAsync(context); } if (!accountService.Validate(username, password, out var roles)) { return renderer.RenderLoginPage(username, null, "Invalid user name or password.").ExecuteAsync(context); } var identity = new GenericIdentity(name: username, type: CookieAuthenticationDefaults.AuthenticationScheme); foreach (var role in roles) { identity.AddClaim(new Claim(ClaimTypes.Role, role)); } var user = new ClaimsPrincipal(identity); return context.SignInAsync(user); }
演示實例授權的效果就是讓擁有“Admin”角色的用戶才能訪問主頁,所以我們將授權實現在如下這個WelcomeAsync方法中。如果當前用戶(由注入的ClaimsPrincipal對象表示)并未通過認證,我們依然調用HttpContext上下文的ChallengeAsync擴展方法返回一個“匿名請求”的質詢。在確定用戶通過認證的前提下,我們創建了一個RolesAuthorizationRequirement來表示主頁針對授權用戶的“角色要求”。授權檢驗通過調用注入的IAuthorizationService對象的AuthorizeAsync方法來完成,我們將代表當前用戶的ClaimsPrincipal對象和包含RolesAuthorizationRequirement對象的數組作為參數。如果授權成功,主頁得以正常呈現,否則我們調用HttpContext上下文的ForbidAsync擴展方法返回“權限不足”的質詢,上面提供的“拒絕訪問”頁面將會呈現出來。
async Task WelcomeAsync(HttpContext context, ClaimsPrincipal user, IPageRenderer renderer,IAuthorizationService authorizationService) { if (user?.Identity?.IsAuthenticated ?? false) { var requirement = new RolesAuthorizationRequirement(new string[] { "admin" }); var result = await authorizationService.AuthorizeAsync( user:user, resource: null, requirements: new IAuthorizationRequirement[] { requirement }); if (result.Succeeded) { await renderer.RenderHomePage(user.Identity.Name!).ExecuteAsync(context); } else { await context.ForbidAsync(); } } else { await context.ChallengeAsync(); } }
程序啟動之后,具有“Admin”權限的“Bar”用戶能夠正常主頁,其他的用戶(比如“Foo”)會自動重定向到“訪問拒絕”頁面,具體效果體現在圖1中。
圖1 針對主頁的授權
[S2802]基于“策略”的授權
我們調用IAuthorizationService服務的AuthorizeAsync方法進行授權檢驗的時候,實際上是將授權要求定義在一個RolesAuthorizationRequirement對象中,這是一種比較煩瑣的編程方式。另一種推薦的做法是在應用啟動的過程中創建一系列通過AuthorizationPolicy對象表示的授權規則,并指定一個唯一的名稱對它們進行全局注冊,那么后續就可以針對注冊的策略名稱進行授權檢驗。如下面的代碼片段所示,在調用AddAuthorization擴展方法注冊授權相關服務時,我們利用作為輸入參數的Action<AuthorizationOptions>對象對授權策略進行了全局注冊。表示授權規策略的AuthorizationPolicy對象實際上是對基于角色“Admin”的RolesAuthorizationRequirement對象的封裝,我們調用AuthorizationOptions配置選項的AddPolicy方法對授權策略進行注冊,并將注冊名稱設置為“Home”。
using App; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization.Infrastructure; using System.Security.Claims; using System.Security.Principal; var builder = WebApplication.CreateBuilder(); builder.Services .AddSingleton<IPageRenderer, PageRenderer>() .AddSingleton<IAccountService, AccountService>() .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(); builder.Services.AddAuthorization(AddAuthorizationPolicy); var app = builder.Build(); app.UseAuthentication(); app.Map("/", WelcomeAsync); app.MapGet("Account/Login", Login); app.MapPost("Account/Login", SignInAsync); app.Map("Account/Logout", SignOutAsync); app.Map("Account/AccessDenied", DenyAccess); app.Run(); void AddAuthorizationPolicy(AuthorizationOptions options) { var requirement = new RolesAuthorizationRequirement(new string[] { "admin" }); var requirements = new IAuthorizationRequirement[] { requirement }; var policy = new AuthorizationPolicy(requirements: requirements, authenticationSchemes: Array.Empty<string>()); options.AddPolicy("Home", policy); }在呈現主頁的WelcomeAsync方法中,我們依然調用IAuthorizationService服務的AuthorizeAsync方法來檢驗用戶是否具有對應的權限,但這次采用的是另一個可以直接指定授權策略注冊名稱的AuthorizeAsync方法重載(S2802)。
async Task WelcomeAsync(HttpContext context, ClaimsPrincipal user, IPageRenderer renderer, IAuthorizationService authorizationService) { if (user?.Identity?.IsAuthenticated ?? false) { var result = await authorizationService.AuthorizeAsync(user: user, policyName: "Home"); if (result.Succeeded) { await renderer.RenderHomePage(user.Identity.Name!).ExecuteAsync(context); } else { await context.ForbidAsync(); } } else { await context.ChallengeAsync(); } }
[S2803]將“角色”綁定到路由終結點
上面演示的例子都調用IAuthorizationService對象的AuthorizeAsync方法來確定指定的用戶是否滿足提供的授權規則,實際上針對請求的授權直接交給AuthorizationMiddleware中間件來完成,該中間件可以采用如下的方式調用UseAuthorization擴展方法進行注冊。
... var builder = WebApplication.CreateBuilder(); builder.Services .AddSingleton<IPageRenderer, PageRenderer>() .AddSingleton<IAccountService, AccountService>() .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(); builder.Services.AddAuthorization(); var app = builder.Build(); app .UseAuthentication() .UseAuthorization(); ...
當該中間件在進行授權檢驗的時候,會從當前終結點的元數據中提取授權規則,所以我們在注冊對應終結點的時候需要提供對應的授權規則。由于WelcomeAsync方法不再需要自行完成授權檢驗,所以它只需要將主頁呈現出來就可以了。針對“Admin”角色的授權要求直接利用標注在該方法上的AuthorizeAttribute特性來指定,該特性就是為AuthorizationMiddleware中間件提供授權規則的元數據(S2803)。
[Authorize(Roles ="admin")] IResult WelcomeAsync(ClaimsPrincipal user, IPageRenderer renderer)=> renderer.RenderHomePage(user.Identity!.Name!);
[S2804]將“授權策略”綁定到路由終結點
如果在調用AddAuthorization擴展方法時已經定義了授權策略,我們也可以按照如下的方式將策略名稱設置為AuthorizeAttribute特性大的Policy屬性(S2804)。
[Authorize(Policy = "Home")] IResult WelcomeAsync(ClaimsPrincipal user, IPageRenderer renderer) => renderer.RenderHomePage(user.Identity!.Name!);
如果采用Lambda表達式來定義終結點處理器,我們可以按照如下的方式將AuthorizeAttribute特性標注在表達式上。注冊終結點的各種Map方法會返回一個IEndpointConventionBuilder對象,我們可以安裝如下的方式調用它的RequireAuthorization擴展方法將AuthorizeAttribute特性作為一個IAuthorizeData對象添加到注冊終結點的元數據集合。RequireAuthorization擴展方法來有一個將授權策略名稱作為參數的重載。
app.Map("/",[Authorize(Roles ="admin")]ClaimsPrincipal user, IPageRenderer renderer) => renderer.RenderHomePage(user.Identity!.Name!)); app.Map("/",[Authorize(Policy = "Home")](ClaimsPrincipal user, IPageRenderer renderer) => renderer.RenderHomePage(user.Identity!.Name!)); app.Map("/", WelcomeAsync).RequireAuthorization(new AuthorizeAttribute { Roles = "Admin"}); app.Map("/", WelcomeAsync).RequireAuthorization(new AuthorizeAttribute { Policy = "Home"}); app.Map("/", WelcomeAsync).RequireAuthorization(policyNames: "Home");