Blazor教程 第十二课:认证的基础知识

Blazor

1. 什么是认证,什么是授权

假设你是一名大学生,你们学校有个网站,用来选课、查看考试成绩等。

又或者你是个公司职员,你们公司有个网站(OA系统),用来打卡签到查工资单年终奖等。

当你要访问学校网站或公司网站的时候,从用户体验的角度来看,你对这个网站的体验大抵是:

  1. 这个网站需要登录,或者说大部分内容、功能都需要在登录后查看。你需要在登录页面输入用户名、密码,点击登录

  2. 登录进网站后,你只能查看、或者使用这个网站的一部分功能:

    • 作为学生,功能大致只有选课、查成绩、提交请假条等。你没法查看你同学的成绩,也没法篡改自己的过往成绩。
    • 作为公司职员,功能大致只有打卡、查看工资单、下载/打印各种证明、提交请假条等。你没法查看你同事的工资单,也没法给自己放半个月假,更没法篡改过往的打卡记录。

作为网站的使用者而言,整个过程的核心在于“登录”:

  1. 登录要填用户名和密码,或者扫描二维码,这是非常自然的体验,没人觉得这有什么不对
  2. 登录后,我也只能看到与自己相关的东西,操作我能操作的功能,这感觉也很自然,也没什么不对

而如果你站在网站开发者的角度来看这件事的话,你会发现,上面描述的“登录后浏览网站”的这个过程,其实是要分成两步的:

  1. 提交用户名+密码

    1. 这个事在用户的角度来看,就是访问网站需要提供一个凭据,来证明自己是网站的用户

    2. 这个事在网站的角度来看,在用户提交了一个凭据后,网站需要:

      1. 验证这个凭据的真伪:用户名和密码必须是真实有效的
      2. 从凭据上拿到用户的信息:用户名和密码对应着网站后台记录的一个用户,这个用户有名,有姓,有系院班信息或者职位信息等等
  2. 登录后浏览网站内容,或者使用网站功能

    1. 这个事在用户的角度来看,即并不是所有的功能我都能用: 我无法进入教师账号特有的修改/上传成绩页面,或者老板账号特有的休假审批页面:即使我复制了URL直接去访问那个页面,那个页面也会弹出提示告诉我“不要搞事”
    2. 这个事在网站的角度来看,当用户想要查看某些内容,或者想要使用某些功能的时候,网站需要检查用户的一部分身份信息,来判断用户是否有权限访问这个页面或者使用这个功能

这两个步骤其实是相对独立的,或者说,在网站开发者的角度来讲,这两个步骤可以拆分成两坨代码去执行:

前者就叫认证(Authentication),简写为AuthN,后者就叫授权(Authorization),简写为AuthZ

这里再多说一点文化差异:在中文语境中,“授权”这个词其实和Authorization不是完全划等号的。

“授权”给我们的直觉是一个动词,并且总有一股爹味:即听起来,像是有个领导,为你授予了某种特权之后,你才能去做某件事。

大多数人只有在仔细琢磨之后,才能琢磨出来“授权”这个词的名词意味,比如小张向保安怒吼道:“老子进库房领材料是有领导的授权的,你算个什么狗东西敢拦我!”

而英文中的Authorization,是一个名词,它有两个意思:

  1. 是站在保安的角度,“审查访客是否有权限去做这件事”这个过程的名词化描述,把整个审核过程名词化,意思就是Authorization
  1. 指某种能证明我有权限的文档或者证明材料,比如“领导签过字的授权文件”,“皇上颁给钦差的圣旨”

而我们现在所讨论的Authorization,其实说的是它的第一个意思,其实对于这个意思来说,中文中更合适的翻译应当叫“鉴权”,或者干脆大白话一点,叫“权限审查”也可以。

在开发者的角度来看,认证包含两个动作:

  1. 检验用户提交的凭据的真伪
  2. 根据凭据拿到更多的用户信息

授权也包含两个动作:

  1. 开发者需要事先定义好很多权限规则:比如学生只能看成绩,经理可以审批假条等
  2. 在处理用户请求的时候,拿着登录后拿到的用户信息,去套事先定义好的权限规则,来判断用户是否有权访问

2. 凭据这件事,其实也分两步

在用户这边,登录一个网站,只需要填写一次用户名和密码,后续的访问、跳转都不需要,或者至少短期内是不需要再输入用户名和密码了。就好像网站门前有个老大爷,在你输入完一次用户名和密码后,就认识你了一样,后续的出入都不再问“小伙子你哪位?这里不让外卖进”一样。

但在代码的世界里,门口的老大爷是纯粹的脸盲+老年痴呆:大爷记不住任何人的脸。

那大爷为什么不次次都找你要用户名和密码呢?因为凭据的原理,其实分两步:

  1. 你提交用户名+密码的时候,大爷去翻了花名册,证实了确实有这么个人,登录成功
  2. 然后紧接着,大爷还搁你脑门上贴了个纸条,只是这个纸条你看不见而已

后续你进出网站,你脑门上都贴了这个纸条,大爷是看见你脑门上有那个纸条,才放你进去的。

  • 大爷会验证纸条的真伪,对于伪造的纸条的人,大爷会在发现后把他赶出去。
  • 大爷会保证既没有人能造出以假乱真的纸条,大爷也会保证纸条上的内容无法被篡改、涂抹
  • 大部分大爷会在纸条上写个过期时间,超过这个过期时间后就要换新的纸条,旧纸条就无效了

这个纸条只是一个比喻,在现实世界里,这个纸条有两种最常见的实现方式

  1. 一是Cookie: 服务端返回的HTTP Response中,如果头部有Set-Cookie: xxxx字样,那么浏览器在收到这个Response后,会在后续所有对这个站点的访问请求中,在HTTP Request头部添加字段Cookie: xxx
  2. 二是借助于前端框架,把服务端以某种方式返回的纸条内容,塞进后续所有HTTP Request的头部其它字段中去,典型常用的是Authorization字段

这个HTTP Request的头部字段名叫Authorization,像是“授权”的意思。但实际上,服务端程序在处理这个HTTP Request的时候,是从这个字段里解析“认证”小纸条

要正确理解这个字段的语义,就得再回顾一下英文中这个单词的意思,我们上面讲了,Authorization这个单词在英文中有两个意思

  1. 指“审查某人是否有权限去做某事”这个过程
  1. 指某种能够证明我权限的文档或材料,比如“领导签字”

站在HTTP协议制订者,或者HTTP请求发送者的角度上来说,请求头部中的Authorization字段的语义是第二个意思。

理解到这个小知识点,你就能理解:为什么明明服务端是解析这个字段去做Authentication的,但这个字段的名字却叫Authorization

当我们以“纸条”为核心,去考虑如何以asp .net core middleware的方式实现网站的认证功能的时候,就可以总结出以下伪代码逻辑:

    public async Task AuthenticationMiddleware(HttpContext ctx, RequestDelegate next) {
        if(ctx.Request 中没有纸条,或者纸条过期,或者纸条不合法) {
            return 就地返回,引导用户去登录页面拿纸条
        }

        ctx.User = 解析纸条信息,拿到纸条中的用户信息;

        await next.InvokeAsync(ctx);
    }

而我们如果再延申一点,去考虑如何以asp .net core middleware的方式实现网站的权限,即授权功能的时候,可以总结出以下伪代码逻辑:

    public async Task AuthorizationMiddleware(HttpContext ctx, RequestDelegate next) {
        var policies = 从预先定义权限规则的地方,拿到权限规则;
        var requestPath = 拿到本次请求的访问路径;
        var userInfo = ctx.User;

        var isAllow = 判断本次请求是否合法(policies, requestPath, userInfo);

        if (isAllow) {
            await next.InvokeAsync(ctx);
        } else {
            ctx.Response.StatusCode = 401;
        }
    }

在asp .net core框架中,权限规则是分散成两部分的,具体哪两部分这个后续我们讲到AuthZ的细节的时候再细说,这里只提一下,框架允许程序员把权限信息以Attribute的形式写在各endpoint的脑门上,就像下面的Controller:

[Authorize(policy: "ManagersOnly")]
[ApiController]
[Route("api/[controller]")]
public class xxxController: ControllerBase
{
    // ...
}

这就导致在Authorization Middleware执行前,就需要先拿到这个请求对应的endpoint的信息,换句话说,在request pipeline中,EndpointRoutingMiddleware需要先执行,Authorization Middleware需要后执行。

而Authentication Middleware做的事只是解析纸条,并不需要endpoint的信息,所以在request pipeline中,EndpointRoutingMiddleware可以放在Authentication Middleware之后。

所以,通常情况下,在配置request pipeline的时候,需要遵循下面两个规则 :

  1. AuthN要配置在AuthZ之前:因为AuthZ依赖于ctx.User
  2. Routing要配置在AuthZ之前:因为AuthZ也依赖于ctx.Endpoint

理解到这点,再回头看下面这张出息asp .net core官方文档中的图,是不是又多了一点体会呢?

middleware_pipeline

3. asp .net core中,HttpContext是怎么存储用户信息的

我们上面的伪代码中,说过,认证的目的就是从HTTP请求中读纸条,然后解析纸条,从中解析出一个用户信息来。再把这个用户信息放在ctx.User字段中去。

虽然上面的代码是伪代码,但有一件事是真的:那就是在asp .net core中,沿着request pipeline传递的HttpContext对象,确实有个字段叫User,并且这个字段的作用,确实是用来存储认证结果的。

比如我们用dotnet new web --use-program-main -o HelloSimpleAuthN来创建一个空的web应用,并把它的Program.cs写成下面这样:

namespace HelloSimpleAuthN;

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

        app.MapGet("/", async ctx => 
        {
            await ctx.Response.WriteAsync("Hello");
        });

        app.Run();
    }
}

我们在那个唯一的endpoint执行体内打个断点,通过调试器就可以观察到ctx.User字段:虽然目前的代码没有认证功能,但这个字段是存在的

ctx_user

它并不像我们想当然的,里面用Username, FirstName, LastName, Email, Gender之类的常用字段来描述一个“用户”,而是里面有着ClaimsIdentitiesIdentity三个字段,让人有些许疑惑:

  1. 什么是Claims?
  2. 我知道identity的英文单词,意思是“身份”,那为什么它有一个Identity字段,还有一个复数形式的Identities字段呢?

要解答这些疑惑,就得介绍一下asp .net core中有关“用户”的三层概念:

  • Claim : 就是key-value-pair的另一个名字,一个Claim就是一个键值对
  • Identity : 由多个Claim构成,用来描述一个“身份”
  • Principal : 由一个或多个“身份”构成,用来描述一个“用户”

咱先不论这三个概念的语义,你先记住:

  1. Claim就是键值对
  2. 多个键值对构成的那个像字典一样的东西,叫Identity
  3. 一个或多个字典,构成的那个东西,叫Principal

说白了,Principal就是一个类似IList<Dictionary<string, string>>的东西。

先牢牢记住这三层概念在数据结构层面的样子,然后我们来介绍它们的语义:

什么是Principal

Principal这个概念,对应着框架里的System.Security.Claims.ClaimsPrincipal类型:它描述的是一个自然人,一个网站的用户。

asp .net core框架认为网站的认证授权过程中只能存在一个登录用户:即不存在一种场景,有两个人坐在一台电脑页面,希望同时登录他们两个的账号,继而将两个人有权限访问的内容都合并起来展示在浏览器上。

所以在框架的的默认实现中,HttpContext只持有一个User字段,当然了,这个字段的类型就是ClaimsPrincipal

什么是Identity

Identity概念对应着框架里的System.Security.Claims.ClaimsIdentity类型:它描述的是一个人的某种身份。

听起来好像一个自然人,会有多种身份,是吗?没错,asp .net core框架是这样认为的。怎么理解这个逻辑呢?

假设有一个大四的学生叫小明,那么小明作为一个自然人,其实同时有多个身份:

  1. 在学校里,他的身份是学生,那么当我们关注他的学生身份的时候,关注的就是诸如“读几年级、在哪个班、学什么专业、学号是多少、是不是学生会成员”之类的信息。
  2. 小明还有考了驾照,那么当小明开着他爹的车在马路上驰骋的时候,当我们关注他的“驾驶员”身份的时候,我们关注的就是诸如“驾照申领日期、准驾车型、是否在实习期内、当前被扣了几分”之类的信息
  3. 小明还找了份实习的工作,那么当小明在单位实习的时候,我们关注它的“企业雇员”身份的时候,我们关注的就是“入职时间、部门、级别”之类的信息

这样的例子在小明身上还能举出很多个,即:

  • 要完整、准确的描述一个自然人的话,我们需要很多的信息,乃至于其实我们无法在程序中准确的描述一个自然人,因为与一个自然人有关的信息,或者说数据,实在太多了:要不要记录他的性别?要不要记录他是否脱发?

  • 大多数时候,我们其实关注的只是一个人的一部分数据:而这一部分数据,其实描述的就是一个身份,一个Identity

    • 交警只关心小明的“驾驶员”身份
    • 导师只关心小明的“学生”身份
    • 小明的上司只关心小明的“企业雇员”身份

    而在程序的世界里,这个道理也是成立的:

    • 交管12123程序,只关心小明的“驾驶员”身份
    • 学校的信息系统,只关心小明的“学生”身份
    • 企业的OA系统,只关心小明的“雇员”身份

那么,作为一个网站开发框架,只用一个身份去描述它的用户,合理吗?在95%的情况下是合理的,在另外5%的情况下是不合理的:因为有些网站可能同时会关注用户的好几个身份。

比如小明所在的学校与某几个企业的老板一拍脑门,校领导与企业老板决定让校企合作更粘稠一些:我们把企业OA和学校信息系统集成起来吧!这样学生用身份证号作用户名登录后,就既能查看学校方面的信息,也能查看自己的实习企业信息了,既能在上面选课,也能申请加入企业的一些实习项目。

那么这个应用,就会同时关注用户的多个身份:在访问校内资源的时候,程序要检查小明的“学生”身份相关的信息,来审阅权限,在访问企业资源的时候,程序要检查小时的“雇员”身份相关信息,来审阅权限。

所以,asp .net core框架认为:一个自然人,即ClaimsPrincipal,可以有一个或多个身份,即ClaimsIdentity

所以在ClaimsPrincipal类的定义中,你会看到有个属性的名字叫Identities,是复数形式,类型是IEnumerable<ClaimsIdentity>

虽然在ClaimsPrincipal类的定义中,也有一个属性的名字叫Identity,但请注意:

  1. 如果一个用户只有一个身份的话,可以拿Identity属性作为快速访问这个身份的一个语法糖去用,没什么问题

  2. 如果一个用户有多种身份的话,就不要贸然使用Identity属性了,具体原因见官方文档对这个字段的说明,我大致翻译一下,内容如下:

    这个字段的语义是“主要身份”

    默认情况下,框架会优先将实际类型为WindowsIdentity的对象列为“主要身份”

    如果没有WindowsIdentity的实例的话,框架就会将Identities属性中的第一个身份返回

    要改写主要身份的筛选逻辑,你需要写个新类去继承ClaimsPrincipal类,再覆盖PrimaryIdentitySelector方法,把“主要身份”的筛选逻辑放在里面

    如果你对上面的内容感到莫名其妙,那么就忽略它就可以了,先不要管这个奇怪的Identity属性

什么是Claim

我们上面说了,一个身份是多份数据的集合。而Claim这个概念,就是“数据”的意思。它对应着框架里的System.Security.Claims.Claim类。

大多数时候,描述身份的数据,其实就是个键值对而已,比如以下的例子:

性别
年龄 21
职位 普通员工
年级 大四
专业 采矿工程

所以Claim的本质就是个键值对:Claim.TypeClaim.Value分别描述了键和值。这两个字段都是string类型。

不过框架为了更好的适应各种奇形怪状的需求以及提供给程序员尽可能多的扩展性,在键值对的基础上,还给Claim类扩展了其它字段,这些字段我们目前忽视它们。

如何在代码中创建出一个用户

理解了Claim, Identity, Principal三层概念,也了解了它们对应的在asp .net core框架中的类型后,就很容易的能在代码中创建出一个用户出来,即ClaimsPrincipal实例,如下:

using System.Security.Claims;

namespace HelloSimpleAuthN;

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

        app.Use(async (ctx, next) => 
        { 
            List<Claim> claimsOfXiaoMingAsAStudent = new List<Claim> 
            {
                new Claim("name", "Xiao Ming"),
                new Claim("major", "Mining Engineering"),
                new Claim("grade", "junior"),
                new Claim("is member of union", "false")
            };

            List<Claim> claimsOfXiaoMingAsADriver = new List<Claim>
            {
                new Claim("name", "Xiao Ming"),
                new Claim("age", "21"),
                new Claim("type", "C2"),
                new Claim("issuance date", "2024-03-17"),
                new Claim("expiration date", "2030-03-16")
            };

            List<Claim> claimsOfXiaoMingAsAnEmployee = new List<Claim>
            {
                new Claim("name", "Xiao Ming"),
                new Claim("age", "21"),
                new Claim("title", "intern engineer"),
                new Claim("department", "Engineering/Tunneling Team 3")
            };

            List<ClaimsIdentity> identitiesOfXiaoMing = new List<ClaimsIdentity>
            {
                new ClaimsIdentity(claimsOfXiaoMingAsAStudent),
                new ClaimsIdentity(claimsOfXiaoMingAsADriver),
                new ClaimsIdentity(claimsOfXiaoMingAsAnEmployee),
            };

            ClaimsPrincipal xiaoming = new ClaimsPrincipal(identitiesOfXiaoMing);

            ctx.User = xiaoming;

            await next.Invoke(ctx);
        });

        app.MapGet("/", async ctx => 
        {
            await ctx.Response.WriteAsync("Hello");
        });

        app.Run();
    }
}

上面的代码确实在处理每个Http请求的时候向ctx.User写入了一个用户信息,但问题是:这个用户是在代码中写死的,而不是从“HTTP请求脑门上的纸条”中读取出来的。

我们上面说了,凭据这事,其实有两步:

  • 第一步:用户提供类似于“用户名+密码”的东西,来换取“脑门上的纸条”
  • 第二步:在后续请求中,用户脑门上附带上一步换取的纸条,框架再从纸条中解读出用户信息

上面的代码没有第一步,即没有“登录”体验。对于第二步,用户信息也不是从某个纸条中获取的,而是无中生有直接生出来的。

4. 换个角度从0开始思考“认证”的本质

在这一章节,我们从0开始思考“认证”的本质,并一步步的用代码去实现它。

4.1 最简单的认证

现在我们抛弃框架,假设asp .net core没有提供任何有关认证或授权的基础设施,在这种情况下,我们想实现一个“认证”功能,需要做什么?

  1. 要有一个“登录”页面:用户访问他之后就能获得纸条
  2. 要有“纸条”机制:由于cookie机制是内嵌在浏览器功能里的,所以我们选择使用cookie去存储纸条

我们设计这样一个网站,整个网站只有三个endpoint:

  1. "/"根目录页面用来从cookie中读取用户信息,并展示在页面上
  2. "/login"页面即是"登录页面",用户访问这个页面,即可得到一个“纸条”。而为了方便起见,我们不需要用户输入用户名与密码,而是直接在代码中写死一个用户信息,写入cookie中
  3. "/logout"页面即是“注销页面”,用户访问这个页面,即可擦除掉登录获得的纸条。在cookie机制中,擦除纸条就是将已经设置好的某个cookie的值设置为空

而要实现这样一个简陋到令人发指的网站,代码也简单直观到令人发指,如下:

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

        app.MapGet("/login", async ctx => 
        {
            ctx.Response.Headers.SetCookie = "userinfo=username:Alice,gender:female,age:18";

            await ctx.Response.WriteAsync($"You've just logged in as Alice");
        });

        app.MapGet("/logout", async ctx =>
        {
            ctx.Response.Headers.SetCookie = "userinfo=";

            await ctx.Response.WriteAsync($"You've just logged out");
        });

        app.MapGet("/", async ctx => 
        {
            string? userinfo = null;
            foreach(var cookieKvp in ctx.Request.Headers.Cookie)
            {
                if(cookieKvp.StartsWith("userinfo"))
                {
                    userinfo = cookieKvp.Split("=")[1];
                    break;
                }
            }

            if(string.IsNullOrEmpty(userinfo))
            {
                await ctx.Response.WriteAsync($"You are not login yet, please visit /login to login");
            }
            else
            {
                await ctx.Response.WriteAsync($"You currently logged in as {userinfo}");
            }

        });

        app.Run();
    }
}

运行效果如下:

authN_v1

以上的逻辑非常简单,我们甚至都不需要使用到框架为我们设计的ClaimsPrincipal/ClaimsIdentity/Claim类以及HttpContext.User属性,就已经从本质上说明了“认证”的本质。

4.2 将登录与注销的逻辑抽离出来

在不更改逻辑的情况下,我们可以将登录、注销、认证的逻辑从endpoint中抽离出来,封装在一个独立的类中,在不改变上述代码的逻辑的前提下,我们就可以做如下的重构:

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

        builder.Services.AddHttpContextAccessor();
        builder.Services.AddScoped<AuthService>();

        var app = builder.Build();

        app.MapGet("/login", async ctx => 
        {
            AuthService auth = ctx.RequestServices.GetRequiredService<AuthService>();
            string userinfo = "username:Alice,gender:female,age:18";
            auth.Signin(userinfo);
            await ctx.Response.WriteAsync($"You've just logged in as Alice");
        });

        app.MapGet("/logout", async ctx =>
        {
            AuthService auth = ctx.RequestServices.GetRequiredService<AuthService>();
            auth.Signout();
            await ctx.Response.WriteAsync($"You've just logged out");
        });

        app.MapGet("/", async ctx => 
        {
            AuthService auth = ctx.RequestServices.GetRequiredService<AuthService>();
            string? userinfo = auth.Authenticate();

            if(string.IsNullOrEmpty(userinfo))
            {
                await ctx.Response.WriteAsync($"You are not login yet, please visit /login to login");
            }
            else
            {
                await ctx.Response.WriteAsync($"You currently logged in as {userinfo}");
            }

        });

        app.Run();
    }
}
public class AuthService
{
    IHttpContextAccessor ctxAccessor;

    public AuthService(IHttpContextAccessor ctxAccessor)
    {
        this.ctxAccessor = ctxAccessor;
    }

    public void Signin(string userinfo)
    {
        ctxAccessor.HttpContext!.Response.Headers.SetCookie = $"userinfo={userinfo}";
    }

    public void Signout()
    {
        ctxAccessor.HttpContext!.Response.Headers.SetCookie = $"userinfo=";
    }

    public string? Authenticate()
    {
        string? userinfo = null;
        foreach (var cookieKvp in ctxAccessor.HttpContext!.Request.Headers.Cookie)
        {
            if (cookieKvp.StartsWith("userinfo"))
            {
                userinfo = cookieKvp.Split("=")[1];
                break;
            }
        }
        return userinfo;
    }
}

将逻辑抽离出来最大的好处,就是endpoint不用再关心登录、注销、认证的具体实现细节了,实现了逻辑上的解耦。

上面有一个知识点,即IHttpContextAccessor,这里简单的过一下:

  1. 在request pipeline和endpoint中的代码能直接访问到HttpContext实例,这没什么问题。但像上述AuthService这种类,它与request pipeline和endpoint是解耦的,是无法直接访问到HttpContext对象的
  2. 框架实现了一个访问器,类型是IHttpContextAccessor,但这个访问器默认情况下是没有注册到DI池中的,需要通过手动调用builder.Services.AddHttpContextAccessor()来将其进行注册
  3. 我们的AuthService在实现时将IHttpContextAccessor声明成构造函数的参数,又被注册到DI池中,生命周期为Scoped。这种情况下,当我们在一次Http请求的处理过程中,首次通过ctx.RequestServices.GetRequiredService<AuthService>()来获取AuthService的实例时,DI池就会为我们新创建一个AuthService对象,创建时填充构造函数用的就是之前注册好的IHttpContextAccessor对象

