7.1 认证 Authentication 认证把凭证还原成身份:登录时验证用户名口令、种下一张加密票据;此后每个请求带着票据进门,认证站验票、解票、把"声明集合"组装成用户主体贴到 HttpContext 上。Cookie 认证走这条往返,JWT 把票据本身变成自证身份的令牌——两条路线服务两类场景。 守门人上岗第一天先学认票。观察哨本节完整跟拍一次 Cookie 认证往返——它把"登录"这个日常动作拆成了六个可观察的步骤;然后再看接口世界的 JWT:没有 Cookie 的往返,靠签名自证。两条路线的分工,决定了你该给项目配哪种票。 身份的数据结构:声明主体 先统一词汇。
认证把凭证还原成身份:登录时验证用户名口令、种下一张加密票据;此后每个请求带着票据进门,认证站验票、解票、把"声明集合"组装成用户主体贴到 HttpContext 上。Cookie 认证走这条往返,JWT 把票据本身变成自证身份的令牌——两条路线服务两类场景。
守门人上岗第一天先学认票。观察哨本节完整跟拍一次 Cookie 认证往返——它把"登录"这个日常动作拆成了六个可观察的步骤;然后再看接口世界的 JWT:没有 Cookie 的往返,靠签名自证。两条路线的分工,决定了你该给项目配哪种票。
先统一词汇。认证的产物是 ClaimsPrincipal(用户主体),它由一组 Claim(声明)构成——每条声明是"主体的一条属性":姓名是声明、角色是声明、邮箱也是声明。这个设计比"用户对象"抽象,但换来了表达力:任何认证方案(本库账号、微信、企业目录)产出的都是同一结构,授权站(7.2 节)只读声明不问出身。
// 手工组装一个主体,看它的构成 var claims = new List<Claim> { new(ClaimTypes.Name, "liwei"), new(ClaimTypes.Role, "Operator"), new("Dept", "仓库一部") // 自定义声明:业务想带什么带什么 }; var identity = new ClaimsIdentity(claims, "本地登录"); // 第二参是认证方式名 var principal = new ClaimsPrincipal(identity); Console.WriteLine(principal.Identity!.Name); // 输出:liwei Console.WriteLine(principal.IsInRole("Operator")); // 输出:True
管线里的一切身份操作都落在 ClaimsPrincipal 上——控制器与页面模型里的 User 属性就是它。认证方案的全部职责可以压缩成一句话:把进门请求变成一个可信的 ClaimsPrincipal。
浏览器场景的标准方案。启用它 + 一个登录端点,观察全流程:
// 1. 启用 Cookie 认证 builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(o => o.LoginPath = "/login"); // 未登录访问受保护页时重定向到这里 builder.Services.AddAuthorization(); var app = builder.Build(); app.UseAuthentication(); // 认证站:次序在路由之后、授权之前(2.2 节铁律) app.UseAuthorization(); // 授权站 // 2. 登录端点:验证凭证并种票 app.MapPost("/login", async (LoginInput input, HttpContext ctx) => { // 演示用硬编码校验;生产环境口令必须走散列比对(7.2 节) if (input.User != "liwei" || input.Pass != "demo-123") return Results.Unauthorized(); // 401:验票失败 var claims = new List<Claim> { new(ClaimTypes.Name, input.User), new(ClaimTypes.Role, "Operator") }; var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "Cookie")); await ctx.SignInAsync(principal); // 关键一跳:写票据、种 Cookie return Results.Ok("登录成功"); }); // 3. 受保护的端点 app.MapGet("/stock/count", (HttpContext ctx) => $"当前身份:{ctx.User.Identity!.Name},库存 42").RequireAuthorization();
六步往返对应着代码与浏览器的配合:提交凭证(表单进登录端点)→ 验证(比对)→ 写票据(SignInAsync 把声明序列化并加密)→ 种 Cookie(票据放进去随响应下发)→ 携带(此后每个请求浏览器自动带上)→ 还原(认证站解密票据、组装主体贴到请求上)。响应里的 Set-Cookie 头与后续请求里的 Cookie 头,用观察哨中间件打印原始头就能看到。

Cookie 依赖浏览器的自动携带机制,接口的调用方(移动端、服务间调用)没有这套机制,JWT 是它们的通用票。JWT 三段结构:头(算法)+ 载荷(声明集合)+ 签名(用服务端密钥对前两段签名)。要点常被误解:载荷只是编码不加密,谁都能读;签名只保证"没被改过",不保密。
// 接口侧启用 JWT 验签 builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(o => { o.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = "obs-station", // 谁发的 ValidateAudience = true, ValidAudience = "obs-api", // 发给谁的 ValidateLifetime = true, // 过期时间有效 IssuerSigningKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!)) }; }); // 登录端点签发令牌(示意:实际项目中密钥长度必须足够) var token = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken( issuer: "obs-station", audience: "obs-api", claims: claims, expires: DateTime.UtcNow.AddHours(2), signingCredentials: creds)); // 返回的 token 串形如 xxx.yyy.zzz:头.载荷.签名
调用方此后每个请求带 Authorization 头(Bearer 加令牌)。认证站收到后做三件事:用同一密钥重算签名比对(防篡改)、核对发行者与受众(防拿别家的票进门)、查有效期。全部通过组装主体——接口世界没有 Cookie 往返,验票逻辑全在站内完成。
案例复盘:某项目把角色声明放 JWT 载荷里,后来发现"改了用户角色要等令牌过期才生效"。定位:JWT 一旦签发就是终局,服务端只验签不回查。解法两选:短有效期加刷新令牌(角色变化最多延迟一个短周期),或关键操作实时查库校验。解读:Cookie 方案的"身份"每请求可重新装配,JWT 方案的"身份"冻结在签发时刻——时效性与实时性之间的取舍是 JWT 的固有属性,不是配置失误。变式:高敏系统用"JWT 只证明持有者是谁,权限实时查"的混合模式。
按调用方定:浏览器渲染型站点(第 3、4 章的页面)选 Cookie——浏览器自动携带、生态成熟;接口服务(第 6 章)与移动端选 JWT——无状态、跨域友好。两者可以在一个项目并存(多方案认证),按端点类型分别挂。
⚠️ 常见坑:JWT 密钥写死在代码里或提交进仓库。密钥一旦泄漏,任何人可签发合法身份。正确姿势是配置系统出密钥(开发用用户机密、生产用环境变量,2.3 节机制),密钥轮换有预案。
💡 关键直觉:认证是"验票不查座"——它只负责把门口的凭证变成可信身份,不判断这个人能干什么。判断能干什么是授权站的活,别在认证里越权。