夜雨聆风学习资料网

ARTICLE · 1206382

从源码拆穿 ASP.NET Core 鉴权授权的底裤

从源码拆穿 ASP.NET Core 鉴权授权的底裤

为什么 AddAuthentication + AddCookie 就能用?从鉴权实质、自定义 UrlToken、四天王核心对象到三层扩展,一次拆透 ASP.NET Core 鉴权源码。

01别再只会 AddAuthentication + AddCookie!我从源码拆穿 ASP.NET Core 鉴权授权的底裤

一条请求,穿过两道门,鉴权与授权到底拦住了什么。

02开头:三行代码背后的十万个为什么

问一个扎心的问题:你用 ASP.NET Core 做鉴权授权,是不是永远就这三板斧—— `csharp builder.Services.AddAuthentication().AddCookie(); app.UseAuthentication(); app.UseAuthorization(); [Authorize] `

能用,能跑,别人问一句“为什么这样写”,你大概率只能回一句“模板就是这样”。至于它到底干了什么、请求进来之后是哪个对象在解析你的 Cookie、[Authorize] 又是怎么把请求拦下来的——全是黑盒。

这套东西恰恰是整个框架里封装最狠、绕得最深的一块:一不留神你就把它用成了“魔法”,出了问题也只能瞎试。今天这篇文章,就带你把这层魔法扒干净。

我们不空谈概念,直接拿一套真实可跑的 Demo当手术台,从本质讲到实操,再从设计讲到源码,最后落到三个层次的扩展。读完你能得到两样东西:一条请求进来,鉴权授权到底经历了什么;以及以后遇到“要加一种登录方式”的需求,你该怎么下手。

先说清楚这篇文章的四段主线: 1. 理解鉴权授权:详细流程实操 + 总结 2. 分析鉴权设计:扩展自定义鉴权方式 3. 鉴权源码全流程:多个核心对象的底层理解 4. 鉴权三层扩展:多 Scheme 的实践应用

0301 鉴权、授权:同一个古老问题,被拆成了两个动作

先回到最原始的起点。HTTP 是无状态协议——每次请求对服务器来说都是“第一次见面”,它根本不记得你。可产品偏偏需要“记住你是张三,而且张三能访问这个页面”。于是人类发明了一套从Cookie + Session到Token的演进史。

但不管你用哪种凭证方案,有一段内容永远不变,永远是三步: 1. 请求服务端,获取凭证(登录) 2. 客户端再次请求,带上凭证 3. 服务端识别凭证,判断是否允许访问

看清楚了吗?鉴权授权的本质,其实就落在第 3 步:解决“用户识别 + 判断授权”的问题。而这个问题,被框架拆成了两个动作:

鉴权(Authentication):凭证的识别与解析——有没有登录?凭证有没有过期?你是张三还是李四?

授权(Authorization):基于解析结果做权限检测——张三/李四,到底有没有权限访问这个资源?

这两个词太容易混,我送你一句记住它俩的关系:鉴权是“验明正身”,授权是“验明之后才谈资格”。而且注意,授权阶段其实也会偷偷再调一次鉴权——它俩是通过 context.User 这个中间人通信的。这一点,等你看完源码就会彻底明白。

下面先带着这份基本理解,去实操里走一遍。

0402 快捷实操:把完整鉴权授权流程先跑一遍

实操的东西“过程有点长、东西有点多”,所以我只带大家完整走一遍,走完你就有了直观画面。

052.1 先搭好三样东西

