2

ASP.NET Core 6框架揭秘实例演示[40]:基于角色的授权

 1 year ago
source link: https://www.cnblogs.com/artech/p/inside-asp-net-core-6-40.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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方法重载。

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中间件提供授权规则的元数据。

[Authorize(Roles ="admin")]
IResult WelcomeAsync(ClaimsPrincipal user, IPageRenderer renderer)=> renderer.RenderHomePage(user.Identity!.Name!);

[S2804]将“授权策略”绑定到路由终结点

如果在调用AddAuthorization扩展方法时已经定义了授权策略,我们也可以按照如下的方式将策略名称设置为AuthorizeAttribute特性大的Policy属性。

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

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK