Blazor教程 第十三课:授权的基础知识

Blazor 草稿

1. 从0开始撸授权

上篇文章已经介绍了很多概念性的东西,这篇文章就不用那么累了。和上一讲一样,这一讲,我们也从手撸简陋代码开始,体会一下什么是授权,再循序渐进的给大家说框架中有关授权的基础设施都是怎么设计的。

1.1 先复习认证知识

新开一个项目dotnet new web --use-program-main -o HelloAuthZ,然后把Program.cs改成下面这样:

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;

namespace HelloAuthZ;

public class Program
{
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);

        builder.Services.AddAuthentication("school_scheme").AddCookie("school_scheme");

        var app = builder.Build();

        app.UseAuthentication();

        app.MapGet("/Login", LoginEndpoint);
        app.MapGet("/Logout", LogoutEndpoint);

        app.MapGet("/", RootEndpoint);

        app.Run();
    }

    public static async Task RootEndpoint(HttpContext ctx)
    {
        StringBuilder responseContent = new StringBuilder();
        int count = 0;
        foreach(var identity in ctx.User.Identities)
        {
            responseContent.AppendLine($"Identity # {count++}: ");
            responseContent.AppendLine($"    Identity.IsAuthenticated == {identity.IsAuthenticated}");
            responseContent.AppendLine($"    Identity.AuthenticationType == {identity.AuthenticationType}");
            responseContent.AppendLine($"    Identity.Claims :");
            foreach(var c in identity.Claims)
            {
                responseContent.AppendLine($"        {c.Type} : {c.Value}");
            }
        }
        await ctx.Response.WriteAsync(responseContent.ToString());
    }

    public static async Task LoginEndpoint(HttpContext ctx, string name, string gender, string age, string role, string scheme)
    {
        await ctx.SignInAsync(scheme, new ClaimsPrincipal(new ClaimsIdentity(new Claim[]
        {
            new Claim("Name", name),
            new Claim("Gender", gender),
            new Claim("Age", age),
            new Claim("Role", role)
        }, scheme)));
        await ctx.Response.WriteAsync($"You've just logged in as {name} through \"{scheme}\" scheme");
    }

    public static async Task LogoutEndpoint(HttpContext ctx)
    {
        await ctx.SignOutAsync();
        await ctx.Response.WriteAsync("You've just logged out");
    }
}

如果你仔细阅读了上一篇文章的话,上面的代码就非常好懂。唯一可能有点陌生的就是LoginEndpoint方法的参数是怎么回事,这里紧急插播一下这个小知识点:

  • MapGet, MapPost之类的方法,是.net 6.0之后推出的,用来快速定义endpoint的方法
  • MapGet, MapPost之类的方法来定义endpoint时,框架在调用endpoint执行体函数时,会智能的从多个地方来解析参数的值,在没有特殊说明的情况下,框架会倾向于去请求携带的query string中去解析参数

比如在下面的例子中:

var builder = WebApplication.CreateBuilder(args);

// Added as service
builder.Services.AddSingleton<Service>();

var app = builder.Build();

app.MapGet("/{id}", (int id,
                     int page,
                     [FromHeader(Name = "X-CUSTOM-HEADER")] string customHeader,
                     Service service) => { });

class Service { }
  • 参数id是从路由路径中取的,框架之所以会去路由路径中去取这个参数的值,是因为MapGet的第一个参数,即路由匹配规则,使用了{xxx}这种语法
  • 参数page没有额外信息,DI池中也没有匹配的对象,框架就会去去query string中去拿这个参数,拿不到还会抛异常
  • 参数customerHeader有明确的修饰:即[FromHeader(Name = "X-CUSTOM-HEADER")],框架就会去HTTP请求头部,将指定的头部字段的值解析出来当成参数值
  • 参数service虽然也没有额外信息,但DI池中有匹配的对象,框架就会拿DI池中的对象做参数值

1.2 再添加一个授权的野生实现

以上代码的实现只有认证功能,登录用户有四个Claim,下面我们来新增两个endpoint:一个只允许RoleAdmin的登录用户访问,另一个只允许“年龄大于18岁的男性用户”访问。

代码改动如下:

         // ...
         app.MapGet("/Login", LoginEndpoint);
         app.MapGet("/Logout", LogoutEndpoint);
+
+        app.MapGet("/OnlyForAdmin", OnlyForAdminEndpoint);
+        app.MapGet("/OnlyForAdultMales", OnlyForAdultMalesEndpoint);
+
         app.MapGet("/", RootEndpoint);
         // ...
 
     // ...
+    public static async Task OnlyForAdminEndpoint(HttpContext ctx)
+    {
+        IEnumerable<ClaimsIdentity> authenticatedIdentities = ctx.User.Identities.Where(i => i.IsAuthenticated);
+
+        bool isAuthenticated = authenticatedIdentities.Any();
+        bool isAdmin = authenticatedIdentities.Where(i => i.Claims.Where(c => c.Type == "Role" && c.Value == "Admin").Any()).Any();
+
+        if(!isAuthenticated)
+        {
+            ctx.Response.StatusCode = 401;
+            return;
+        }
+
+        if(!isAdmin)
+        {
+            ctx.Response.StatusCode = 403;
+            return;
+        }
+
+        await ctx.Response.WriteAsync("you're AUTHENTICATED and ADMIN !");
+        return;
+    }
+
+    public static async Task OnlyForAdultMalesEndpoint(HttpContext ctx)
+    {
+        IEnumerable<ClaimsIdentity> authenticatedIdentities = ctx.User.Identities.Where(i => i.IsAuthenticated);
+
+        bool isAuthenticated = authenticatedIdentities.Any();
+        Func<ClaimsIdentity, bool> identityIsAdult = identity => identity.Claims.Where(c => c.Type == "Age" && int.TryParse(c.Value, out int age) && age >= 18).Any();
+        Func<ClaimsIdentity, bool> identityIsMale = identity => identity.Claims.Where(c => c.Type == "Gender" && c.Value == "Male").Any();
+        bool isAdultMales = authenticatedIdentities.Where(i => identityIsAdult(i) && identityIsMale(i)).Any();
+
+        if(!isAuthenticated)
+        {
+            ctx.Response.StatusCode = 401;
+            return;
+        }
+
+        if(!isAdultMales)
+        {
+            ctx.Response.StatusCode = 403;
+            return;
+        }
+
+        await ctx.Response.WriteAsync("you're ADULT and MALE for sure !");
+        return;
+    }
     // ...

就目前这个代码,跑起来,以下是几种运行结果:

第一种:不登录,两个额外页面是无法访问的,都会返回401

anonymous_user

第二种:以Alice的身份登录,我们假定Alice是一个20岁的女性,且是管理员身份。那么她就能访问OnlyForAdmin页面,但无法访问OnlyForAdultMales页面:会返回403

alice

第三种:以Bob的身份登录,假定Bob是一个19岁的男性,但不是管理员,是一个学生,那么他就只能访问OnlyForAdultMales页面,不能访问OnlyForAdmin页面:会返回403

bob

1.3 介绍概念:"Role Based AuthZ"和"Claims Based AuthZ"

上面的代码实现很简单,两个特殊的endpoint看起来代码差不多,但从概念的角度来讲,他们其实使用了两种不同的鉴权思路:

  1. Role based authZ
  2. Claims based authZ

咱现在说的是思路,是概念,和任何框架、任何实现都没关系。

所谓的“role based authZ”其实非常好理解,也非常自然:即在用户的信息中,单独开个字段来说明这个用户的权限是什么,然后在代码实现的时候,只通过这一个单一字段来判断用户的请求是否合法。这种设计思路非常简单,也非常实用,大规模的应用在这个世界上80%的交互式网站上:大多数网站的权限规则都非常简单,不需要考虑过多的幺蛾子。

我们以一个网络论坛为例,如果一个网络论坛决定采用这种方式来设计整个认证授权用户体系,那么很简单,给用户信息表中单加个字段,就叫“等级”就好了:

  • 新注册用户0级,达到某种活跃度升一级,级别的什就是"role"。

  • 对于论坛的各级管理员,可以设定一些奇怪的级别:

    • 比如“生活区”的版主的级别,设计成“10000 + 7”,其中“10000”是所有管理员阶层的起始等级,“7”是生活区的代号
    • 全站管理员,可以把级别设计成“1000000"
    • 总之数值范围很多,怎么设计都行
    • 当然这个字段也可以是字符串形式的

这几乎就能搞定所有问题了,但有一种场景,这种设计实现应对起来比较吃力。比如这个论坛的例子中,忽然有一天站长决定开发一个少儿不宜区,问题就出来了:如何把年龄判断,加进现在的鉴权过程中呢?

两种实现思路:

一种是缝缝补补,尽量少改代码,而是发挥聪明才智,把年龄信息编码到已经存在的“等级”字段中去,然后适当更新权限配置文件或者配置表。

另一种是去他妈的,把当前系统中的,基于单一字段判断权限的逻辑砍了,重新写一套更灵活的鉴权代码。

这第二种,就是业界总结出来的:claims based authZ

这玩意说白了,其实就是把原来基于单一字段的判断逻辑,改成了多个字段的判断逻辑。

1.4 使用框架代码,实现role based authZ

我们上面的/OnlyForAdminEndpoint的鉴权逻辑是我们自己写的,现在我们把它改造一下,使用框架现成的基础设施来实现role based authZ

首先,与认证Authentication一样,授权,或者说鉴权,需要向DI中注册一些对象,这些对象承载着鉴权逻辑的具体实现。

其次,与认证Authentication一样,鉴权在asp .net core中也被设计成了一个middleware

所以我们要对主函数做如下修改:

         // ...
         builder.Services.AddAuthentication("school_scheme").AddCookie("school_scheme");
 
+        builder.Services.AddAuthorization();
 
         var app = builder.Build();
 
         app.UseAuthentication();
+        app.UseRouting();
+        app.UseAuthorization();
 
         app.MapGet("/Login", LoginEndpoint);
         app.MapGet("/Logout", LogoutEndpoint);
         // ...

而之所以我们要在app.UseAuthorization之前加上app.UseRouting的调用,如果你之前看过我们第十二课前的番外篇,会有一点朦胧的印象。如果你没看过,或者印象不深,不重要,这里简单的解释一下:

  • app.UseRouting添加的middleware,会执行“路由”,所谓的“路由”,就是搞明白这个请求将被哪个endpoint执行体处理
  • 与鉴权很多的信息是写在endpoint执行体的定义上的,这个我们马上就能看到例子。。简单来说,鉴权所需的信息,只有在“路由”工作结束之后才能拿到。

现在我们向DI中注册了一揽子对象,用来干鉴权的活。又配置了一个middleware,去实际执行鉴权动作。我们要实现的,是role based authZ,那么自然的,就需要把这个规则,写在endpoint定义的地方,如下:

        // ...
        app.MapGet("/Logout", LogoutEndpoint);

-       app.MapGet("/OnlyForAdmin", OnlyForAdminEndpoint);
+       app.MapGet("/OnlyForAdmin", OnlyForAdminEndpoint).RequireAuthorization(new AuthorizeAttribute { Roles = "Admin" });
        app.MapGet("/OnlyForAdultMales", OnlyForAdultMalesEndpoint);

        app.MapGet("/", RootEndpoint);
        // ...

我们在MapGet之后链式调用的这个方法,就是在向框架说:

  1. 这个endpoint只允许认证用户访问
  2. 不光这个用户得是认证用户,它的Role还必须是Admin才行

目前不必过分纠结RequireAuthorization方法的细节,以及AuthorizeAttribute的细节:比如设定Role的地方为什么是复数形式Roles(其实是因为可以允许多种Role的用户来访问这个endpoint,多个Role以逗号分开),只需要大致明白这个逻辑就可以了。关键的点在:我们是把鉴权规则和endpoint写在了一起。

这正应了上面说的:鉴权所需要的信息,只有在“路由”工作结束后才能拿到。

再接下来,框架会根据鉴权信息去分析某个请求是否合法,那我们在endpoint执行体中就没必要写条件判断了,所以OnlyForAdminEndpoint就可以改写成下面这样:

    public static async Task OnlyForAdminEndpoint(HttpContext ctx)
    {
        await ctx.Response.WriteAsync("you're AUTHENTICATED and ADMIN !");
        return;
    }

之前一大坨的,用来做权限判断的代码,都可以扔了:现在框架会帮我们做这些事,更具体一点,app.UseAuthorization注册的middleware会做那些事

最后一件事:我们还没有告诉框架,我们的用户信息中,哪个字段代表着role!

这很关键!!我们虽然用英文单词定义了Name, Gender, Age, Role四个claim,但框架又不会讲英语,它哪知道所谓的Role,指的是哪个Claim呢?

那么我们来思考一下:我们应当如何告诉框架,去哪里拿Role?或者说,我们把我们假定为asp .net core框架的开发者,我们应当在哪里开放一个口子,让程序员可以自定义Role的来源?