第一样,注册鉴权/授权服务。鉴权这块用 Cookie 方式(LoginPath 是未登录时跳转的登录页,AccessDeniedPath 是“有凭证但没权限”时跳转的 403 页):

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)    .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>    {        options.LoginPath = "/Home/Index";          // 未登录 → 跳登录页        options.AccessDeniedPath = "/Home/Privacy"; // 有凭证无权限 → 403    });// 授权:AddMVC 里其实已经带了 AddAuthorization,// 想加自定义 Policy 再单独配置即可

第二样,把两个中间件挂到管道上(位置必须在 UseRouting 之后、MapControllerRoute 之前):

app.UseAuthentication(); // 保证请求来了都做一次凭证解析app.UseAuthorization();  // 保证请求来了都做一次授权检测

第三样,写一个 登录方法。登录的本质,是造一个 ClaimsPrincipal(一堆声明 Claims 拼起来的“用户”),然后调 SignInAsync 把它交给框架,框架负责写入 Cookie:

[HttpPost] // /Authentication/Login?name=Eleven&password=123456publicasync Task<IActionResult> Login(string name, string password){    if ("YueXuan".Equals(name, StringComparison.CurrentCultureIgnoreCase)        && password.Equals("123456"))    {        var claimIdentity = new ClaimsIdentity("Custom"); // 声明身份        claimIdentity.AddClaim(new Claim(ClaimTypes.Name, name));        claimIdentity.AddClaim(new Claim(ClaimTypes.Email, "777@qq.com"));        claimIdentity.AddClaim(new Claim(ClaimTypes.Role, "Admin"));        claimIdentity.AddClaim(new Claim(ClaimTypes.Country, "Chinese"));        await base.HttpContext.SignInAsync(            CookieAuthenticationDefaults.AuthenticationScheme,            new ClaimsPrincipal(claimIdentity),            new AuthenticationProperties { ExpiresUtc = DateTime.UtcNow.AddMinutes(30) });        return new JsonResult(new { Result = true, Message = "登录成功" });    }    return new JsonResult(new { Result = false, Message = "登录失败" });}

这一串看着多,其实只干了两件事:把“我是谁、我有什么角色”放进声明里,再让框架把这些声明序列化进 Cookie。后续每次请求,框架就把 Cookie 反序列化回 context.User,你就又能读到这些声明了。

062.2 用 [Authorize] 家族做授权检测

授权检测的用法,本质就是一套特性标记,告诉框架“这个方法需要哪种授权”:

[Authorize]                       // 只需要登录过(鉴权通过即可)public IActionResult InfoWithAuthorize() { ... }[Authorize(Roles = "Admin")]      // 登录 + 角色必须是 Adminpublic IActionResult InfoWithRolesAdmin() { ... }[Authorize(Policy = "MutiPolicy", AuthenticationSchemes = "Cookies")]public IActionResult InfoWithPolicy() { ... }  // 登录 + 满足一个复杂策略[AllowAnonymous]                  // 免登录直接访问public IActionResult InfoWithAllowAnonymous() { ... }

其中 MutiPolicy 是在配置里用 AddPolicy 拼出来的一条“复合规则”,它能同时要求角色、用户名、声明、甚至是自定义断言(比如“Email 必须是某个域名”):

builder.Services.AddAuthorization(options =>{    options.AddPolicy("MutiPolicy", policyBuilder =>    {        policyBuilder.RequireRole("Admin")            // 必须 Admin 角色            .RequireUserName("Yuexuan")                // 必须叫 Yuexuan            .RequireClaim(ClaimTypes.Country)         // 必须有 Country 声明            .RequireAssertion(context =>              // 自定义断言,还能校验 IP 等                context.User.HasClaim(c => c.Type == ClaimTypes.Email)                && context.User.Claims.First(c => c.Type == ClaimTypes.Email)                    .Value.Equals("777@qq.com"))            .AddRequirements(new SingleEmailRequirement("@qq.com")); // 自定义 Requirement    });});

这里有个很关键的细节你以后一定会碰到:[Authorize(Policy = "MutiPolicy", AuthenticationSchemes = "Cookies")] 里那个 AuthenticationSchemes,就是“授权时用哪个/哪些 Scheme 去取用户信息”。这一点,在第四部分讲多 Scheme 时是重头戏。

072.3 主动鉴权、主动授权:把中间件的事自己干一遍

除了靠特性自动拦截,框架也允许你在代码里“手动触发”。这两个接口是理解鉴权授权的钥匙:

// 主动鉴权:手动调 AuthenticateAsync 去解析凭证,拿到 Principalvar result = await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);if (result?.Principal != null) HttpContext.User = result.Principal;// 主动授权:先鉴权,再判断,该挑战就挑战,该拒绝就拒绝if (!user.Identity.IsAuthenticated)    await HttpContext.ChallengeAsync(scheme);   // 未登录 → 401/跳登录else if (!user.Identity.Name.Equals("Yuexuan"))    await HttpContext.ForbidAsync(scheme);      // 无权限 → 403

是不是更清楚了?ChallengeAsync(未登录)和 ForbidAsync(无权限)这两个动作,其实就是 [Authorize] 拦下请求后框架替你调用的东西。你手动调,就等于把“魔法”拆开看了。

082.4 一张表记住状态码与动作

情况
框架动作
最终状态
未登录,直接访问受保护接口
ChallengeAsync → 跳 LoginPath
401 / 跳登录页
有凭证,但无权限
ForbidAsync → 跳 AccessDeniedPath
403 / 拒绝页
登录成功
SignInAsync 写入 Cookie
后续请求正常
退出登录
SignOutAsync 清除 Cookie
再访问需重新登录

走完这一遍你会发现:配置一下就能生效,封装的确实很厉害。但反过来也说明,你完全不知道它内部发生了什么。下面,我们从“设计”层面开始深入。

0903 深入鉴权设计:鉴权到底要干哪几件事

把鉴权细化、拆开看,你会发现无论 Cookie、JWT 还是别的,本质上都是在回答同一个问题:“怎么从凭证里认出张三?”而要回答它,必须处理五件事: 1. 凭证位置:凭证是怎么传递的?Cookie 放 Header 里,JWT 也放 Header,但读取方式不同。 2. 凭证格式:凭证是什么格式?加密、序列化、还是 JWT 的 Base64 三段式? 3. 信息有效性:过期时间、签名校验、token 是否有效——这些怎么判断? 4. 鉴权信息传递:解析出来的用户信息要保存下来给后面用,就放进 context.User。 5. 特殊情况处理:没登录、没权限时,该怎么跳转、该返回什么状态码?

这五件事,就是“一次鉴权”的全部职责。理解了它,你就能理解为什么框架要设计成“Scheme + Handler”——因为每种凭证方式,无非就是把这五件事的实现换一套而已。

1004 自定义鉴权:自己写一个 UrlToken 鉴权方式

光看 Cookie 鉴权,你还是看不清“凭证在哪、格式是啥”是被谁控制的。干脆,我们手写一种全新的鉴权方式——UrlToken 鉴权:凭证放在 URL 的查询参数里,格式是 eleven-123456。写完你会发现,自定义鉴权根本没有想象中那么玄。

114.1 核心:实现 IAuthenticationHandler

自定义鉴权的第一步,是实现 IAuthenticationHandler。这个接口就是“鉴权动作”的全部契约,它有四个方法:

public class UrlTokenAuthenticationHandler    : IAuthenticationHandler,          // 鉴权核心:Authenticate / Challenge / Forbid      IAuthenticationSignInHandler,    // 登录:SignInAsync      IAuthenticationSignOutHandler    // 登出:SignOutAsync{    private AuthenticationScheme _AuthenticationScheme;    private HttpContext _HttpContext;    // ① 初始化:Provider 把 Scheme 和 HttpContext 注入进来(像方法注入)    public Task InitializeAsync(AuthenticationScheme scheme, HttpContext context)    {        _AuthenticationScheme = scheme;        _HttpContext = context;        return Task.CompletedTask;    }    // ② 核心鉴权方法:解析用户信息    public Task<AuthenticateResult> AuthenticateAsync()    {        string userInfo = _HttpContext.Request.Query["UrlToken"]; // 凭证从哪读        if (string.IsNullOrWhiteSpace(userInfo))            return Task.FromResult(AuthenticateResult.NoResult()); // 没带 → Challenge        if ("eleven-123456".Equals(userInfo))  // 校验规则,可提到 Options        {            var claimIdentity = new ClaimsIdentity("Custom");            claimIdentity.AddClaim(new Claim(ClaimTypes.Name, userInfo));            claimIdentity.AddClaim(new Claim(ClaimTypes.Role, "User"));            claimIdentity.AddClaim(new Claim(ClaimTypes.Email, "123@126.com"));            var principal = new ClaimsPrincipal(claimIdentity);            // 成功后,把用户信息封装成 Ticket 往后传            return Task.FromResult(AuthenticateResult.Success(                new AuthenticationTicket(principal, null, _AuthenticationScheme.Name)));        }        return Task.FromResult(AuthenticateResult.Fail("UrlToken is wrong")); // → Forbid    }    // ③ 未登录:跳登录页    public Task ChallengeAsync(AuthenticationProperties properties)    {        _HttpContext.Response.Redirect("/Home/Index");        return Task.CompletedTask;    }    // ④ 无权限:返回 403    public Task ForbidAsync(AuthenticationProperties properties)    {        _HttpContext.Response.StatusCode = 403;        return Task.CompletedTask;    }}

看到没,自定义鉴权的关键就一句话:实现 AuthenticateAsync,告诉框架“凭证从哪读、怎么校验、成功后拼出什么 Principal”;再实现 Challenge/Forbid,告诉框架“没登录/没权限时怎么办”。

顺带解释一个常见困惑:为什么 SignInAsync / SignOutAsync 是独立接口而不是塞进 IAuthenticationHandler?因为现代架构通常有统一的认证中心负责发证和销毁,而其它服务只负责验证证书——它们根本用不到登录登出。把 SignIn 单独拆出来,就是为了让“只验证、不签发”的服务不必实现它。

124.2 注册:AddScheme,告诉框架“怎么解析”

builder.Services.AddAuthentication(options =>{    // 用一个固定名字(UrlTokenScheme) 绑定到我们自定义的 Handler 类型    options.AddScheme<UrlTokenAuthenticationHandler>(        UrlTokenAuthenticationDefaults.AuthenticationScheme, "UrlTokenScheme-Demo");    // 关键:把默认 Scheme 全指向 UrlTokenScheme    options.DefaultAuthenticateScheme = UrlTokenAuthenticationDefaults.AuthenticationScheme;    options.DefaultChallengeScheme    = UrlTokenAuthenticationDefaults.AuthenticationScheme;    options.DefaultSignInScheme       = UrlTokenAuthenticationDefaults.AuthenticationScheme;    options.DefaultForbidScheme       = UrlTokenAuthenticationDefaults.AuthenticationScheme;    options.DefaultSignOutScheme      = UrlTokenAuthenticationDefaults.AuthenticationScheme;});

这套 AddAuthentication + AddScheme,本质上就是“告诉框架:当有人请求 'UrlTokenScheme' 这个 Scheme 时,去实例化 UrlTokenAuthenticationHandler 来处理”。剩下的,框架全包了——请求一到,自动调你的 Handler 完成鉴权。

134.3 验证:标记 + 带 Token 就过,不带就跳

// /Authentication/UrlToken?UrlToken=eleven-123456[Authorize] // 默认走 UrlTokenSchemepublicasync Task<IActionResult> UrlToken() { ... }// 或显式指定 Scheme// [Authorize(AuthenticationSchemes = UrlTokenAuthenticationDefaults.AuthenticationScheme)]

到了这一步,我们不光会“用”,还能“造”——自定义一套鉴权方式,全程没碰框架内部一行代码,只实现了几个接口。这说明什么?ASP.NET Core 的鉴权体系设计得非常开:Handler 是唯一扩展点,其余全部复用。

看到这里,你肯定想问了:注册时到底存了什么?请求进来又是怎么找到这个 Handler 的?这就到了重头戏——源码全流程。

1405 源码全流程:一条请求是怎么被鉴权的

实操做完了,自定义也做完了,现在我们进入核心中的核心:框架源码到底怎么跑?要直接调试微软的源码挺费劲,所以我们用一个“新套路”:把源码拷贝过来、改个名、替换掉原注册(比如把 AddAuthentication 换成自定义的 CustomAddAuthentication,中间件换成 CustomUseAuthentication),在本地项目里直接断点调试。这也是彻底搞懂框架最有效的方式。

整套鉴权,其实就两大环节:启动环节(AddAuthentication + UseAuthentication)和请求环节(一次 Invoke)。下面一步步拆。

155.1 启动一:AddAuthentication —— 注册 4 个核心对象

入口是 AddAuthentication,核心逻辑在 AddAuthenticationCore。它做的事,是把鉴权最底层的 4 个核心对象注册进 IOC:

services.TryAddScoped<IAuthenticationService, AuthenticationService>();        // 鉴权服务services.TryAddSingleton<IClaimsTransformation, NoopClaimsTransformation>();   // 声明转换(空实现)services.TryAddScoped<IAuthenticationHandlerProvider, AuthenticationHandlerProvider>(); // Handler 提供者services.TryAddSingleton<IAuthenticationSchemeProvider, AuthenticationSchemeProvider>(); // Scheme 提供者// 顺带配置 AuthenticationOptions,并指定默认 DefaultScheme

先别被吓到,这 4 个对象就是全文最重要的记忆点,我们把它们记成“鉴权四天王”:

核心对象
作用
一句话理解
AuthenticationSchemeProvider
保存全部 Scheme,用字典按 Name 缓存
登记处,知道有哪些“登录方式”
AuthenticationScheme
2 个名字 + 具体的 Handler 类型
一张“名片”:名字→处理器类型
AuthenticationService
鉴权服务,context.AuthenticateAsync 就是调它
总调度,真正的干活人
AuthenticationHandlerProvider
通过 Scheme 拿到 Handler,完成实例化+初始化+缓存
工厂,按需造出处理器

而 IClaimsTransformation(默认 NoopClaimsTransformation,空实现)是第 5 个隐藏角色,负责“解析完之后给 Principal 做一次转换”,后面讲扩展时会用到。

那 AddScheme 又是怎么回事?关键在这:

// AuthenticationOptions 里publicvoidAddScheme<TOptions, THandler>(...) where ...{    // 其实只是 new 一个 AuthenticationSchemeBuilder,把 Handler 类型存进去    Schemes.Add(new AuthenticationSchemeBuilder(name) { HandlerType = typeof(THandler), ... });}// 注意:此时只是"Builder",还没 Build 成 AuthenticationScheme

重点来了:AddScheme 阶段并没有真正生成 AuthenticationScheme,只是建了个 Builder 存着 HandlerType,用 Name 当 key 存进字典。AddCookie、AddJWT、我们的 AddUrlToken——本质上全一样,都是“字符串 Name 对应一个 Handler 类型”。这就是为什么你可以随意扩展新的鉴权方式。

165.2 启动二:UseAuthentication —— 中间件把 Scheme 真正 Build 出来

注册完 IOC,就该把中间件挂上管道了。挂 UseAuthentication 时会 UseMiddleware<AuthenticationMiddleware>,而这个中间件的构造函数,注入了 AuthenticationSchemeProvider:

publicAuthenticationMiddleware(RequestDelegate next, IAuthenticationSchemeProvider schemes){    _next = next;    Schemes = schemes;   // 注入 Scheme 提供者}// AuthenticationSchemeProvider 构造函数里:foreach (var builder in _options.Schemes)   // 遍历之前 AddScheme 存的 Builder{    var scheme = builder.Build();           // 终于 Build:得到 name + displayName + HandlerType    AddScheme(scheme);                      // 缓存进字典}

也就是说:启动环节收尾时,Provider 把 AddScheme 存的所有 Builder 一次性 Build 成真正的 AuthenticationScheme(带 HandlerType),并缓存进字典。到这一步,“哪个 Name 对应哪个 Handler”才真正落定。

175.3 请求环节:一次 Invoke,绕一圈完成鉴权

启动搞定,请求来了。中间件的 Invoke 方法会执行四步:

publicasync Task Invoke(HttpContext context){    context.Features.Set<IAuthenticationFeature>(new AuthenticationFeature    { OriginalPath = context.Request.Path, OriginalPathBase = context.Request.PathBase });    // ① 扩展点:先给 IAuthenticationRequestHandler 类型一个机会"直接处理请求"(不走后续)    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;    }    // ② 取默认鉴权 Scheme    var defaultAuthenticate = await Schemes.GetDefaultAuthenticateSchemeAsync();    if (defaultAuthenticate != null)    {        // ③ 主动鉴权:AuthenticateAsync 内部会一路绕下去        var result = await context.AuthenticateAsync(defaultAuthenticate.Name);        if (result?.Principal != null) context.User = result.Principal; // ④ 保存到 context.User    }    await _next(context);}

中间这步 context.AuthenticateAsync 是关键,它内部是一套Provider → Handler → Service的多对象协作,我们把调用链画出来: `text context.AuthenticateAsync(name)   └─> AuthenticationService.AuthenticateAsync(name)         ├─ ① 用 AuthenticationHandlerProvider.GetHandlerAsync(context, name)         │     └─ 用 AuthenticationSchemeProvider 找到 AuthenticationScheme         │     └─ 从字典缓存取 Handler;没有就实例化 + InitializeAsync(scheme, context) + 缓存         └─ ② 调 handler.AuthenticateAsync()   ← 我们自定义的解析逻辑就在这         └─ ③ 成功 → IClaimsTransformation.TransformAsync(principal) 做一次转换         └─ ④ 包成 AuthenticationTicket 返回 → 中间件存进 context.User `

绕来绕去,价值就一个词:单一职责。SchemeProvider 管“有哪些方式”,SchemeProvider 管“名字对应什么 Handler”,HandlerProvider 管“造 Handler、初始化、缓存”,Service 管“串联整个流程”,Handler 管“具体的凭证解析”,Transformation 管“转换”。每个对象只干一件事,所以你想扩展任何一个环节,都只需要换掉对应的对象——这,就是它能被你“随意自定义”的根本原因。

185.4 一个执行顺序的细节

这里有个很关键的时序逻辑: 1. AddAuthentication:注册 4 核心对象 + 把 Scheme 和 Handler 注册进 Options(只存 Builder)。 2. UseAuthentication:注册中间件,注入 Provider,在 Provider 构造函数里把 Builder 全部 Build 成 Scheme 并缓存。 3. 请求来了:用默认 Scheme 的名字,按上面那条链绕一圈,完成鉴权,把用户信息存进 context.User。

到这一步,你已经看懂了 ASP.NET Core 鉴权 80% 的真相。剩下 20%,是“三个层次的扩展”——这也是面试和实战里最能拉开差距的地方。

1906 鉴权的三层扩展:Handler、Transformation、多 Scheme

看完源码,我们回到实战最关心的部分——遇到“要加一种登录方式 / 要改造用户信息 / 多种登录并存”时,怎么扩展。这正好是鉴权的三个扩展层次,一层比一层深。

206.1 第一层扩展:自定义 IAuthenticationHandler(换“凭证解析”)

就是我们第四部分写的 UrlTokenAuthenticationHandler。它解决的是“凭证来源、凭证格式、校验规则”全部自定。要加一种新的登录方式(比如“临时授权链接”“扫码”),你就实现一套新的 Handler + AddScheme 注册,其余全复用。这是最彻底的扩展,也是理解源码的入场券。

216.2 第二层扩展:自定义 IClaimsTransformation(改“用户信息”)

有时候你不想换凭证方式,只想在“解析完之后、存进 context.User 之前”,给用户信息加点料。这就是 IClaimsTransformation 的用武之地。默认是空实现(NoopClaimsTransformation,原样返回),你可以替换成自己的:

public class CustomClaimsTransformation : IClaimsTransformation{    public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)    {        // 如果是中国人,就给声明里补一条 language=cn        if (principal.Claims.FirstOrDefault(c => c.Type.Equals(ClaimTypes.Country))                ?.Value.Equals("Chinese") ?? false)        {            principal.Claims.Append(new Claim("language", "cn"));        }        return Task.FromResult(principal);    }}// 替换注册(替换默认的 NoopClaimsTransformation):builder.Services.Replace(ServiceDescriptor.Scoped<IClaimsTransformation,    CustomClaimsTransformation>());

注意它的执行时机:在 AuthenticationService.AuthenticateAsync 里,handler 解析成功之后、返回 Ticket 之前,会调 Transform.TransformAsync(principal) 一次。最常见的落地场景是:从 DB/Redis 实时补充用户角色、权限、部门等声明——不用把大而全的信息都塞进 Cookie。

226.3 第三层扩展:多 Scheme 并存(Cookie + UrlToken 同时在线)

这是最容易踩坑、也最能体现功底的一层。程序完全可以同时支持多个 Scheme 并存——比如同一个站点,Web 端走 Cookie,接口/扫码走 UrlToken:

builder.Services.AddAuthentication(options =>{    options.AddScheme<UrlTokenAuthenticationHandler>(UrlTokenAuthenticationDefaults.AuthenticationScheme,        "UrlTokenScheme-Demo");    // 默认 Scheme 指向 UrlToken    options.DefaultAuthenticateScheme = UrlTokenAuthenticationDefaults.AuthenticationScheme;    options.DefaultChallengeScheme    = UrlTokenAuthenticationDefaults.AuthenticationScheme;    ...}).AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>{    options.LoginPath = "/Home/Index";    options.AccessDeniedPath = "/Home/Privacy";}); // 又一个 Scheme:Cookie

但这里有个极易误解的点,必须讲透:鉴权中间件(UseAuthentication)只走“默认 Scheme”做一次解析,并不会把所有 Scheme 都解析一遍。那“其他 Scheme 的用户信息怎么拿到”?答案是——靠授权标记([Authorize] 上的 AuthenticationSchemes)来指定,或者靠主动鉴权挨个取:

// 方式一:显式指定用哪个 Scheme 授权[Authorize(AuthenticationSchemes = CookieAuthenticationDefaults.AuthenticationScheme)]// [Authorize(AuthenticationSchemes = "Cookies,UrlTokenScheme")] // 多个 Scheme 逗号分隔// 方式二:主动鉴权,把多个 Scheme 的用户信息都拿出来var urlToken = await HttpContext.AuthenticateAsync(UrlTokenAuthenticationDefaults.AuthenticationScheme);var cookie   = await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);

那么问题来了:多 Scheme 并存时,授权到底怎么算?这其实是个复杂话题,关键在“Principal 的合并”——框架会通过 SecurityHelper.MergeUserPrincipal 把多个 Scheme 解析出的身份合并成一个 ClaimsPrincipal(新身份放前面,旧的非空身份保留),这样授权检测就能同时看到两边的声明:

publicstatic ClaimsPrincipal MergeUserPrincipal(ClaimsPrincipal? existing,                                                 ClaimsPrincipal? additional){    var newPrincipal = new ClaimsPrincipal();    if (additional != null) newPrincipal.AddIdentities(additional.Identities); // 新身份在前    if (existing != null)   newPrincipal.AddIdentities(existing.Identities        .Where(i => i.IsAuthenticated || i.Claims.Any()));                     // 旧的非空身份保留    return newPrincipal;}

这正是你在 Demo 里能看到 UrlCookieByDouble、UrlCookieByUrlToken、UrlCookieByCookie 好几个 Action 的原因——同样的资源,用不同 Scheme(或 Scheme 组合)授权,得到的是不同身份来源,声明的交集/并集也就不同。理解多 Scheme,核心就是理解“默认只解析默认 Scheme,其它靠标记指定,最终按需合并 Principal”。

236.4 顺带一提:Ticket 的存放也能扩展

再深一层,连“鉴权成功后 Ticket 存哪”都能改。默认 Cookie 鉴权把 Ticket 序列化进 Cookie;你也可以实现 ITicketStore 存到内存/Redis,Cookie 里只放一个 Key。Demo 里的 MemoryCacheTicketStore 就是例子,适合“凭证太长、想减轻 Cookie 体积”或“想统一管理会话”的场景。鉴权的扩展性,就是设计出来的。

2407 写在最后:你该带走的判断框架

文章有点长,但值得。最后给你一套能直接带走的判断框架:

以后遇到鉴权授权需求,先回答三个问题:

1. 要“认人”还是“认方式”?只是用户信息加工,走第二层(IClaimsTransformation);要全新的凭证格式/来源,走第一层(自定义 IAuthenticationHandler)。 

2. 要不要多种登录并存?要,就走第三层多 Scheme,记住“默认只解析默认 Scheme,其它靠 [Authorize] 的 AuthenticationSchemes 指定,按需合并 Principal”。 

3. 授权规则够不够灵活?内置 Roles/Policy 不够,就自定义 Requirement + Handler(实现 IAuthorizationHandler),这是授权侧的另一个扩展世界。

回到开头那个问题:为什么 AddAuthentication + AddCookie + UseAuthentication + [Authorize] 能用?因为框架把“凭证位置、凭证格式、有效性校验、信息传递、异常处理”五件事全部封装成了可替换的对象。你看懂了这条链:AddScheme 存 Builder → 中间件 Build 成 Scheme → 请求来用默认 Scheme → HandlerProvider 造 Handler → handler.AuthenticateAsync 解析 → IClaimsTransformation 转换 → 存进 context.User——从此它对你不再是魔法。

理解鉴权,就是理解“怎么把该记的东西记清楚、该拆的职责拆干净”。框架给你留了接口,剩下的,就看你要往哪个方向扩展了。

这套体系讲完鉴权,还有更大的半边天——授权(Authorization)的策略组装、Policy 的构建、以及多 Scheme 下的授权合并。那才是“鉴权之后真正决定谁能进来”的最后一公里。想接着深入,点个关注,下篇见分晓。

THE END

相关学习资料