在上例中,其实将AuthService的生命周期声明为TransientSingleton,都可以实现相同的效果,因为

  1. AuthService虽然在访问HttpContext,但每次访问ctx.Accessor.HttpContext时,拿到的都是“当前的HttpContext
  2. AuthService整个类里的三个功能,除了HttpContext实例之外,不依赖任何状态,自身也没有保存任何状态,所以从纯代码逻辑的角度来说,这个对象应当在DI池中被注册为Singleton

4.3 将认证纸条加密起来

以上代码的最大、最明显的问题是:cookie依赖于浏览器的默认行为,但cookie其实是可以篡改的,如下:

authN_v2_hack

在实际中,认证使用的纸条应当满足如下的条件:

  1. 只有服务端才能制造出正确的纸条,其它人不能凭空制造纸条
  2. 大爷能鉴别出纸条是否被涂改,其它人不能篡改纸条
  3. 纸条只有大爷才能解析,其它人不能解析纸条
  4. 由于纸条是暴露给浏览器的,所以纸条其实不能防止泄漏,但为了降低这方面的危害,纸条应当有过期时间

这四条的核心在于:

  1. 不能明文传递纸条,必须使用某种加密手段,最好是非对称加密
  2. 必须为纸条定义一个过期时间

所以我们可以把上面的代码升级成下面这样:

 public class Program
 {
     public static void Main(string[] args)
     {
         var builder = WebApplication.CreateBuilder(args);
 
+        builder.Services.AddDataProtection();
         builder.Services.AddHttpContextAccessor();
         builder.Services.AddScoped<AuthService>();
 
         var app = builder.Build();
 
         app.MapGet("/login", async ctx => 
         {
             AuthService auth = ctx.RequestServices.GetRequiredService<AuthService>();
             string userinfo = "username:Alice,gender:female,age:18";
             auth.Signin(userinfo);
             await ctx.Response.WriteAsync($"You've just logged in as Alice");
         });
 
         app.MapGet("/logout", async ctx =>
         {
             AuthService auth = ctx.RequestServices.GetRequiredService<AuthService>();
             auth.Signout();
             await ctx.Response.WriteAsync($"You've just logged out");
         });
 
         app.MapGet("/", async ctx => 
         {
             AuthService auth = ctx.RequestServices.GetRequiredService<AuthService>();
             string? userinfo = auth.Authenticate();
 
             if(string.IsNullOrEmpty(userinfo))
             {
                 await ctx.Response.WriteAsync($"You are not login yet, please visit /login to login");
             }
             else
             {
                 await ctx.Response.WriteAsync($"You currently logged in as {userinfo}");
             }
 
         });
 
         app.Run();
     }
 }
 public class AuthService
 {
     IHttpContextAccessor ctxAccessor;
+    IDataProtectionProvider idp;
+
+    IDataProtector DataProtector => this.idp.CreateProtector("dp for authentication");
+
+    long CurrentTimestamp => new DateTimeOffset(DateTime.UtcNow).ToUnixTimeSeconds();
 
-    public AuthService(IHttpContextAccessor ctxAccessor)
-    {
-        this.ctxAccessor = ctxAccessor;
-    }
+    public AuthService(IHttpContextAccessor ctxAccessor, IDataProtectionProvider idp)
+    {
+        this.ctxAccessor = ctxAccessor;
+        this.idp = idp;
+    }
 
     public void Signin(string userinfo)
     {
-        ctxAccessor.HttpContext!.Response.Headers.SetCookie = $"userinfo={userinfo}";
+        ctxAccessor.HttpContext!.Response.Headers.SetCookie = new Microsoft.Extensions.Primitives.StringValues(
+            new string[] 
+            {
+                $"userinfo={this.DataProtector.Protect(userinfo)}",
+                $"userinfo_expire_time={this.DataProtector.Protect((this.CurrentTimestamp + 10).ToString())}"
+            });
     }
 
     public void Signout()
     {
-        ctxAccessor.HttpContext!.Response.Headers.SetCookie = $"userinfo=";
+        ctxAccessor.HttpContext!.Response.Headers.SetCookie = new Microsoft.Extensions.Primitives.StringValues(
+            new string[]
+            {
+                "userinfo=",
+                "userinfo_expire_time="
+            });
     }
 
     public string? Authenticate()
     {
-        string? userinfo = null;
-        foreach (var cookieKvp in ctxAccessor.HttpContext!.Request.Headers.Cookie)
-        {
-            if (cookieKvp.StartsWith("userinfo"))
-            {
-                userinfo = cookieKvp.Split("=")[1];
-                break;
-            }
-        }
-        return userinfo;
+        string? userinfo = null;
+        bool expired = true;
+
+        foreach (var cookieKvp in ctxAccessor.HttpContext!.Request.Headers.Cookie)
+        {
+            try
+            {
+                string key = cookieKvp.Split("=")[0];
+                string value = cookieKvp.Split("=")[1];
+                if (key == "userinfo")
+                {
+                    userinfo = this.DataProtector.Unprotect(value);
+                }
+
+                if (key == "userinfo_expire_time")
+                {
+                    string expireTimeStr = this.DataProtector.Unprotect(value);
+                    if (long.Parse(expireTimeStr) > this.CurrentTimestamp)
+                    {
+                        expired = false;
+                    }
+                }
+            }
+            catch
+            {
+                userinfo = null;
+                break;
+            }
+        }
+
+        return expired ? null : userinfo;
     }
 }

运行效果如下,首先是登录的用户十秒后纸条过期,需要重新登录:

authN_v3_expire

其次是cookie的内容是加密的:无法假冒,甚至无法篡改,篡改后的cookie会导致解析的时候抛出异步,从而判定为用户信息不存在:

authN_v3_hack

除了新增一个cookie写明过期时间外,以上代码最大的改动是引入了asp .net core框架内置的数据加密API,即我们在DI池中调用的那个builder.Services.AddDataProtection(),这行调用的作用是:向DI池中注册了一个IDataProtectionProvider对象。

通过这个IDataProtectionProvider对象,可以调用CreateProtector(string)方法来获取到一个加密解密器IDataProtector。通过一个IDataProtectionProvider可以获取到N多个加密解密器,而CreateProtector(string)中的参数就起到区分这个加密解密器的作用,你可以把参数理解为“加密解密器的名字”。

加密解密默认情况下使用的是对称加密算法,这也很符合直觉,因为加密和解密都在同一个地点执行,即服务端。而这有一个关键问题,即算法的密钥,存放在哪里?默认情况下,密钥存储在%LOCALAPPDATA%\ASP.NET\DataProtection-Keys目录,但我们可以通过以下的方式在代码中明确指定将密钥存储在文件系统指定路径,或者数据库中(我们目前还未涉及如何连接数据库,先感受一下就行了)