有两个地方:

  1. 给框架说一次:记着,ClaimsIdentity里诸多的Claims中,你去找那个Type == "Role"的Claim,它的值,就是Role
  2. 每次创建ClaimsIdentity实例的时候,都手动指定:对于目前这个用户的这个身份,请把其中Type == "Role"的Claim的值,作为这个用户,这个身份的Role

对于前者,可以如下在代码中指定:

         builder.Services.AddAuthentication("school_scheme").AddCookie("school_scheme");
 
+        builder.Services.Configure<ClaimsIdentityOptions>(options => 
+        {
+            options.RoleClaimType = "Role";
+        });
 
         builder.Services.AddAuthorization();

但是,这种方法是无效的,接下来这个没用的知识点比较有意思:

上面的代码虽然看起来做了我们想做的事,但实际上,框架在判断登录用户是否与某个Role相契合的时候,调用的是ClaimsPrincipal.IsInRole()方法,而这个方法的默认实现,长下面这样:

        public virtual bool IsInRole(string role)
        {
            for (int i = 0; i < _identities.Count; i++)
            {
                if (_identities[i] != null)
                {
                    if (_identities[i].HasClaim(_identities[i].RoleClaimType, role))
                    {
                        return true;
                    }
                }
            }

            return false;
        }

这里有两个关键信息:

  1. ClaimsPrincipal中,IsInRole的实现,完全没有去看,哪怕一眼,DI池中配置的ClaimsIdentityOptions配置对象:逻辑纯写死的,配置对象你随便配置,我看一眼算我输
  2. 这是一个可在子类覆盖的虚方法:这意味着虽然asp .net core框架在ClaimsPrincipal这一层没有尊重ClaimsIdentityOptions中的配置,但各种其它类库、框架中的子类,则有可能改写这个IsInRole的具体实现

既然这个方法是无效的,那么我们就只能用第2个,看起来蠢一点的办法了:在构造ClaimsIdentity的时候,指定一个Claim为Role。

去查阅ClaimsIdentity的文档,会发现有个属性叫RoleClaimType,它就是我们想要的那个属性,不过麻烦的是,这个属性是只读的,只能通过构造函数,在构造的时候设定值。翻遍所有的构造函数重载,我们能找到下面这个重载:

public ClaimsIdentity (IEnumerable<Claim>? claims, string? authenticationType, string? nameType, string? roleType);

完美!那我们就可以把我们登录的代码改写成下面这样:

     public static async Task LoginEndpoint(HttpContext ctx, string name, string gender, string age, string role, string scheme)
     {
         await ctx.SignInAsync(scheme, new ClaimsPrincipal(new ClaimsIdentity(new Claim[]
         {
             new Claim("Name", name),
             new Claim("Gender", gender),
             new Claim("Age", age),
             new Claim("Role", role)
-        }, scheme)));
+        }, scheme, null, "Role")));
         await ctx.Response.WriteAsync($"You've just logged in as {name} through \"{scheme}\" scheme");
     }

嵌套太多了不太好阅读,不好意思,下面是清爽版:

    public static async Task LoginEndpoint(HttpContext ctx, string name, string gender, string age, string role, string scheme)
    {
        IEnumerable<Claim> claims = new List<Claim> 
        { 
            new Claim("Name", name),
            new Claim("Gender", gender),
            new Claim("Age", age),
            new Claim("Role", role)
        };

        ClaimsIdentity identity = new ClaimsIdentity(claims, scheme, null, "Role");

        ClaimsPrincipal principal = new ClaimsPrincipal(identity);

        await ctx.SignInAsync(scheme, principal);

        await ctx.Response.WriteAsync($"You've just logged in as {name} through \"{scheme}\" scheme");
    }

现在我们把程序运行起来,有以下效果:

如果是匿名用户去访问/OnlyForAdmin,会被重定向至/Account/Login?ReturnUrl=%2FOnlyForAdmin:其中/Account/Login显然是意图重定向至登录页面,而附带的query string则是在记录登录成功后应当跳转的地址,也是我们本意欲访问但没有权限的地址:/OnlyForAdmin

framework_authz_401

如果是认证用户Bob去访问/OnlyForAdmin,会被重定向至/Account/AccessDenied?ReturnUrl=%2FOnlyForAdmin,这个/Account/AcccessDenied就不是登录页面了,而显然是一个通知页面,应当在这个页面向Bob展示“你没有权限访问”等提示信息

framework_authz_401

如果是认证用户Alice去访问的话,自然和我们之前的效果一样,并没有什么区别:

framework_authz_200

