ASP.NET Core 6框架揭祕範例演示[40]:基於角色的授權

2023-06-25 12:01:27

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中。

image

圖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");