builder.Services.AddDataProtection()
    .PersistKeysToFileSystem(new DirectoryInfo(@"\\server\share\directory\"));
builder.Services.AddDataProtection()
    .PersistKeysToDbContext<SampleDbContext>();

对数据库来说,事情稍微麻烦一点,对应的DbContext类必须实现IDataProtectionKeyContext接口,不过这是后话,我们讲到数据库的时候再细聊。

OK把话题拉回到认证方面:我们上面的代码已经用手撸cookie的方式实现了asp .net core框架中AuthenticationMiddleware的核心功能了,下一步我们要做的,就是把用户信息包装在一个ClaimsPrincipal中,赋给ctx.User,并且把“读纸条解析用户信息”这件事,封装成一个Middleware

4.4 使用ctx.User保存用户信息并把认证功能写成middleware

代码如下:

 public class Program
 {
     public static void Main(string[] args)
     {
         var builder = WebApplication.CreateBuilder(args);
 
         builder.Services.AddDataProtection();
         builder.Services.AddHttpContextAccessor();
         builder.Services.AddScoped<AuthService>();
+        builder.Services.AddScoped<BasicCookieAuthMiddleware>();
 
         var app = builder.Build();
+
+        app.UseBasicCookieAuth();
 
         app.MapGet("/login", async ctx => 
         {
             AuthService auth = ctx.RequestServices.GetRequiredService<AuthService>();
             string userinfo = "username:Alice,gender:female,age:18";
             auth.Signin(userinfo);
             await ctx.Response.WriteAsync($"You've just logged in as Alice");
         });
 
         app.MapGet("/logout", async ctx =>
         {
             AuthService auth = ctx.RequestServices.GetRequiredService<AuthService>();
             auth.Signout();
             await ctx.Response.WriteAsync($"You've just logged out");
         });
 
         app.MapGet("/", async ctx => 
         {
-            AuthService auth = ctx.RequestServices.GetRequiredService<AuthService>();
-            string? userinfo = auth.Authenticate();
-
-            if(string.IsNullOrEmpty(userinfo))
-            {
-                await ctx.Response.WriteAsync($"You are not login yet, please visit /login to login");
-            }
-            else
-            {
-                await ctx.Response.WriteAsync($"You currently logged in as {userinfo}");
-            }
+            await ctx.Response.WriteAsync($"<h1>claims count == {ctx.User.Claims.Count()}</h1>");
+            foreach(var claim in ctx.User.Claims)
+            {
+                await ctx.Response.WriteAsync($"<h1>{claim.Type} : {claim.Value}</h1>");
+            }
         });
 
         app.Run();
     }
 }
 
+public class BasicCookieAuthMiddleware : IMiddleware
+{
+    private AuthService auth;
+    public BasicCookieAuthMiddleware(AuthService auth)
+    {
+        this.auth = auth;
+    }
+    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
+    {
+        this.auth.Authenticate();
+        await next.Invoke(context);
+    }
+}
+
+public static class BasicCookieAuthMiddlewareExtensions
+{
+    public static void UseBasicCookieAuth(this IApplicationBuilder app )
+    {
+        app.UseMiddleware<BasicCookieAuthMiddleware>();
+    }
+}
+
 public class AuthService
 {
     IHttpContextAccessor ctxAccessor;
     IDataProtectionProvider idp;
 
     IDataProtector DataProtector => this.idp.CreateProtector("dp for authentication");
 
     long CurrentTimestamp => new DateTimeOffset(DateTime.UtcNow).ToUnixTimeSeconds();
 
     public AuthService(IHttpContextAccessor ctxAccessor, IDataProtectionProvider idp)
     {
         this.ctxAccessor = ctxAccessor;
         this.idp = idp;
     }
 
     public void Signin(string userinfo)
     {
         ctxAccessor.HttpContext!.Response.Headers.SetCookie = new Microsoft.Extensions.Primitives.StringValues(
             new string[] 
             {
                 $"userinfo={this.DataProtector.Protect(userinfo)}",
                 $"userinfo_expire_time={this.DataProtector.Protect((this.CurrentTimestamp + 10).ToString())}"
             });
     }
 
     public void Signout()
     {
         ctxAccessor.HttpContext!.Response.Headers.SetCookie = new Microsoft.Extensions.Primitives.StringValues(
             new string[]
             {
                 "userinfo=",
                 "userinfo_expire_time="
             });
     }
 
-    public string? Authenticate()
+    public void Authenticate()
     {
         string? userinfo = null;
         bool expired = true;
 
         foreach (var cookieKvp in ctxAccessor.HttpContext!.Request.Headers.Cookie)
         {
             try
             {
                 string key = cookieKvp.Split("=")[0];
                 string value = cookieKvp.Split("=")[1];
                 if (key == "userinfo")
                 {
                     userinfo = this.DataProtector.Unprotect(value);
                 }
 
                 if (key == "userinfo_expire_time")
                 {
                     string expireTimeStr = this.DataProtector.Unprotect(value);
                     if (long.Parse(expireTimeStr) > this.CurrentTimestamp)
                     {
                         expired = false;
                     }
                 }
             }
             catch
             {
                 userinfo = null;
                 break;
             }
         }

-        return expired ? null : userinfo;
+
+        if(!expired && userinfo is not null)
+        {
+            IEnumerable<KeyValuePair<string, string>> userClaims = userinfo.Split(",").Select(kvpStr => new KeyValuePair<string, string>(kvpStr.Split(":")[0], kvpStr.Split(":")[1]));
+            IEnumerable<Claim> claims = userClaims.Select(kvp => new Claim(kvp.Key, kvp.Value));
+            ClaimsIdentity identity = new ClaimsIdentity(claims);
+            ClaimsPrincipal principal = new ClaimsPrincipal(identity);
+            ctxAccessor.HttpContext.User = principal;
+        }
     }
 }

上面我们的代码改动做了这么几件事:

  1. AuthService中,把Authenticate方法的返回值改成了void:因为现在如果成功的从纸条中解析到用户信息的话,可以直接把用户信息写在HttpContext.User字段中

  2. 我们把认证逻辑包装成了一个Middleware,于是就有了BasicCookieAuthMiddleware类和BasicCookieAuthMiddlewareExtensions

    • 之前我们把解析纸条的逻辑直接写在endpoint "/"的逻辑里,这导致其它endpoint如果也需要解析用户信息,就需要再调用一次AuthService.Authenticate()方法,现在好了,在所有endpoint执行之前,有BasicCookieAuthMiddleware去解析用户信息,并写在ctx.User
    • 注意Middleware本身也需要作为一个Service向DI池注册
  3. 登录和注销的逻辑没有更改,依然是在endpoint中直接调用AuthServiceSigninSignout方法来实现登录与注销

  4. endpoint "/"现在渲染的是ctx.User中的Claims

现在的运行效果如下所示:

接下来,我们再进一步,把登录与注销两部分逻辑,写成HttpContext的扩展方法,让endpoint的逻辑更清晰一些

4.5 把登录与注销的逻辑包装成HttpContext的扩展方法

我们进一步对代码做如下改动:

 public class Program
 {
 
     public static void Main(string[] args)
     {
         var builder = WebApplication.CreateBuilder(args);
 
         builder.Services.AddDataProtection();
         builder.Services.AddHttpContextAccessor();
         builder.Services.AddScoped<AuthService>();
         builder.Services.AddScoped<BasicCookieAuthMiddleware>();
 
         var app = builder.Build();
 
         app.UseBasicCookieAuth();
 
         app.MapGet("/login", async ctx => 
         {
+            ctx.BasicCookieAuthSignin("username:Alice,gender:female,age:18");
-            AuthService auth = ctx.RequestServices.GetRequiredService<AuthService>();
-            string userinfo = "username:Alice,gender:female,age:18";
-            auth.Signin(userinfo);
             await ctx.Response.WriteAsync($"You've just logged in as Alice");
         });
 
         app.MapGet("/logout", async ctx =>
         {
+            ctx.BasicCookieAuthSignout();
-            AuthService auth = ctx.RequestServices.GetRequiredService<AuthService>();
-            auth.Signout();
             await ctx.Response.WriteAsync($"You've just logged out");
         });
 
         app.MapGet("/", async ctx => 
         {
             await ctx.Response.WriteAsync($"<h1>claims count == {ctx.User.Claims.Count()}</h1>");
             foreach(var claim in ctx.User.Claims)
             {
                 await ctx.Response.WriteAsync($"<h1>{claim.Type} : {claim.Value}</h1>");
             }
         });
 
         app.Run();
     }
 }

 // ...
 // ...

+public static class BasicCookieAuthServiceExtensions
+{
+    public static void BasicCookieAuthSignin(this HttpContext ctx, string userinfo)
+    {
+        ctx.RequestServices.GetRequiredService<AuthService>().Signin(userinfo);
+    }
+
+    public static void BasicCookieAuthSignout(this HttpContext ctx)
+    {
+        ctx.RequestServices.GetRequiredService<AuthService>().Signout();
+    }
+}

 // ...
 // ...

现在就非常像样了,这基本就是框架中认证的实现思路,当然我们实现的这个玩意只是大的指导思想与框架的实现吻合,细节上差得还是很多。

上面的代码除了实现简陋,且并没有实现“校验用户提交的用户名+密码”功能外,其实还有一个问题,就是如果你在endpoint "/"的实现体中打个断点的话,你会看到,ctx.User.Identity.IsAuthenticated属性的值是false

我们上面介绍了有关ClaimsIdentity的知识,但没有提到这个类下的这个叫IsAuthenticated的属性,按常理来说,这个字段从英文单词的意思上来看,应当是在指代“当前这个用户的身份是否是认证得来的”,或者直接一点,用ctx.User.Identity.IsAuthenticated来判断当前HTTP请求是否成功的通过了认证相关的middleware。

那么怎么样才能让这个字段为true呢?如果你像我一样去翻官方文档,你会发现:

is_authenticated_property

  • 这个IsAuthenticated是一个只读字段
  • 这个字段的语义是“用来指示当前身份是否是通过认证得来的”,或者叫“指示当前身份是否通过了认证”
  • 这个字段的值,在文档“Remarks”小节里,又单独说了,是与另外一个叫AuthenticationType的属性是绑定的,基本等价于!string.IsNullOrEmpty(this.AuthenticationType)表达式
  • 在创建ClaimsIdentity对象的时候,我们没有为这个属性赋值的机会,没有任何构造函数可以直接指定IsAuthenticated属性的值

如果你接着去翻文档去看AuthenticationType字段是什么鬼,你会发现:

  • AuthenticationType也是一个只读字段
  • 文档对该字段的描述就如同这个字段的命名一样直白
  • 去翻看构造函数列表的时候,有大量的构造函数接受一个名为authenticationType的参数,就是在为该属性赋值

OK,来总结一下:

  • 构造ClaimsIdentity的时候,如果不提供authenticationType参数,那么会导致构造出的对象的AuthenticationType字段是空的,会接着导致IsAuthenticated属性的值是false

现在问题来了:这个“authentication type”的概念,到底是什么东西?要说明白这个问题,还稍微有一点麻烦。先把这个问题埋在心里,接着继续往下看。

4.6 暂停分析一下当前的知识

现在我们所写的代码,就基本是把框架内附带的Cookie认证相关的基础设施手撸了一遍,当然撸出来的是一个极简版的认证。

我们现在来回顾一下我们做了哪些事情:

  1. 把所有与认证相关的逻辑都封装在一个独立的类中,即是上面的AuthService

    • 这个类使用IHttpContextAccessor来访问HttpContext对象

    • 这个类使用IDataProtectionProvider来加密纸条

    • 这个类的功能分两类:

      1. 登录与注销:登录与注销的本质,是在创建和销毁纸条
      2. 认证:认证的本质,其实是在解析纸条。在该类中,纸条的解析结果直接写进了HttpContext.User对象中
  2. 我们把AuthService中的登录与注销功能,封装在了BasicCookieAuthServiceExtensions类的扩展方法中,这样可以使得我们的/login/logout两个endpoint的逻辑更清晰

  3. 我们把AuthService中的认证逻辑,封装在了BasicCookieAuthMiddleware中写成了middleware,这样使得所有需要查看用户信息的endpoint不需要再关心认证逻辑

上面的代码除了简陋之外,还有什么缺陷吗?

目前能观察到的一个缺陷,就是:AuthService应当分层,以支持进一步扩展。

这话是什么意思呢?是指我们目前,把所有的逻辑实现都直接写在了AuthService中,而无论是BasicCookieAuthServiceExtensions中的扩展方法,还是BasicCookieAuthMiddleware,都直接的、明晃晃的依赖着这个实现类。

这太耦合了,我们应该把AuthService抽象成:接口+实现两层。

4.7 进一步改造的思路:将AuthService接口化

我们应该定义一个叫IAuthService的接口,里面大致有以下的方法

public interface IAuthService
{
    // 从纸条中获取用户信息,并写入ctx.User中
    // 应该被封装在middleware中
    void Authenticate(HttpContext ctx);

    // 使用户登入
    // 对基于cookie机制的实现来说,就是把principal序列化后写入Http Response的头部字段set-cookie字段中
    // 应该被封装在 this HttpContext ctx 的扩展方法中
    void SignIn(HttpContext ctx, ClaimsPrincipal principal);

    // 使用户注销
    // 对基于cookie机制的实现来说,就是清空现有的cookie
    // 应该被封闭在 this HttpContext ctx 的扩展方法中
    void SignOut(HttpContext ctx);
}

而具体的实现逻辑,放在实现类CookieAuthService中。这样的好处就是我们后续可以写很多不同的实现类,来支持各种各校的“纸条”的实现,不需要局限在cookie机制上。

并且即便是基于cookie机制,我们也可以使用不同的具体实现,使用不同的加密解密器等。

现在我们把示例程序拆分成多个文件结构,以便后续继续升级改造,目前将AuthService接口化后的项目目录结构如下:

HelloSimpleAuthN
    |
    \--> HelloSimpleAuthN.csproj
    \--> ISimpleAuthNService.cs
    \--> Program.cs
    \--> SimpleAuthNMiddleware.cs
    \--> SimpleCookieAuthNService.cs

核心接口,对认证的抽象,ISimpleAuthNService.cs的内容如下:

using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using System.Security.Claims;

namespace HelloSimpleAuthN;

public interface ISimpleAuthNService
{
    void Authenticate(HttpContext ctx);
    void SignIn(HttpContext ctx, ClaimsPrincipal principal);
    void SignOut(HttpContext ctx);
}

public static class SimpleAuthNServiceExtensions
{
    public static void SimpleAuthNSignIn(this HttpContext ctx, ClaimsPrincipal principal)
    {
        ctx.RequestServices.GetRequiredService<ISimpleAuthNService>().SignIn(ctx, principal);
    }

    public static void SimpleAuthNSignOut(this HttpContext ctx)
    {
        ctx.RequestServices.GetRequiredService<ISimpleAuthNService>().SignOut(ctx);
    }
}

既然我们已经把“认证的功能”抽象成了接口,那么很自然的,我们就可以根据该接口,写出SimpleAuthNSignInSimpleAuthNSignOut两个扩展方法,在endpoint或middleware中可以直接通过ctx.SimpleAuthNSign[In|Out]去方便的调用,实现登录与注销功能。endpiont和middleware的代码无需去关心登录与注销的底层实现是怎么回事。

更自然的,我们基于这个接口就可以直接写出负责读纸条的middleware,就是SimpleAuthNMiddleware.cs,内容如下:

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using System.Threading.Tasks;

namespace HelloSimpleAuthN;

public class SimpleAuthNMiddleware : IMiddleware
{
    private ISimpleAuthNService authService;

    public SimpleAuthNMiddleware(ISimpleAuthNService authService)
    {
        this.authService = authService;
    }
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        this.authService.Authenticate(context);
        await next.Invoke(context);
    }
}

public static class SimpleAuthNMiddlewareExtensions
{
    public static void UseSimpleAuthN(this IApplicationBuilder app)
    {
        app.UseMiddleware<SimpleAuthNMiddleware>();
    }
}

这个middleware同样不关心认证的具体实现,只关心接口。同样只关心接口,不关心实现的还有我们的主逻辑代码Program.cs,内容如下:

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;

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

        builder.Services.AddDataProtection();
        builder.Services.AddScoped<ISimpleAuthNService, SimpleCookieAuthNService>();
        builder.Services.AddScoped<SimpleAuthNMiddleware>();

        var app = builder.Build();

        app.UseSimpleAuthN();

        app.MapGet("/login", async ctx => 
        {
            ClaimsPrincipal alice = new ClaimsPrincipal(new ClaimsIdentity(new List<Claim> { 
                new Claim("Name", "Alice"),
                new Claim("Gender", "Female"),
                new Claim("Age", "18"),
            }));
            ctx.SimpleAuthNSignIn(alice);
            await ctx.Response.WriteAsync($"You've just logged in as Alice");
        });

        app.MapGet("/logout", async ctx => 
        {
            ctx.SimpleAuthNSignOut();
            await ctx.Response.WriteAsync($"You've just logged out");
        });

        app.MapGet("/", async ctx => 
        {
            await ctx.Response.WriteAsync($"<h1>claims count == {ctx.User.Claims.Count()}</h1>");
            foreach(var claim in ctx.User.Claims)
            {
                await ctx.Response.WriteAsync($"<h1>{claim.Type} : {claim.Value}</h1>");
            }
        });

        app.Run();
    }
}