我们先忽略掉401/Acccount/Login403/Account/AccessDenied这两个细节的话,就可以说:我们已经从思路上掌握了所谓的role based authZ,同时也掌握了如何在asp .net core框架中,去实现role based authZ。

为了防止大家刺挠,我也简单的说一下这个重定向的问题:

问题一:重定向合理吗?合理。那返回401和403合理吗?也合理。那到底应该重定向,还是返回401/403呢?

  • 如果请求是用户从浏览器发过来的,请求路径对应的endpoint的渲染结果是一个给人看的网页的话,那么返回重定向,让没认证的用户去登录,让没权限的用户死心,是合理的

  • 如果请求不是浏览器发送来的,或者说虽然是浏览器发送来的,但请求路径对应的endpoint本身就不是个页面,而是一个API接口的话,那么返回重定向是很滑稽的,这时候就应该返回401/403

  • 还有一种重要的知识点大家需要意识到:只有基于Cookie的认证机制,才会存在这个问题,才需要在服务端纠结到底是返回401/403,还是重定向。

    你仔细琢磨这个问题,它意味着,如果存在一种Http请求,它:

    1. 它是由客户端浏览器发起的,希望收到一个页面回复
    2. 它还包含一个纸条,里面写着认证信息

    那么这个认证信息,这个纸条,一定是写在cookie里的,没有其它技术方案。换言之,如果浏览器没有执行脚本的能力,那么它唯一能在HTTP这个无状态协议上实现“纸条”的技术方案,就是使用cookie,并且只有这一种可能。

    而如果认证是基于其它非cookie的手段实现的,比如把认证纸条放在HTTP请求的Authorization字段中,那么,能发送这种请求的东西,一定不是浏览器本身,只能是浏览器身上的脚本语言,或者直接就是非浏览器环境的其它程序。浏览器本身是不会给HTTP请求填充Authorization字段的

问题二:那怎么实现,在渲染页面的endpoint鉴权时,返回重定向,而对API的调用进行鉴权的时候,返回401/403呢?

这个问题有三个层次的回答:

  • 最低层次的答案是:

    有一个HTTP请求头部字段叫X-Requested-With,这不是一个标准的头部字段,这只是一个约定俗成的行业习惯,用来标识当前请求是对API的请求,而不是页面的请求。

    大多数前端开发框架,以及前端网络库,在向后端发送API请求的时候,都会加上这个头部字段,并把字段值设定为XMLHttpRequest

    而如果请求发送方遵守了这个行业习惯的话,asp .net core中,基于cookie机制实现的认证功能,是能自动识别请求类型,从而自动的去判断,应当发送重定向,还是401/403。

    至于非cookie机制实现的认证功能,则压根不会碰到这个问题:浏览器要在请求页面的HTTP请求中插入认证纸条的话,不借助JS的话,cookie几乎就是唯一选择。

  • 中间层次的答案是:

    你要理解到,无论是重定向,还是401/403,都是authentication middleware做的,而不是authorization middleware。

    你可以简单的理解为:authZ middleware只是鉴别出一个能不能访问的结果,而这个鉴别结果到底是要怎么处理,是authN middleware的事情。

    我们可以对authN的相关对象进行额外配置,可以做到:

    1. 改写重定向的地址
    2. 或者干脆禁用重定向,而使得authN middleware直接返回401或403

    改写重定向的地址,可以使用CookieAuthenticationOptions中的如下字段:

         builder.Services.AddAuthentication("school_scheme").AddCookie("school_scheme", cookieAuthNOptions => 
         {
             cookieAuthNOptions.LoginPath = "/customizedLoginPath";
             cookieAuthNOptions.AccessDeniedPath = "/customizedUnauthorizedPagePath";
         });

    如果要禁用重定向,或者按需禁用重定向,可以通过CookieAuthenticationOptions.Events中的事件回调,来自定义用户在未登录或权限不够时的行为,下面是个例子,来按请求路径是否以/api开头,来决定在未认证的情况下,是重定向到登录页面,还是直接返回401。

         builder.Services.AddAuthentication("school_scheme").AddCookie("school_scheme", cookieAuthNOptions => 
         {
             cookieAuthNOptions.Events.OnRedirectToLogin = ctx =>
             {
                 if(ctx.HttpContext.Request.Path.StartsWithSegments("/api"))
                 {
                     ctx.Response.Clear();
                     ctx.Response.StatusCode = 401;
                     return Task.CompletedTask;
                 }
    
                 ctx.Response.Redirect(ctx.RedirectUri);
                 return Task.CompletedTask;
             };
         });
  • 最高层次的答案

    对于API Endpoint,你就不应该使用基于cookie的认证方案!

    HTTP协议当初设计Cookie这个东西,这套机制,就是专门给浏览器用的,就不是给程序用的!除非你写的那个程序,就叫“浏览器”,那当我没说!