在三个endpoint的执行代码中,没有任何人关心认证/登录/注销的具体实现是怎么回事,所有人都是在直接调用扩展方法,依赖接口。这极大的降低了业务逻辑程序员的心智负担。

整个Program.cs中,唯一与认证/登录/注销的具体实现相关的代码,只有一行:就是builder.Services.AddScoped<ISimpleAuthNService, SimpleCookieAuthNService>();这一行:毕竟其它地方再不在乎具体实现,那DI池子里也得有个对象真的在干活呀!干活的这个对象的类,即是SimpleAuthNMiddleware.cs,内容如下:

using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;

namespace HelloSimpleAuthN;

public class SimpleCookieAuthNService : ISimpleAuthNService
{

    private IDataProtectionProvider idp;

    private IDataProtector DataProtector => this.idp.CreateProtector("dp for simple cookie authN");

    private long CurrentTimestamp => new DateTimeOffset(DateTime.UtcNow).ToUnixTimeSeconds();

    public long ExpireTimeInSeconds { get; set; }

    public string CookieName { get; set; }

    public SimpleCookieAuthNService(IDataProtectionProvider idp)
    {
        this.idp = idp;
        this.ExpireTimeInSeconds = 10;
        this.CookieName = "userinfo";
    }

    public void Authenticate(HttpContext ctx)
    {
        bool expired = true;
        string? userInfoStr = null;
        foreach (var cookieKvp in ctx.Request.Headers.Cookie)
        {
            try
            {
                string key = cookieKvp.Split("=")[0];
                string value = cookieKvp.Split("=")[1];
                if (key == this.CookieName)
                {
                    userInfoStr = this.DataProtector.Unprotect(value);
                }

                if (key == $"{this.CookieName}_expire_time")
                {
                    string expireTimeStr = this.DataProtector.Unprotect(value);
                    if (long.Parse(expireTimeStr) > this.CurrentTimestamp)
                    {
                        expired = false;
                    }
                }
            }
            catch
            {
                userInfoStr = null;
                break;
            }
        }

        if (!expired && userInfoStr is not null)
        {
            IEnumerable<KeyValuePair<string, string>> userClaims = userInfoStr.Split(",").Select(kvpStr => new KeyValuePair<string, string>(kvpStr.Split(":")[0], kvpStr.Split(":")[1]));
            IEnumerable<Claim> claims = userClaims.Select(kvp => new Claim(kvp.Key, kvp.Value));
            ClaimsIdentity identity = new ClaimsIdentity(claims);
            ClaimsPrincipal principal = new ClaimsPrincipal(identity);
            ctx.User = principal;
        }
    }

    public void SignIn(HttpContext ctx, ClaimsPrincipal principal)
    {
        string userInfoStr = string.Join(",", principal.Claims.Select(claim => $"{claim.Type}:{claim.Value}"));
        ctx.Response.Headers.SetCookie = new Microsoft.Extensions.Primitives.StringValues(
            new string[]
            {
                $"{this.CookieName}={this.DataProtector.Protect(userInfoStr)}",
                $"{this.CookieName}_expire_time={this.DataProtector.Protect($"{this.CurrentTimestamp + this.ExpireTimeInSeconds}")}"
            });

    }

    public void SignOut(HttpContext ctx)
    {
        ctx.Response.Headers.SetCookie = new Microsoft.Extensions.Primitives.StringValues(
            new string[]
            { 
                $"{this.CookieName}=",
                $"{this.CookieName}_expire_time="
            });
    }
}

这个类中写着所有具体的认证/登录/注销的实际逻辑,与我们前几个版本的逻辑基本一致,程序运行起来后行为也没有差异,这一版我们只是将两个东西写成了公开属性,以供使用者自定义而已:

  1. 用户信息过期时间被设定成了一个公开属性ExpireTimeInSeconds,单位是秒,默认值和之前一样,是10秒
  2. cookie的名字被设定成了一个公开属性CookieName,默认值和之前一样,是"userinfo"

接口化之后一切都很美好

  • 业务逻辑只需要关注接口,不需要关注具体实现,比如上面的Program.cs中的三个endpoint的执行逻辑,完全不需要去看认证的具体实现
  • 程序员如果想使用一种新的实现,只需要自己写个新类,来实现ISimpleAuthNService接口就行了

除了一个小瑕疵:程序中只能存在一个ISimpleAuthNService的实例。换言之:在这种设计下,一个网站无法同时支持多种登录/注销/认证实现同时存在。比如你的网站无法既支持通过微信扫码登录,同时还支持用户名+密码登录。

在我们上面的例子中,我们假设这样一种场景,虽然目前我们只实现了一个SimpleCookieAuthNService,但我们可以通过属性ExpireTimeInSecondsCookieName的区别,来假设两种不同的登录/注销/认证流程。

4.8 同时支持多种认证实现

这里的关键在于:我们不能用“类名”来映射“登录/注销/认证的具体实现”了。因为“登录/注销/认证的具体实现”对应的其实是对象,而不是类。

比如上面的例子中,如果我们将Program.cs的代码改动如下,虽然前后使用的是同一个ISimpleAuthNService的实现类,但是前后其实应用的是两套“登录/注销/认证的具体实现”。

         // ...
         builder.Services.AddDataProtection();
-        builder.Services.AddScoped<ISimpleAuthNService, SimpleCookieAuthNService>();
+        builder.Services.AddScoped<ISimpleAuthNService>(sp => 
+        {
+            SimpleCookieAuthNService authService =  new SimpleCookieAuthNService(sp.GetRequiredService<IDataProtectionProvider>());
+            authService.CookieName = "auth info issued by org a";
+            authService.ExpireTimeInSeconds = 100;
+            return authService;
+        });
         builder.Services.AddScoped<SimpleAuthNMiddleware>();
         // ...

它们或许在代码上是高度相似的,但这只是因为我们只将“过期时间+cookie名字”设置成了可调属性而已。如果我们把SimpleCookieAuthNService写得更灵活一点,完全可以实现一种:虽然我和你使用的是同一个类,但实例化出来后的登录/注销/认证流程有很大差别,这种效果。

4.8.1 “登录/注销/认证的具体实现”的名字

如果我们要支持多种“登录/注销/认证的具体实现”同时存在在程序中,我们就必须为每一种“登录/注销/认证的具体实现”起个名字,这个“名字”的概念,有两种叫法:

  • 多数情况下,在大多数文档中,这个名字一般被称为“authentication scheme”
  • 少数情况下,个别场合中,这个名字会被称为“authentication type”

没错,这正是ClaimsIdentity.AuthenticationType属性,正是我们上面悬而未解的一个问题。

再说回authentication scheme这个概念:

  • 有时候,它指的是某一套“登录/注销/认证的具体实现”的名字
  • 有时候,它指的是某一套特定的“登录/注销/认证实现”,是把“名字”+“具体实现”这两个东西打包绑在一起,叫authentication scheme

具体什么意思,要看上下文环境。

我们再谈一谈,ClaimsIdentity.AuthenticatinoTypeIsAuthenticated属性吧:

  • 在构造ClaimsIdentity的时候,如果传入了authenticationType参数,则会导致构造出的对象,IsAuthenticated的值为true

  • 这说明了两件事

    • asp .net core认为,“登录/注销/认证”这一套东西,是一个面向“身份”的概念,而不是面向“用户”的概念。
    • asp .net core认为,一个由“登录/注销/认证”机制生成的“身份”,才能算是“经过了认证/被认证”,即authenticated identity。

4.8.2 如何改造现有代码使其支持多种 scheme

我们首先假设我们要支持的两套scheme,其具体实现的逻辑和SimpleCookieAuthNService里写的基本一致,只不过

  1. 对于scheme A,cookie名字是userinfo_issued_by_A,过期时间100秒
  2. 对于scheme B,cookie名字是userinfo_issued_by_B,过期时间30秒

如果有多种scheme同时存在,那么这意味着对于“登录/注销/认证功能”的调用方而言,每次调用的时候都必须指定scheme了。

也就是说,Program.cs中,在调用ctx.SimpleAuthNSignIn(..)ctx.SimpleAuthNSignOut()的时候,需要额外指定一个scheme参数了。当然,在构造alice的时候,也需要在构造ClaimsIdentity的时候把这个scheme的值写进构造函数中。

然后,我们需要把ISimpleAuthNService <- SimpleCookieAuthNService这个两层抽象,改造成三层,比如:

  • ISimpleAuthNService的功能是向endpoint和middleware提供一个简单的入口,将“登录/注销/认证”的细节全部隐藏掉

  • SimpleAuthNService的体内不再包含任何scheme的实现细节,而是以字典的方式存储着scheme名字 <-> scheme具体实现的映射表

  • CookieAuthNHandler体内包含着使用cookie实现登录/注销/认证的全部细节

    • 而这一层也可以进一步接口化,分为IAuthNHandlerCookieAuthNHandler两层

    • 由于支持了多种scheme,所以在认证逻辑中,具体实现应当是调用ctx.User.AddIdentity(xxx)来向HttpContext中添加一个身份,而不是使用ctx.User = xxx来赋值一个用户

最后,我们要改造我们的middleware,使它在认证过程中,把所有的scheme都过一遍。

改造完成后,项目结构如下所示:

HelloSimpleAuthN
    |
    \--> HelloSimpleAuthN.csproj
    \--> CookieAuthNHandler.cs
    \--> IAuthNHandler.cs
    \--> ISimpleAuthNService.cs
    \--> Program.cs
    \--> SimpleAuthNMiddleware.cs
    \--> SimpleAuthNService.cs

虽然有点乱啊,但咱仔细捋一下,就不乱了

首先是四层抽象

ISimpleAuthNService相较之前,没什么大的变动,就是支持了scheme参数而已

using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using System.Security.Claims;

namespace HelloSimpleAuthN;

public interface ISimpleAuthNService
{
    void Authenticate(HttpContext ctx, string scheme);
    void SignIn(HttpContext ctx, string scheme, ClaimsPrincipal principal);
    void SignOut(HttpContext ctx, string scheme);
}

public static class SimpleAuthNServiceExtensions
{
    public static void SimpleAuthNSignIn(this HttpContext ctx, string scheme, ClaimsPrincipal principal)
    {
        ctx.RequestServices.GetRequiredService<ISimpleAuthNService>().SignIn(ctx, scheme, principal);
    }

    public static void SimpleAuthNSignOut(this HttpContext ctx, string scheme)
    {
        ctx.RequestServices.GetRequiredService<ISimpleAuthNService>().SignOut(ctx, scheme);
    }
}

SimpleAuthNService则是统领了所有的scheme,并把它们保存在一个Dictionary<string, IAuthNHandler>

using Microsoft.AspNetCore.Http;
using System.Collections.Generic;
using System.Security.Claims;

namespace HelloSimpleAuthN
{
    public class SimpleAuthNService : ISimpleAuthNService
    {
        private Dictionary<string, IAuthNHandler> handlers;

        public SimpleAuthNService()
        {
            this.handlers = new Dictionary<string, IAuthNHandler>();
        }