1.5 理论分析:claims based authZ和policy based authZ

在role based authZ中,其实我们可以把鉴权的逻辑,抽象成下面的“权限表”

endpoint 允许访问的Role
/OnlyForAdmin Admin
/OnlyForStudent Student, Admin

(我们的代码示例中并没有写/OnlyForStudent这个Endpoint,但你应该能明白我想表达什么)

上面这个表最大的问题是,列名“允许访问的Role”本身就暗含了鉴权的逻辑。如果我们写得详细一点,上面的权限表其实应该写成下面这样

endpoint 鉴权逻辑函数 调用鉴权逻辑时的参数
/OnlyForAdmin (string[] roles) => roles.Select(r => ctx.User.IsInRole(r)).Where(isInRole => isInRole).Any() new [] {"Admin"}
/OnlyForAdmin (string[] roles) => roles.Select(r => ctx.User.IsInRole(r)).Where(isInRole => isInRole).Any() new [] {"Admin", "Student"}

注意啊,上面的代码,都是伪代码,就说那么个意思而已。我们现在假设,在某个平行宇宙中,asp .net core官方人员最初就是这样设计鉴权系统的,那么对于role based authZ来说,鉴权逻辑函数都是一样的,不同的只是调用函数时的参数不同,这个参数,其实就是我们在MapGet后面调用RequireAuthorization时告诉框架的信息:

        app.MapGet("/OnlyForAdmin", OnlyForAdminEndpoint).RequireAuthorization(new AuthorizeAttribute { Roles = "Admin" });

也就是总结一下,在role based authZ中

  1. 鉴权逻辑函数是框架内置的,这个函数的逻辑也很简单,就是去轮ctx.User中的Claims,去看那个RoleClaim里面的值与参数是不是能匹配上

  2. 鉴权逻辑函数的参数是由程序员书写的,其实就是IAuthorizeData.Roles字段

    • AuthorizeAttribute是一个对接口IAuthorizeData的实现

我们上面也说了,只用一个Claim来判断权限限制太大,框架是万万不能这样设计的,所以很自然的,在那个平行宇宙中,asp .net core官方人员的下一步改进目标,就是要支持用多种claim来判断权限,如果延续上面的思路,就可以将鉴权表改造成下面这样:

endpoint 鉴权逻辑函数 调用鉴权逻辑时的参数
/OnlyForAdmin /* ... */ new Dictionary<string, Func<string, bool>> { { "Role", r => r == "Admin" } }
/OnlyForStudent /* ... */ new Dictionary<string, Func<string, bool>> { { "Role", r => r == "Admin" || r == "Student" } }

固化在框架内部的鉴权逻辑函数则可以改造成下面这样:

public static bool ClaimsBasedAuthZ(ClaimsPrincipal user, Dictionary<string, Func<string, bool>> rules)
{
    foreach(var kvp in rules)
    {
        if(!UserIsMatchRule(user, kvp.Key, kvp.Value))
        {
            return false;
        }
    }

    return true;
}

public static bool UserIsMatchRule(ClaimsPrincipal user, string claimType, Func<string, bool> isValueAllowed)
{
    foreach(Claim c in user中的所有已认证Identity下的Claims)
    {
        if(c.Type == claimType && isValueAllowed(c.Value))
        {
            return true;
        }
    }
    return false;
}

这样的设计理论上确实可以实现claim based authZ,但没必要:因为上面的权限表只需要再进一步,就可以进化成一种更为灵活的方式

  • 为什么不直接开放一个回调函数入口给程序员呢?由程序员去实现授权的所有细节

即,为什么不直接让程序员写出下面这样的代码呢?


        // ...
        app.MapGet("/OnlyForAdmin", OnlyForAdminEndpoint)
            .RequireAuthorization(OnlyForAdminAuthorizationFunc);
        // ...
    
    public static bool OnlyForAdminAuthorizationFunc(HttpContext ctx)
    {
        if(/* 各种细节逻辑判断,想看claim就看claim,想看什么就看什么 */)
        {
            return true
        }
        else
        {
            return false;
        }
    }

有没有感觉豁然开朗?返璞归真大道至简?

事实上也差不多是这样(其实细节差得挺多的,但你可以这样去理解):asp .net core创造了一个概念,叫policy,这个policy的意思,其实就是“用户自定义的鉴权函数”,只不过在代码中,它的形式并不是C#函数。

也就是说,从历史发展的角度来看,在发现role based authZ不好用之后,应该有一个阶段,是去实现claims based authZ。但框架直接跳过了这个阶段,直接实现了最有自由度的那种鉴权设计,然后为它起了个名字,叫policy based authZ。

1.6 如何在代码中使用policy based authZ

首先,policy的本质其实就是自定义的鉴权函数,但它的形式又不是纯C#函数:要用以下的代码,在AddAuthorization的时候定义policy。这也很好理解:policy的定义从逻辑上来说也算是鉴权系统中的一部分,通过配置方式去定义policy也很合理

    builder.Services.AddAuthorization(authorizationOptions => 
    {
        authorizationOptions.AddPolicy("AdultMalesPolicy", ???);
    });

上面的代码中,通过AuthorizationOptions.AddPolicy方法来定义,或者说向当前鉴权系统中添加一个policy时,

  • 第一个参数,是policy的名字:这将是这个policy的全局唯一的身份证号,后续任何使用到这个policy的地方,都是用这个名字来指代它

  • 第二个参数,是policy的具体实现:它可以是一个AuthorizationPolicy对象,也可以是一个Action<AuthorizationPolicyBuilder>

    • 使用AuthorizationPolicy当然最直接,这也是policy在框架内部的真实实现:每一个所谓的policy,其实在框架内部都是一个AuthorizationPolicy对象。但手动构造这个对象其实有点复杂

    • 使用Action<AuthorizationPolicyBuilder>其实是一种间接方法:框架根据你传入的函数,来构造出一个AuthorizationPolicy对象,最终在框架内部的policy,其实还是一个AuthorizationPolicy对象

      由于是间接构造,不可避免的是有一些限制的,不能像直接构造AuthorizationPolicy那样随心所欲,但好处也是非常明显的:AuthorizationPolicyBuilder有一些工具方法,设计出来就是为了让我们快速描述鉴权逻辑的,代码写起来清爽得多。

    builder.Services.AddAuthorization(authZOptions => 
    {
        authZOptions.AddPolicy("AdultMalesPolicy", policyBuilder => 
        {
            policyBuilder.AddAuthenticationSchemes("school_scheme");
            policyBuilder.RequireAuthenticatedUser();
            policyBuilder.RequireClaim("Gender", "Male");
            // ...
        });
    });

AuthorizationBuilderPolicy有四个常用方法

  1. RequireAuthenticatedUser : 添加“仅认证用户可通过鉴权”的逻辑
  2. RequireRole : 添加“仅Role为指定值才能通过鉴权”的逻辑
  3. RequireClaim : 有两个语义,通过重载区分。添加“必须存在某个指定Claim”,或“必须存在某个指定Claim,且Claim的值必须为指定值”的逻辑
  4. RequireUserName : 和RequireRole类似

这四个基本方法里,又以RequireAuthenticatedUserRequireClaim最为基本,仅使用这两个方法,其实就可以实现所谓的claim based authZ了。

RequireClaim的几个重载,都最多只能实现“指定的Claim的值必须在某几个指定的值里面”这种逻辑,没法做更复杂的逻辑:比如我们要实现的,把claim的值转化为数值,再去做逻辑判断。

这时候就得使用一个叫RequireAssertion的方法了:它接受一个Func<AuthorizationHandlerContext, bool>作为参数,在里面,可以通过AuthorizationHandlerContext.User直接拿到ClaimsPrincipal对象,做任何想做的逻辑判断,如下:

    builder.Services.AddAuthorization(authZOptions => 
    {
        authZOptions.AddPolicy("AdultMalesPolicy", policyBuilder => 
        {
            policyBuilder.AddAuthenticationSchemes("school_scheme");
            policyBuilder.RequireAuthenticatedUser();
            policyBuilder.RequireClaim("Gender", "Male");
            policyBuilder.RequireAssertion(authZHandlerCtx => 
            {
                IEnumerable<ClaimsIdentity> authNedIdentities = authZHandlerCtx.User.Identities.Where(i => i.IsAuthenticated);
                foreach(var identity in authNedIdentities)
                {
                    Claim? ageClaim = identity.Claims.Where(c => c.Type == "Age").FirstOrDefault();
                    if(ageClaim is not null && int.TryParse(ageClaim.Value, out int age))
                    {
                        return age >= 18;
                    }
                }
                return false;
            });
        });
    });