        public void AddScheme(string scheme, IAuthNHandler handler)
        {
            this.handlers.Add(scheme, handler);
        }

        public void Authenticate(HttpContext ctx, string scheme)
        {
            this.handlers[scheme].Authenticate(ctx);
        }

        public void SignIn(HttpContext ctx, string scheme, ClaimsPrincipal principal)
        {
            this.handlers[scheme].SignIn(ctx, principal);
        }

        public void SignOut(HttpContext ctx, string scheme)
        {
            this.handlers[scheme].SignOut(ctx);
        }
    }
}

每个Scheme中的逻辑被抽象成了IAuthNHandler接口

using Microsoft.AspNetCore.Http;
using System.Security.Claims;

namespace HelloSimpleAuthN;

public interface IAuthNHandler
{
    void SignIn(HttpContext ctx, ClaimsPrincipal principal);
    void SignOut(HttpContext ctx);
    void Authenticate(HttpContext ctx);
}

真正的逻辑实现被写在了CookieAuthNHandler类中,但请注意我们之前说过,scheme对应的并不是handler类,而是handler实例

using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;

namespace HelloSimpleAuthN;

public class CookieAuthNHandler : IAuthNHandler
{
    private string schemeName;

    private IDataProtectionProvider idp;

    private IDataProtector DataProtector => this.idp.CreateProtector("dp for simple cookie authN");

    private long CurrentTimestamp => new DateTimeOffset(DateTime.UtcNow).ToUnixTimeSeconds();

    public long ExpireTimeInSeconds { get; set; }

    public string CookieName { get; set; }

    public CookieAuthNHandler(string schemeName, IDataProtectionProvider idp, long expireTimeInSeconds, string cookieName)
    {
        this.schemeName = schemeName;
        this.idp = idp;
        this.ExpireTimeInSeconds = expireTimeInSeconds;
        this.CookieName = cookieName;
    }

    public void Authenticate(HttpContext ctx)
    {
        bool expired = true;
        string? userInfoStr = null;
        foreach (var cookieKvp in ctx.Request.Headers.Cookie)
        {
            try
            {
                string key = cookieKvp.Split("=")[0];
                string value = cookieKvp.Split("=")[1];
                if (key == this.CookieName)
                {
                    userInfoStr = this.DataProtector.Unprotect(value);
                }

                if (key == $"{this.CookieName}_expire_time")
                {
                    string expireTimeStr = this.DataProtector.Unprotect(value);
                    if (long.Parse(expireTimeStr) > this.CurrentTimestamp)
                    {
                        expired = false;
                    }
                }
            }
            catch
            {
                userInfoStr = null;
                break;
            }
        }

        if (!expired && userInfoStr is not null)
        {
            IEnumerable<KeyValuePair<string, string>> userClaims = userInfoStr.Split(",").Select(kvpStr => new KeyValuePair<string, string>(kvpStr.Split(":")[0], kvpStr.Split(":")[1]));
            IEnumerable<Claim> claims = userClaims.Select(kvp => new Claim(kvp.Key, kvp.Value));
            ClaimsIdentity identity = new ClaimsIdentity(claims, this.schemeName);
            ctx.User.AddIdentity(identity);
        }
    }

    public void SignIn(HttpContext ctx, ClaimsPrincipal principal)
    {
        string userInfoStr = string.Join(",", principal.Claims.Select(claim => $"{claim.Type}:{claim.Value}"));
        ctx.Response.Headers.SetCookie = new Microsoft.Extensions.Primitives.StringValues(
            new string[]
            {
            $"{this.CookieName}={this.DataProtector.Protect(userInfoStr)}",
            $"{this.CookieName}_expire_time={this.DataProtector.Protect($"{this.CurrentTimestamp + this.ExpireTimeInSeconds}")}"
            });

    }

    public void SignOut(HttpContext ctx)
    {
        ctx.Response.Headers.SetCookie = new Microsoft.Extensions.Primitives.StringValues(
            new string[]
            {
            $"{this.CookieName}=",
            $"{this.CookieName}_expire_time="
            });
    }
}

接下来是负责认证的middleware

这个middleware相较于之前的改动,是要把所有scheme都轮一遍,把所有能解析的纸条都解析出来

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace HelloSimpleAuthN;

public class SimpleAuthNMiddleware : IMiddleware
{
    private IList<string> authNSchemes;

    private ISimpleAuthNService authService;

    public SimpleAuthNMiddleware(ISimpleAuthNService authService, IList<string> authNSchemes)
    {
        this.authService = authService;
        this.authNSchemes = authNSchemes;
    }
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        foreach(string scheme in this.authNSchemes)
        {
            this.authService.Authenticate(context, scheme);
        }
        await next.Invoke(context);
    }
}

public static class SimpleAuthNMiddlewareExtensions
{
    public static void UseSimpleAuthN(this IApplicationBuilder app)
    {
        app.UseMiddleware<SimpleAuthNMiddleware>();
    }
}

最后是主程序逻辑

Program.cs的改动较大,代码如下:

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;

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

        builder.Services.AddDataProtection();
        builder.Services.AddScoped<ISimpleAuthNService>(sp => 
        {
            SimpleAuthNService auth = new SimpleAuthNService();
            auth.AddScheme("scheme_A", new CookieAuthNHandler("scheme_A", sp.GetDataProtectionProvider(), 100, "userinfo_issued_by_A"));
            auth.AddScheme("scheme_B", new CookieAuthNHandler("scheme_A", sp.GetDataProtectionProvider(), 30, "userinfo_issued_by_B"));
            return auth;
        });
        builder.Services.AddScoped<SimpleAuthNMiddleware>(sp => 
        {
            return new SimpleAuthNMiddleware(sp.GetRequiredService<ISimpleAuthNService>(), new string[] { "scheme_A", "scheme_B" });
        });

        var app = builder.Build();

        app.UseSimpleAuthN();

        app.MapGet("/loginA", async ctx => 
        {
            ClaimsPrincipal alice = new ClaimsPrincipal(new ClaimsIdentity(new List<Claim> { 
                new Claim("Name", "Alice"),
                new Claim("Gender", "Female"),
                new Claim("Age", "18"),
            }));
            ctx.SimpleAuthNSignIn("scheme_A", alice);
            await ctx.Response.WriteAsync($"You've just logged in as Alice");
        });

        app.MapGet("/loginB", async ctx => 
        {
            ClaimsPrincipal alice = new ClaimsPrincipal(new ClaimsIdentity(new List<Claim> { 
                new Claim("Name", "Bob"),
                new Claim("Gender", "Mmale"),
                new Claim("Age", "28"),
            }));
            ctx.SimpleAuthNSignIn("scheme_B", alice);
            await ctx.Response.WriteAsync($"You've just logged in as Bob");
        });

        app.MapGet("/logoutA", async ctx => 
        {
            ctx.SimpleAuthNSignOut("scheme_A");
            await ctx.Response.WriteAsync($"You've just logged out Alice");
        });

        app.MapGet("/logoutB", async ctx => 
        {
            ctx.SimpleAuthNSignOut("scheme_B");
            await ctx.Response.WriteAsync($"You've just logged out Bob");
        });

        app.MapGet("/", async ctx => 
        {
            await ctx.Response.WriteAsync($"<h1>Identities count == {ctx.User.Identities.Count()}</h1>");
            int count = 0;
            foreach(var identity in ctx.User.Identities)
            {
                await ctx.Response.WriteAsync($"<h2>Identities[{count}].IsAuthenticated = {identity.IsAuthenticated}</h2>");
                foreach(var claim in identity.Claims)
                {
                    await ctx.Response.WriteAsync($"<h3>{claim.Type} : {claim.Value}</h3>");
                }

                ++count;
            }
        });

        app.Run();
    }
}

运行效果如下:

authN_v8

4.8.3 有几个概念需要先在脑海中记住

现在我们用非常简陋的代码实现了对多scheme的支持,从4.1章节到4.8章节,我们一步步的用迭代简陋代码的过程为大家介绍了很多概念,既说明了Authentication的本质,也大致告诉你,如果要手撸Authentication相关代码的话,大致要做哪些事情。

接下来我不会再对这个示例代码进行下一步迭代了,因为我们要了解的一些概念、思路已经了解的差不多了,是时候转头去看看框架为我们提供的Authentication基础设施了,但在那之前,请牢记以下重点

重点一:Authenticate的本质是两个动作

用比喻的方式来说,这两个动作分别是“解析纸条”与“操纵纸条”

  • “解析纸条”就是我们在middleware中做的事情
  • “操纵纸条”包括但不限于:登录/注销等动作

重点二:要搞清楚Claim/Identity/Principal是什么,也要认识到认证是“身份”层面的概念

Authentication是一个身份上的概念,所以ClaimsIdentity的构造函数重载会有authenticationType参数,会有IsAuthenticated属性。

网站应用的登录“用户”,永远只是“一个用户”。网站可以支持“拥有多个身份的一个用户”登录,但从逻辑和设计上就不支持“多个用户挤在一个session中登录网站”

重点三:scheme映射的是实例,而不是类

认证的具体实现一定是写在某个类中的,我们一般把这种类叫AuthenticationHandler类。

但scheme指的是:“Handler类的实例” + 一个名字。就好比上面我们的示例代码:scheme_Ascheme_B完全共享代码,但它们是两个完全不同的,可以独立存在的两个scheme。

5. asp .net core中的认证基础设施

现在,让我们把示例代码扔掉,转头去看,同样的程序运行效果,如果我们使用asp .net core框架的基础设施,写出来长什么样子

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;

namespace HelloSimpleAuthN;

public class AuthenticateAllSchemesMiddleware : IMiddleware
{
    private IAuthenticationService authService;
    private IAuthenticationSchemeProvider authSchemeProvider;

    public AuthenticateAllSchemesMiddleware(
        IAuthenticationService authService,
        IAuthenticationSchemeProvider authSchemeProvider)
    {
        this.authService = authService;
        this.authSchemeProvider = authSchemeProvider;
    }

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        foreach(AuthenticationScheme scheme in await this.authSchemeProvider.GetAllSchemesAsync())
        {
            AuthenticateResult authRes = await this.authService.AuthenticateAsync(context, scheme.Name);
            if(authRes.Succeeded)
            {
                ClaimsIdentity authenticatedIdentity = (ClaimsIdentity)authRes.Principal.Identity;
                context.User.AddIdentity(authenticatedIdentity);
            }
        }
        await next.Invoke(context);
    }
}

public class Program
{
    public static readonly string SchemeA = "scheme_A";
    public static readonly string SchemeB = "scheme_B";

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

        builder.Services.AddAuthentication()
            .AddCookie(SchemeA)
            .AddCookie(SchemeB);
        builder.Services.AddScoped<AuthenticateAllSchemesMiddleware>();

        var app = builder.Build();

        app.UseMiddleware<AuthenticateAllSchemesMiddleware>();

        app.MapGet("/loginA", async ctx => 
        {
            List<Claim> claims = new List<Claim>
            { 
                new Claim("Name", "Alice"),
                new Claim("Gender", "Female"),
                new Claim("Age", "18")
            };
            ClaimsIdentity identity = new ClaimsIdentity(claims, SchemeA);
            ClaimsPrincipal principal = new ClaimsPrincipal(identity);
            await ctx.SignInAsync(SchemeA, principal);
            await ctx.Response.WriteAsync($"You've just logged in as Alice");
        });

        app.MapGet("/loginB", async ctx => 
        {
            List<Claim> claims = new List<Claim>
            { 
                new Claim("Name", "Bob"),
                new Claim("Gender", "Male"),
                new Claim("Age", "28")
            };
            ClaimsIdentity identity = new ClaimsIdentity(claims, SchemeB);
            ClaimsPrincipal principal = new ClaimsPrincipal(identity);
            await ctx.SignInAsync(SchemeB, principal);
            await ctx.Response.WriteAsync($"You've just logged in as Bob");
        });