当你如上构造好policy后,就可以如下使用它了:

    app.MapGet("/OnlyForAdultMales", OnlyForAdultMalesEndpoint).RequireAuthorization("AdultMalesPolicy");

而endpoint执行体中,也就可以把原先的鉴权逻辑删掉了:

     public static async Task OnlyForAdultMalesEndpoint(HttpContext ctx)
     {
-        IEnumerable<ClaimsIdentity> authenticatedIdentities = ctx.User.Identities.Where(i => i.IsAuthenticated);
-
-        bool isAuthenticated = authenticatedIdentities.Any();
-        Func<ClaimsIdentity, bool> identityIsAdult = identity => identity.Claims.Where(c => c.Type == "Age" && int.TryParse(c.Value, out int age) && age >= 18).Any();
-        Func<ClaimsIdentity, bool> identityIsMale = identity => identity.Claims.Where(c => c.Type == "Gender" && c.Value == "Male").Any();
-        bool isAdultMales = authenticatedIdentities.Where(i => identityIsAdult(i) && identityIsMale(i)).Any();
-
-        if(!isAuthenticated)
-        {
-            ctx.Response.StatusCode = 401;
-            return;
-        }
-
-        if(!isAdultMales)
-        {
-            ctx.Response.StatusCode = 403;
-            return;
-        }
-
         await ctx.Response.WriteAsync("you're ADULT and MALE for sure !");
         return;
     }

小小的总结一下:

  1. asp .net core框架中并没有所谓的claims based authZ,而是直接进化到了所谓的policy based authZ

  2. policy的理论本质是一个自定义的鉴权函数,policy的实际本质是 一个AuthorizationPolicy对象 + 一个独一无二的字符串名字

  3. 定义policy的地方通常是在AddAuthorization时,通过配置参数Action<AuthorizationOptions>内部,调用options.AddPolicy("name", xxx)定义的

  4. 一般情况我们并不会真的创建一个AuthorizationPolicy对象,传递给AddPolicy作为第二个参数,而是选择使用Action<AuthorizationPolicyBuilder>的方式间接定义policy

  5. AuthorizationPolicyBuilder有几个常用的Requirexxx方法可以描述一些基本的鉴权逻辑,更复杂一些的逻辑,需要用RequireAssertion来实现

    • RequireAssertion中,鉴权逻辑虽然可以更灵活一点,但有个限制是:鉴权能拿到的数据,也仅局限于框架经过Authentication Middleware后,得到的ClaimsPrincipal对象。而像诸如:请求源IP之类的信息,是拿不到的

2. 更灵活的鉴权

其实到目前为止,你已经基本学会了policy based authZ,虽然你能定义的policy都是通过Action<AuthorizationPolicyBuilder>间接创建的,能做的无非就是拿点claim出来判断,但就这么点知识,已经足够应付日常开发了。

你不需要知道DI里有多少鉴权相关的对象被注册在那里,也不需要知道UseAuthorization注册的middleware背后的运行逻辑,你只需要知道怎么定义policy,然后怎么应用policy就行了。

哦,不太对,我们上面只讲了在MapXXX定义的endpoint上怎么应用policy,但没说在Controller中,在Razor Page中怎么应用policy,这里补充上:

2.1 如何在Controller和Razor Page中使用policy

核心很简单:就是AuthorizeAttribute,它可以直接修饰Controller类,或类中的方法,如下:

[Authorize(Policy = "xxx")]
public class xxxController : Controller
{
    // ...
}

当然除了policy外,还可以使用它实现role based authZ:[Authorize(Roles = "xxx,xxx")]。或者什么参数都不加,代表“只要登录了就行”,跟什么参数都不加去在MapGet()后面调用RequireAuthorization()一样。

那么如何在Razor Page中,对指定页面指定policy呢?坏消息是:AuthorizeAttribute是不能用来修饰Razor Page的model方法的,比如你在Razor Page后面的OnGetOnPost方法上去使用,是没有效果的。

那怎么办呢?官方推荐:把你的Razor Page项目改造成MVC项目,给中间加一层Controller

2.2 凝视一下深渊:待续

// To be continued: 授权之块的内部实现还是有点复杂,有点纠结要不要仔细从源代码角度来接着介绍 // 暂时决定就先讲到这里吧,对于日常开发来说,policy based authZ已经完全足够了