        app.MapGet("/logoutA", async ctx =>
        {
            await ctx.SignOutAsync(SchemeA);
            await ctx.Response.WriteAsync($"You've just logged out Alice");
        });

        app.MapGet("/logoutB", async ctx =>
        {
            await ctx.SignOutAsync(SchemeB);
            await ctx.Response.WriteAsync($"You've just logged out Bob");
        });

        app.MapGet("/", async ctx => 
        {
            await ctx.Response.WriteAsync($"<h1>Identities count == {ctx.User.Identities.Count()}</h1>");
            int count = 0;
            foreach(var identity in ctx.User.Identities)
            {
                await ctx.Response.WriteAsync($"<h2>Identities[{count}].IsAuthenticated = {identity.IsAuthenticated}</h2>");
                foreach(var claim in identity.Claims)
                {
                    await ctx.Response.WriteAsync($"<h3>{claim.Type} : {claim.Value}</h3>");
                }

                ++count;
            }
        });

        app.Run();
    }
}

接下来我们以对比的方式,来看框架的实现,和我们自己那个简陋的例子之间,有什么异同

5.1 框架对认证过程的抽象

我们那个简陋的示例中,调用的ctx.SimpleAuthNSignInctx.SimpleAuthNSignOut是我们自己写的扩展方法,源代码如下:

public static class SimpleAuthNServiceExtensions
{
    public static void SimpleAuthNSignIn(this HttpContext ctx, string scheme, ClaimsPrincipal principal)
    {
        ctx.RequestServices.GetRequiredService<ISimpleAuthNService>().SignIn(ctx, scheme, principal);
    }

    public static void SimpleAuthNSignOut(this HttpContext ctx, string scheme)
    {
        ctx.RequestServices.GetRequiredService<ISimpleAuthNService>().SignOut(ctx, scheme);
    }
}

现在的这份代码中,调用的是框架为我们提供的扩展方法,我们以SignInAsync为例,它有四个重载,框架源代码如下:

namespace Microsoft.AspNetCore.Authentication;

public static class AuthenticationHttpContextExtensions
{
    // ...

    public static Task SignInAsync(this HttpContext context, string? scheme, ClaimsPrincipal principal) =>
        context.SignInAsync(scheme, principal, properties: null);

    public static Task SignInAsync(this HttpContext context, ClaimsPrincipal principal) =>
        context.SignInAsync(scheme: null, principal: principal, properties: null);

    public static Task SignInAsync(this HttpContext context, ClaimsPrincipal principal, AuthenticationProperties? properties) =>
        context.SignInAsync(scheme: null, principal: principal, properties: properties);

    public static Task SignInAsync(this HttpContext context, string? scheme, ClaimsPrincipal principal, AuthenticationProperties? properties) =>
        GetAuthenticationService(context).SignInAsync(context, scheme, principal, properties);

    private static IAuthenticationService GetAuthenticationService(HttpContext context) =>
        context.RequestServices.GetService<IAuthenticationService>() ??
            throw new InvalidOperationException(Resources.FormatException_UnableToFindServices(
                nameof(IAuthenticationService),
                nameof(IServiceCollection),
                "AddAuthentication"));
    // ...
}

思路一致。

而如果你再打开框架中IAuthenticationService接口的定义,你会发现,这玩意其实和我们自己写的ISimpleAuthNService差不多:

namespace Microsoft.AspNetCore.Authentication
{
    public interface IAuthenticationService
    {
        Task<AuthenticateResult> AuthenticateAsync(HttpContext context, string? scheme);
        Task ChallengeAsync(HttpContext context, string? scheme, AuthenticationProperties? properties);
        Task ForbidAsync(HttpContext context, string? scheme, AuthenticationProperties? properties);
        Task SignInAsync(HttpContext context, string? scheme, ClaimsPrincipal principal, AuthenticationProperties? properties);
        Task SignOutAsync(HttpContext context, string? scheme, AuthenticationProperties? properties);
    }
}

而翻开IAuthenticationService的框架默认实现,即AuthenticationService来看的话,你会发现,虽然框架的AuthenticationService的实现更复杂了,但实现思路和我们的SimpleAuthNService也是差不多的,我们以AuthenticationService.SignInAsync方法的实现为例,以下是框架源代码:

        // ...
        public virtual async Task SignInAsync(HttpContext context, string? scheme, ClaimsPrincipal principal, AuthenticationProperties? properties)
        {
            if (principal == null)
            {
                throw new ArgumentNullException(nameof(principal));
            }

            if (Options.RequireAuthenticatedSignIn)
            {
                if (principal.Identity == null)
                {
                    throw new InvalidOperationException("SignInAsync when principal.Identity == null is not allowed when AuthenticationOptions.RequireAuthenticatedSignIn is true.");
                }
                if (!principal.Identity.IsAuthenticated)
                {
                    throw new InvalidOperationException("SignInAsync when principal.Identity.IsAuthenticated is false is not allowed when AuthenticationOptions.RequireAuthenticatedSignIn is true.");
                }
            }

            if (scheme == null)
            {
                var defaultScheme = await Schemes.GetDefaultSignInSchemeAsync();
                scheme = defaultScheme?.Name;
                if (scheme == null)
                {
                    throw new InvalidOperationException($"No authenticationScheme was specified, and there was no DefaultSignInScheme found. The default schemes can be set using either AddAuthentication(string defaultScheme) or AddAuthentication(Action<AuthenticationOptions> configureOptions).");
                }
            }

            var handler = await Handlers.GetHandlerAsync(context, scheme);
            if (handler == null)
            {
                throw await CreateMissingSignInHandlerException(scheme);
            }

            var signInHandler = handler as IAuthenticationSignInHandler;
            if (signInHandler == null)
            {
                throw await CreateMismatchedSignInHandlerException(scheme, handler);
            }

            await signInHandler.SignInAsync(principal, properties);
        }
        // ...

而我们自己写的SimpleAuthNService.SignIn方法的实现如下:

        // ...
        public void SignIn(HttpContext ctx, string scheme, ClaimsPrincipal principal)
        {
            this.handlers[scheme].SignIn(ctx, principal);
        }
        // ...

虽然这两坨代码在数量上差别巨大,但实际逻辑都可以用以下伪代码描述:

    public void Signin(ctx, scheme, principal)
    {
        var handler = 寻找到对应的handler实例(scheme);
        handler.Signin(principal);
    }

再往里追究hanlder的话,就会发现框架的实现和我们开始出现了分歧:我们是简单的使用一个Dictionary就存储了所有的scheme和对应的handler。

但框架这里写得复杂得多,框架为了记录、追踪、索引所有的scheme,分别向DI池中注册了两个对象:

  1. 注册类型为IAuthenticationHandlerProviderAuthenticationHandlerProvider
  2. 注册类型为IAuthenticationSchemeProviderAuthenticationSchemeProvider

其中..SchemeProvider中记录的是“scheme名字”和“Handler类型”之间的映射关系。不过我们之前也强调过很多次了,scheme映射的其实并不是handler类型,而是handler实例。

要获得真正的scheme实例,就需要借助...HandlerProvider来实现,框架源代码如下:

    public class AuthenticationHandlerProvider : IAuthenticationHandlerProvider
    {
        public AuthenticationHandlerProvider(IAuthenticationSchemeProvider schemes)
        {
            Schemes = schemes;
        }

        public IAuthenticationSchemeProvider Schemes { get; }

        private readonly Dictionary<string, IAuthenticationHandler> _handlerMap = new Dictionary<string, IAuthenticationHandler>(StringComparer.Ordinal);

        public async Task<IAuthenticationHandler?> GetHandlerAsync(HttpContext context, string authenticationScheme)
        {
            if (_handlerMap.TryGetValue(authenticationScheme, out var value))
            {
                return value;
            }

            var scheme = await Schemes.GetSchemeAsync(authenticationScheme);
            if (scheme == null)
            {
                return null;
            }
            var handler = (context.RequestServices.GetService(scheme.HandlerType) ??
                ActivatorUtilities.CreateInstance(context.RequestServices, scheme.HandlerType))
                as IAuthenticationHandler;
            if (handler != null)
            {
                await handler.InitializeAsync(scheme, context);
                _handlerMap[authenticationScheme] = handler;
            }
            return handler;
        }
    }

GetHandlerAsync方法中,在通过...SchemeProvider拿到一个scheme的类型信息后,会对其进行实例化。这样看来,框架中的认证体系其实有五层:

  1. IAuthenticationService,负责向程序员提供一个一键式的认证接口

    扩展方法ctx.SignInAsync(...)就实现在这个级别

  2. AuthenticationService:负责统领所有的scheme

    它是通过IAuthenticationSchemeProviderIAuthenticationHandlerProvider来访问到所有scheme的

    它对SignInAsync等方法的实现,可以总结为两步:

    1. 根据scheme找handler
    2. 再调用hanlder里的SignInAsync方法
  3. IAuthenticationHandlerProvider和它的默认实现AuthenticationHandlerProvider:将scheme名字与handler实例映射起来

    这个接口只有一个方法:GetHandlerAsync:你给我名字,我给你handler

    在实现中,...HandlerProvider其实并不直接持有Handler对象,而是通过IAuthenticationSchemeProvider持有其类型信息

    当用户调用GetHandlerAsync时,再按需创建并初始化Handler对象

  4. IAuthenticationSchemeProvider和它的默认实现AuthenticationSchemeProvider:将scheme名字和hanlder类型映射起来

    将scheme集合拆分成...HandlerProvider...SchemeProvider在我看来,多少有点过度设计的味道

  5. 用来描述handler类的一系列接口与抽象类:我们下面单开一小节介绍它们

5.2 与Handler类相关的接口与抽象类

首先是三个最基础的接口

  1. IAuthenticationHandler : 有 初始化(InitializeAsync),认证(AuthenticateAsync),质疑(ChallengeAsync),禁止(ForbidAsync)四个基本方法
  2. IAuthenticationSignOutHandler : 在上个接口的基础上,添加了注销(SignOutAsync)
  3. IAuthenticationSignInHandler : 在上个接口的基础上,添加了登录(SignInAsync)

然后是框架打样实现的三个抽象类:

  1. AuthentiationHandler<TOptions>
  2. SignOutAuthenticationHandler<TOptions>
  3. SignInAuthenticationHandler<TOptions>

如果你去翻框架源代码的话,你会发现接口的定义非常简洁,框架打样实现的三个抽象类里,AuthenticationHandler<T>作为最顶层的抽象类,框架已经在这个抽象类内部为你写了不少默认实现,定义了不少用得上的字段和属性。还在接口定义的语义之外,很明显的在支持着“事件回调”和“认证转发”等骚操作骚特性。

而认证最为基本的五个方法:登录、注销、质疑、禁止和认证中。有这么几个特点:

  1. 虽然在接口中,这五个方法的名字叫xxxAsync,其中xxx是动词SignIn/SignOut/Challenge/Forbidden/Authenticate。但在抽象类中,这五个方法都有了基本实现

    除非你有非常强的理由,否则不要动这个默认实现

  2. 抽象类对接口的默认实现,调用了HandlexxxAsync方法,其中xxx依然是动词

    这个HandlexxxAsync是抽象类中定义的虚方法,这个是没有实现的纯虚方法,是留给程序员自己填充的

    也就是说,如果我们要实现一个自定义的handler,丛一个特殊的HTTP头部字段中去以自定义的方式解析纸条,那么我们就应当创建一个类,让它继承SignInAuthenticationHandler<TOptions>类,然后以自定义的方式去实现五个HandlexxxAsync方法

    而不是去覆写接口里的xxxAsync方法

我们再来说这五个动词的语义:

  1. 登录,SignIn:创建纸条
  2. 注销,SignOut:销毁纸条

这两个语义都比较好理解,并且框架把他们写在认证相关的基础设施类中也比较好理解。

  1. 质疑,Challenge:请你重新去拿个纸条

    Challenge一般的处理结果是给浏览器发送一个401回应或者302回应。

    • 401回应在HTTP协议里的语义是:认证没有成功,服务端没有认出请求者的身份,所以不能返回内容。

      在WebAPI项目中,对于纸条缺失、解析失败等情形,应当返回401给客户端

    • 302回应在HTTP协议层面就是个重定向

      在服务端渲染网站项目中,对于纸条缺失、解析失败等情形,可以给客户端返回个302,把客户端浏览器引导至登录页面

  2. 禁止,Forbidden:我看过你的纸条了,你没有权限查看这个内容

    Forbidden其实是一个Authorization领域的概念,即认证通过了,但用户没有权限访问这个内容。

    虽然这是一个AuthZ领域的概念,但在AuthN这一层的middleware中,我们会对这个请求进行处理:

    • 在web api 项目中,我们一般给客户端扔个403通知它一下

    • 在服务端渲染网站项目中,我们会给客户端一个302重定向,把它引导到一个提示页面,告诉它:“你没权限”

这里之所以要介绍一下这几个动词,和这几个Handler接口和抽象类,是为了给你一个印象,让你知道,如果你自己要实现一个自定义的Handler的时候,大致要做什么,要研究哪个方面、方向的源代码。

OK,不要在这里钻太多的牛角尖,理解到这里就可以了。

5.3 具体逻辑的实现

具体登录、注销、认证逻辑的实现,我们之前那个简陋的例子,是自己手动实现的,写在CookieAuthNHandler.cs中。

而框架中有关基于cookie机制的登录、注销、认证逻辑的实现,写在CookieAuthenticationHandler类中,虽然框架的实现丰富了很多细节,但实现思路基本是一致的:

  1. 登录、注销,即HandleSignInAsyncHandleSignOutAsync都是在通过Http控制浏览器的cookie
  2. 认证,即HandleAuthenticateAsync是从cookie中解析身份信息

不同的是,在CookieAuthNHandler.cs中,我们解析完身份信息后,是就地创建了一个ClaimsIdentity实例,然后将其添加到ctx.User.AddIdentity(...)方法里

而在CookieAuthenticationHandler中,HandleAuthenticateAsync把解析后的身份信息包装成了ClaimsPrincipal,并进一步包装成了AuthenticationResult。而具体解析出的这些结果和上下文应该怎么去使用,Handler其实是不关心的,相关的逻辑应当写在Middleware中

5.4 相关对象向DI的注册过程

在我们实现的简陋版本示例中,我们向DI其实只注册了两个对象,代码长下面这样:

        // ...
        builder.Services.AddDataProtection();
        builder.Services.AddScoped<ISimpleAuthNService>(sp => 
        {
            SimpleAuthNService auth = new SimpleAuthNService();
            auth.AddScheme("scheme_A", new CookieAuthNHandler("scheme_A", sp.GetDataProtectionProvider(), 100, "userinfo_issued_by_A"));
            auth.AddScheme("scheme_B", new CookieAuthNHandler("scheme_A", sp.GetDataProtectionProvider(), 30, "userinfo_issued_by_B"));
            return auth;
        });
        builder.Services.AddScoped<SimpleAuthNMiddleware>(sp => 
        {
            return new SimpleAuthNMiddleware(sp.GetRequiredService<ISimpleAuthNService>(), new string[] { "scheme_A", "scheme_B" });
        });
        // ...

虽然代码上只注册了两个对象,但实际上我们干了三件事:

  1. 注册抽象层:其实就是向DI池中添加ISimpleAuthNServiceSimpleAuthNService
  2. 注册实现层:其实就是向DI池中添加Scheme,而每个scheme的本质其实就是一个CookieAuthNHandler实例
  3. 注册Middleware对象

我们先抛开middleware不管,这里最核心的概念,是要理解到:认证过程大的分层有两层,一层管抽象,一层管实现。

在asp .net core框架中,这个分层的思路也是成立的,比如在新版的示例代码中,有关DI的部分如下:

        // ...
        builder.Services.AddAuthentication()
            .AddCookie(SchemeA)
            .AddCookie(SchemeB);
        builder.Services.AddScoped<AuthenticateAllSchemesMiddleware>();
        // ...

抛开middleware对象的注册不谈,其实框架中的写法也是分了两层:

  1. builder.Services.AddAuthentication其实就是在向DI层注册抽象层对象,如果你翻开它的源代码去看:

        // ...
        services.AddAuthenticationCore();
        services.AddDataProtection();
        services.AddWebEncoders();
        services.TryAddSingleton<ISystemClock, SystemClock>();
        // ...

    这个扩展方法里包了四个调用,后三个比较好理解,从名字上来看,大致就是向DI里添加一些辅助工具,包括我们之前提到的DataProtection相关工具。核心调用是第一个,services.AddAuthenticationCore();,它的实现是:

    services.TryAddScoped<IAuthenticationService, AuthenticationService>();
    services.TryAddSingleton<IClaimsTransformation, NoopClaimsTransformation>();
    services.TryAddScoped<IAuthenticationHandlerProvider, AuthenticationHandlerProvider>();
    services.TryAddSingleton<IAuthenticationSchemeProvider, AuthenticationSchemeProvider>();

    向DI池中添加了AuthenticationServiceAuthenticationHandlerProviderAuthenticationSchemeProvider,唯独没有添加具体的Handler,即scheme是什么。

  2. AddAuthentication的返回值上链式调用AddCookie,其实是在向xxxHandlerProviderxxxSchemeProvider中添加具体实现。AddCookie的源代码如下:

        public static AuthenticationBuilder AddCookie(this AuthenticationBuilder builder, string authenticationScheme, string? displayName, Action<CookieAuthenticationOptions> configureOptions)
        {
            builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<CookieAuthenticationOptions>, PostConfigureCookieAuthenticationOptions>());
            builder.Services.AddOptions<CookieAuthenticationOptions>(authenticationScheme).Validate(o => o.Cookie.Expiration == null, "Cookie.Expiration is ignored, use ExpireTimeSpan instead.");
            return builder.AddScheme<CookieAuthenticationOptions, CookieAuthenticationHandler>(authenticationScheme, displayName, configureOptions);
        }

也算是非常直观了

另外还有件事需要注意到:框架抽象层是定义在以下三个包中的:

  • Microsoft.AspNetCore.Authentication
  • Microsoft.AspNetCore.Authentication.Core
  • Microsoft.AspNetCore.Authentication.Abstractions

而基于cookie的具体实现是定义在Microsoft.AspNetCore.Authentication.Cookies中的。

除了用cookie实现纸条功能外,常用的还有使用JWT Token的实现,这种实现就在包Microsoft.AspNetCore.Authentication.JwtBearer中。

5.5 为什么我们要手写一个Middleware

两版例子唯一的共同点是:两个版本都自己实现了用来认证的middleware。

那么是框架没有提供认证的middleware吗?并不是,框架已经写好了一个叫AuthenticationMiddleware的类(没有实现IMiddleware接口的写法),并且在代码中直接如下调用,就可以使用这个middleware:

    // ...
    var app = builder.Build();

    app.UseAuthentication();

    // ...

UseAuthentication()扩展方法内部调用的就是app.UseMiddleware<AuthenticationMiddleware>()

那为什么我们不用它呢?

你翻开AuthenticationMiddleware的源代码去看一眼就明白了:

public class AuthenticationMiddleware
{
    // ...
    public async Task Invoke(HttpContext context)
    {
        context.Features.Set<IAuthenticationFeature>(new AuthenticationFeature
        {
            OriginalPath = context.Request.Path,
            OriginalPathBase = context.Request.PathBase
        });

        // Give any IAuthenticationRequestHandler schemes a chance to handle the request
        var handlers = context.RequestServices.GetRequiredService<IAuthenticationHandlerProvider>();
        foreach (var scheme in await Schemes.GetRequestHandlerSchemesAsync())
        {
            var handler = await handlers.GetHandlerAsync(context, scheme.Name) as IAuthenticationRequestHandler;
            if (handler != null && await handler.HandleRequestAsync())
            {
                return;
            }
        }

        var defaultAuthenticate = await Schemes.GetDefaultAuthenticateSchemeAsync();
        if (defaultAuthenticate != null)
        {
            var result = await context.AuthenticateAsync(defaultAuthenticate.Name);
            if (result?.Principal != null)
            {
                context.User = result.Principal;
            }
            if (result?.Succeeded ?? false)
            {
                var authFeatures = new AuthenticationFeatures(result);
                context.Features.Set<IHttpAuthenticationFeature>(authFeatures);
                context.Features.Set<IAuthenticateResultFeature>(authFeatures);
            }
        }

        await _next(context);
    }
    // ...
}

框架默认提供的这个middleware有两个特点:

  1. 它只用一个scheme对请求进行身份认证,这个被使用到的scheme,叫"default scheme"
  2. 在scheme认证成功后,它会直接把认证结果里的那个ClaimsPrincipal直接赋值给ctx.User,而不像我们上面所有的例子那样:只把认证出来的身份通过ctx.User.AddIdentity添加到ctx.User体内

现在的问题是,如果我们的应用中存在多个scheme的话,我如何指定一个scheme,为"default scheme"呢?

答案很简单:在向DI注册抽象层的过程中,把"default scheme"的名字送给AddAuthentication()作为参数即可,如下:

builder.Service.AddAuthentication(SchemeA)
    .AddCookie(SchemeA)
    .AddCookie(SchemeB);

如此操作后,就可以使用框架自带的AuthenticationMiddleware了。

比如,我们如下改动我们的代码:

 // ...
-public class AuthenticateAllSchemesMiddleware : IMiddleware
-{
-    private IAuthenticationService authService;
-    private IAuthenticationSchemeProvider authSchemeProvider;
-
-    public AuthenticateAllSchemesMiddleware(
-        IAuthenticationService authService,
-        IAuthenticationSchemeProvider authSchemeProvider)
-    {
-        this.authService = authService;
-        this.authSchemeProvider = authSchemeProvider;
-    }
-
-    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
-    {
-        foreach(AuthenticationScheme scheme in await this.authSchemeProvider.GetAllSchemesAsync())
-        {
-            AuthenticateResult authRes = await this.authService.AuthenticateAsync(context, scheme.Name);
-            if(authRes.Succeeded)
-            {
-                ClaimsIdentity authenticatedIdentity = (ClaimsIdentity)authRes.Principal.Identity;
-                context.User.AddIdentity(authenticatedIdentity);
-            }
-        }
-        await next.Invoke(context);
-    }
-}
-
 // ...
 public class Program
 {
     // ...
     public static void Main(string[] args)
     {
         // ...
-        builder.Services.AddAuthentication()
+        builder.Services.AddAuthentication(SchemeA)
             .AddCookie(SchemeA)
             .AddCookie(SchemeB);
-        builder.Services.AddScoped<AuthenticateAllSchemesMiddleware>();

         var app = builder.Build();

-        app.UseMiddleware<AuthenticateAllSchemesMiddleware>();
+        app.UseAuthentication();
         // ...
     }
 }

那么运行效果会有以下改动:

  1. 我们永远在页面上都看不到Bob身份,因为认证middleware总是以SchemeA对请求进行认证,永远不会以SchemeB对请求进行认证
  2. SchemeA认证成功后,ctx.User只会有一个身份,而不像之前那样,有两个身份

6. 总结

这篇文章中,我们通过循序渐进的示例的方式,向大家展示了asp .net core框架中认证领域的相关概念及设计思路。虽然示例过多导致有点拖沓,但循序渐进的示例能帮助你更好的理解相关概念与设计思路,我强烈建议你手动实现所有的示例以加深记忆。

如果你能回答出以下几个问题,那么就基本证明你已经掌握了这篇文章的知识:

  1. 文章中为什么总是在提及“纸条”这个比喻?这比喻的是什么东西?
  2. 什么是claim, identity, principal
  3. asp .net core框架中有关认证的五层抽象都分别是什么
  4. 什么是scheme