前阵子把一个跑了很久的 .NET Core 2.1 老项目的接口鉴权方式改成了 JWT整个思路就是标题这套.Net Core2.1使用jwt生成Token做身份认证。前后端分离的项目里Session 方案确实别扭登录态存在服务端前端换个域名就失效接口要给第三方调用时还得处理 Cookie 携带问题。改成 JWT 之后服务端只负责签发和校验客户端拿到的就是一串自包含的 Token谁拿到谁用不依赖服务端存储运维和对接都省心不少。这篇文章我把完整落地过程写出来从包引用、Startup 配置、Token 生成到接口测试、常见坑尽量按我在项目里实际踩过的顺序来。给正在用 2.1 做维护项目、或者刚接手老项目要加认证的人一个可以直接照抄的参考。1. 为什么我在这套 2.1 项目里选了 JWT1.1 项目里的旧认证方式撑不住了先说背景。项目是一个典型的运营后台接口服务最初是后端渲染页面登录后把用户信息塞在 Session 里接口层通过 Session 判断是否登录。这套方案在纯服务端场景下问题不大但后来要接小程序、要开放接口给合作方问题就全冒出来了Session 依赖服务端内存或分布式缓存接口服务一旦多实例部署Session 同步就成了事。客户端身份凭证是 Cookie小程序、App、第三方服务去维护 Cookie 解析并不方便。每个请求都要回服务端查一次会话状态接口本身是无状态的但整个认证链路却变成了有状态。我当时需要一个不依赖服务端存储、能跨平台传递、接口层能快速校验的认证方案。JWT 正好命中这些诉求。1.2 JWT 解决的核心痛点JWT 全称 JSON Web Token简单理解就是把用户身份信息加密签名后塞进一个字符串里发给客户端。客户端后续每次请求只要带上这个字符串服务端验一下签名就能确认“你就是你”不再需要翻数据库、查缓存、比对 Session。这种无状态特性带来的直接好处有三个接口天然支持水平扩展任何一台节点都能独立校验 Token不需要共享会话存储。前端、小程序、App 对接时只需要在请求头里塞一个字符串不需要处理 Cookie 的各种安全属性。权限信息可以放进 Token 本身比如角色、用户 ID接口校验时直接从 Token 里读减少一次数据库查询。当然JWT 不是银弹有状态需求、强制踢人下线、精细的会话撤销这类场景它反而麻烦。但对这个项目来说无状态接口认证带来的收益远大于损失。1.3 三段式结构一句话讲明白JWT 长什么样它由三段 Base64Url 编码的字符串组成中间用英文句点分隔大概长这样eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmdvIjoiYWRtaW4ifQ.7dZ3vZQ2J3RohXk3Kf6y3k5oGxK0hY4rWb1Q9sV2pKQ第一段是 Header声明签名算法和 Token 类型第二段是 Payload放用户 ID、角色、过期时间这些实际数据第三段是 Signature用密钥把前两段内容做签名。Signature 存在的意义就是防止有人把 Payload 里的“admin”改成“superadmin”后再丢给服务端——签名对不上服务端直接拒绝。我用生活里的例子给新人解释Token 就像一张带防伪标识的入场券票面上写着你是谁、能坐哪个区、几点失效防伪标识一验就知道券被人改过没有。至于游乐园里你是谁、能去哪票上写得清清楚楚检票员不用回总部查花名册。2. 环境准备与最小配置2.1 创建项目与引用 NuGet 包我用的是 .NET Core 2.1开发环境是 Visual Studio 2019也可以通过命令行直接创建dotnet new webapi -n JwtDemo cd JwtDemo dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 2.1.1需要注意JwtBearer 包版本必须跟目标框架匹配。2.1 项目就老老实实用 2.1.x 版本不要图新鲜装 5.x、6.x 的包版本跨太大可能出现依赖冲突或者 API 行为不一致。如果你项目里已经引用了Microsoft.AspNetCore.App元包这个包本身已经带上 JwtBearer 组件可以直接写代码不用显式安装但我在维护项目里还是习惯显式引用一次避免某些环境还原依赖时不明确。2.2 appsettings.json 配置项怎么写在appsettings.json里新增一个Jwt节点保存 Issuer、Audience、过期分钟数、密钥这几项{ Jwt: { Issuer: JwtDemo, Audience: JwtDemoClient, ExpireMinutes: 120, SecretKey: your-random-secret-key-MUST-be-at-least-32-characters } }Issuer 是 Token 签发方标识Audience 是 Token 的接收方标识。这两个信息在服务端校验时会严格比对任何一项对不上都视为非法 Token。很多新手项目不配这两个字段虽然也能工作但安全性弱很多。真正的多服务架构里不同服务可能校验同一个签发方的 Token但 Audience 不同以此区分这个 Token 是给哪个服务用的。SecretKey 是最关键的一项。它必须足够长、足够随机至少 32 个字符以上。因为 JWT 的签名用 HMAC-SHA256 算法时密钥长度直接关系到暴力破解难度。我见过有人直接在线上配置里写一个四五个字符的“test123”这种签名可以被分分钟撞出来。正式项目里密钥建议放环境变量或者用户机密里不写进配置文件提交到代码仓库。2.3 Startup.cs 里注册 JwtBearer 认证在ConfigureServices里注册认证服务和校验参数public void ConfigureServices(IServiceCollection services) { services.AddSingletonJwtHelper(); var jwtSection Configuration.GetSection(Jwt); services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options { options.TokenValidationParameters new TokenValidationParameters { ValidateIssuer true, ValidIssuer jwtSection[Issuer], ValidateAudience true, ValidAudience jwtSection[Audience], ValidateLifetime true, ClockSkew TimeSpan.FromMinutes(1), ValidateIssuerSigningKey true, IssuerSigningKey new SymmetricSecurityKey( Encoding.UTF8.GetBytes(jwtSection[SecretKey]) ) }; }); services.AddMvc(); }这块代码做的事情是告诉框架以后所有请求进来先去看请求头里的Authorization如果带着Bearer xxx形式的 Token就按照我给定的一套规则去验证。验证通过请求体里就带上用户的身份信息控制器里可以通过User去读取验证不通过直接返回 401不会进到控制器。注意JwtHelper是我自定义的 Token 生成辅助类这里先注册成单例后面签发 Token 直接注入使用。它的实现我会放到下一节详细说。3. 签发 Token从登录接口到可用的 JWT3.1 生成 Token 的辅助类Token 不是随便拼个字符串就行要用官方库生成并且把用户信息编码成 Claim。Claim 可以理解为 Token 里的一条条身份声明比如用户 ID 是“u001”、用户名是“admin”、角色是“Admin”。我一般单独写一个JwtHelper类public class JwtHelper { private readonly IConfiguration _configuration; public JwtHelper(IConfiguration configuration) { _configuration configuration; } public string GenerateToken(LoginUser user) { var jwtSection _configuration.GetSection(Jwt); var secretKey jwtSection[SecretKey]; var issuer jwtSection[Issuer]; var audience jwtSection[Audience]; var expireMinutes Convert.ToInt32(jwtSection[ExpireMinutes]); var claims new ListClaim { new Claim(ClaimTypes.NameIdentifier, user.Id), new Claim(ClaimTypes.Name, user.UserName), new Claim(ClaimTypes.Role, user.Role), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) }; var key new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)); var creds new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var token new JwtSecurityToken( issuer: issuer, audience: audience, claims: claims, notBefore: DateTime.Now, expires: DateTime.Now.AddMinutes(expireMinutes), signingCredentials: creds ); return new JwtSecurityTokenHandler().WriteToken(token); } }这里面有几个细节值得说道说道。ClaimTypes.NameIdentifier 和 ClaimTypes.Name 是 .NET 框架里通用的声明类型映射到 JSON 时会变成一长串 XML 命名空间形式的字符串。如果你希望 Token 里直接看到“name”“nameid”这种短字段可以用 JwtRegisteredClaimNames.Sub、JwtRegisteredClaimNames.UniqueName 这类 JWT 标准类型。但要注意用标准类型时后面读取身份信息可能会有映射问题这个我放到第 5 节专门讲。Jti是 JWT 标准里的唯一标识配合Guid.NewGuid()保证每个 Token 不重复。这个字段在日志追踪和 Token 撤销场景里很有用成本不高我会建议保留。过期时间设置在expires里这里用本地时间。实际项目中建议统一使用 UTC 时间避免部署在不同时区的服务器上出现过期时间偏差。我在项目里后来改成了DateTime.UtcNow.AddMinutes(...)跨区部署时省了很多事。3.2 在登录接口中调用上面辅助类写好之后登录接口就很简单了。先定义一个简单的用户实体和一个登录请求 DTOpublic class LoginUser { public string Id { get; set; } public string UserName { get; set; } public string Role { get; set; } } public class LoginRequest { public string UserName { get; set; } public string Password { get; set; } }然后写登录接口[Route(api/[controller])] [ApiController] public class AuthController : ControllerBase { private readonly JwtHelper _jwtHelper; public AuthController(JwtHelper jwtHelper) { _jwtHelper jwtHelper; } [AllowAnonymous] [HttpPost(login)] public IActionResult Login([FromBody] LoginRequest request) { // 演示代码正式项目一定改成数据库校验并做密码哈希 if (request.UserName admin request.Password 123456) { var user new LoginUser { Id u001, UserName request.UserName, Role Admin }; var token _jwtHelper.GenerateToken(user); return Ok(new { code 0, data new { token token } }); } return Unauthorized(new { code 401, message 用户名或密码错误 }); } }AllowAnonymous是必须的否则登录接口本身也会被认证中间件拦住造成死循环没有登录就拿不到 Token没有 Token 就不能登录。我见过不少人把密码校验写在 Controller 里这是功能能跑但很难维护的写法。实际项目里应该把校验逻辑抽成独立的服务密码还要加盐哈希而不是明文比对。这里为了聚焦 JWT 本身把校验逻辑故意写得很短业务代码顶上。3.3 用 curl 把完整链路跑通项目跑起来之后先用 curl 请求登录接口拿 Tokencurl -X POST http://localhost:5000/api/Auth/login \ -H Content-Type: application/json \ -d {userName:admin,password:123456}正常会得到一串{code:0,data:{token:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...}}。然后拿这个 Token 去请求受保护接口。我随便在测试项目里加了一个需要登录才能访问的接口curl http://localhost:5000/api/User/list \ -H Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...加上Authorization头才能拿到 200 和业务数据不加这个头就是 401。这里非常容易踩的坑是有些人认为只要“带着 Token”就行却漏掉了Bearer前缀。框架默认只认Bearer xxx这种格式xxx裸奔是无效的。4. 中间件顺序与授权控制90% 的坑都在这4.1 UseAuthentication 的位置决定成败Startup.cs的Configure方法里中间件顺序有讲究。2.1 的管道配置大概是这样的public void Configure(IApplicationBuilder app, IHostingEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } // 如果启用了 CORS就放最前面 app.UseCors(AllowAll); // 认证中间件必须在这里 app.UseAuthentication(); app.UseMvc(); }UseAuthentication()必须在UseMvc()之前执行。中间的认证中间件会读取请求带上来的 Token 并校验校验通过后才会继续往管道后面走最终走进 MVC 路由和 Controller。如果你把顺序写反了请求会先进入 MVCController 上的[Authorize]虽然还能拦住请求但认证结果已经丢失或者处理方式不对表现就是各种莫名其妙的 401、403。在 .NET Core 3.0 以上的项目里Configure中还要多一行app.UseAuthorization()放在UseAuthentication()和UseMvc()之间。2.1 里没有这个中间件不要照抄新版本配置硬塞一行会直接找不到方法。4.2 方法级别的角色控制认证通过只是第一层具体到接口粒度还有授权控制。给接口加上[Authorize]就能要求“必须登录”进一步还可以要求“必须是指定角色”[Route(api/[controller])] [ApiController] public class UserController : ControllerBase { [HttpGet(list)] [Authorize] public IActionResult List() { // 任何登录用户都能访问 return Ok(new { users new[] { admin, dev } }); } [HttpPost(create)] [Authorize(Roles Admin)] public IActionResult Create([FromBody] object user) { // 只有 Admin 角色能访问 return Ok(new { message created }); } }这里背后的判断逻辑是Token 里有没有携带符合要求的 Claim。ClaimTypes.Role对应的值如果匹配了Admin这个请求就放行不匹配返回 403。我实际遇到过一个问题Token 生成时角色用的键名和授权判断时用的键名不一致导致明明 Token 里有角色信息但[Authorize(RolesAdmin)]始终不认。原因是 Claim 类型在序列化到 JWT 时被映射成了另一个字段名。解决办法有两个要么生成 Token 时统一用标准类型要么在TokenValidationParameters里配置正确的RoleClaimType。4.3 给不同接口配不同认证策略如果一套系统里既有面向 App 用户的前端接口又有内部管理接口两者想用不同的认证方案可以在ConfigureServices里注册多个认证 Scheme。比如“App”用 JWT“内部”用另一个固定 Header 的简单校验。在 2.1 里注册多 Scheme 的写法稍显繁琐加上日常大多数项目只需要一种 JWT 方案我建议新手先别贪多把单方案跑通了再去扩展。真的需要多方案时[Authorize(AuthenticationSchemes App)]这种方式是能用的按微软官方文档来就行这里不展开。5. 常见问题与排查实录5.1 Token 生成成功但请求始终 401这是出现频率最高的问题没有之一。可能的原因按概率排序如下请求头格式不对。框架要求Authorization: Bearer tokenToken 前面必须有Bearer和空格。用 Postman 调试时Auth 类型要选 Bearer Token不要手动把 Token 粘到 Headers 里却漏了前缀。生成 Token 的密钥和服务端校验的密钥不一致。比如appsettings.json改过、多个环境配置不同步都会导致签名校验失败。这种情况的报错日志里通常会有IDX10501: Signature validation failed. Unable to match key.看到这个就直接查密钥。中间件顺序错了。UseAuthentication()必须在UseMvc()前面检查Configure方法里的顺序。启动了多个 Scheme但没指定默认方案导致认证中间件不知道用哪个方案解析 Token。排查建议先把框架的认证日志拉起来。在appsettings.json里加上日志等级配置把Microsoft.AspNetCore.Authentication输出为 Debug就能看到中间件当时的决策过程和具体报错信息非常直观。5.2 过期时间比预期短一截JWT 标准里有两个时间相关字段nbfnot before和expexpires。如果你的系统时间和签发服务时间不一致或者时区没统一就会出现“明明从生成到请求不到两个小时却提示过期”。还有一个小细节TokenValidationParameters里有ClockSkew属性默认值是 5 分钟。它的意思是允许 Token 过期前后 5 分钟内的误差。如果你希望严格一点可以设置ClockSkew TimeSpan.Zero。我在支付回调、对账这类接口上会把窗口收紧普通业务接口保留 1-2 分钟即可太严格反而容易误伤正常请求。5.3 密钥太短引发的异常生成密钥时用的是SymmetricSecurityKey配合HmacSha256签名算法这个算法有最小密钥长度要求。我第一次测试时随手写了个abc当密钥运行时报错IDX10653: The encryption algorithm HS256 requires a key size of at least 128 bits.原因是abc只有 24 位字节不满足 128 位要求。后来我把密钥换成一长串随机字符至少 32 个字符以上这种报错就再也没遇到。这里补一句上线之前一定要把密钥改成随机生成的高强度字符串我建议用密码生成器直接生成 64 个字符的随机序列。5.4 控制器里读不到 User.Identity.NameToken 签发成功、认证也通过了但你在 Controller 里写User.Identity.Name返回的是 null。这个问题的根源跟 Claim 类型映射相关。.NET 在把 JWT 反序列化成 ClaimsPrincipal 的时候默认会做一次 Claim 类型映射。比如标准声明sub会尝试映射到ClaimTypes.NameIdentifier但名字映射不一定符合预期。常见的解决办法是在配置 JwtBearer 时把映射关系清掉JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); JwtSecurityTokenHandler.DefaultOutboundClaimTypeMap.Clear();清完映射之后Token 里的name字段就直接对应ClaimTypes.Namerole对应ClaimTypes.Role代码里读起来不会出现“明明签发时有用户名读出来却是 null”这种情况。5.5 跨域请求被拦截前端项目在localhost:8080接口在localhost:5000浏览器会发起预检请求。JWT 在请求头里带着Authorization跨域配置必须允许该头。2.1 里启用 CORS 的写法services.AddCors(options { options.AddPolicy(AllowAll, builder builder.AllowAnyOrigin() .AllowAnyHeader() .AllowAnyMethod()); });Configure方法里按顺序执行app.UseCors(AllowAll)后再到app.UseAuthentication()再到app.UseMvc()。顺序很重要CORS 放最后预检请求都不会有机会走到认证逻辑浏览器就直接报错了。这里补充一点安全经验开发环境可以AllowAnyOrigin生产环境建议限定具体域名否则任何一个网站都可以往你的接口发请求如果 Token 是放在 LocalStorage 里的跨站读取和请求伪造的风险会明显上升。5.6 2.1 和更高版本配置差异现在网上大多数教程写的是 .NET 6、.NET 8 的版本照抄到 2.1 会踩坑。3.0 之后多了UseAuthorization()还引入了EndpointRoutingAddMvc和AddControllers的选择也不同。2.1 项目里配置代码基本以我上面的写法为准不要强行把新版本的代码段搬进来。版本差异还体现在包依赖上。2.1 项目如果安装了高版本的Microsoft.AspNetCore.Authentication.JwtBearer运行时经常报“程序集版本冲突”。我当时花了大半天才悟过来必须锁定 2.1.1 版本。5.7 发了 Token 却不知道 Token 里装了什么排查问题时把 Token 内容解码出来看一眼很有用。可以直接用 jwt.io 这类在线工具粘贴 Token 查看在本地也可手写两行代码解码var handler new JwtSecurityTokenHandler(); var jwt handler.ReadJwtToken(token); Console.WriteLine(jwt.Claims.Select(c ${c.Type}: {c.Value}));生产环境不建议大规模打印 Token 内容里面可能有用户敏感信息。调试阶段临时用一用排查完了记得删掉日志。6. 一些操作习惯上的建议6.1 Claim 只放必要信息不要什么都塞我把登录名、用户 ID、角色放进 Token这就够了。有人会把手机号、邮箱甚至家庭住址都塞进去这会造成两个问题Token 中 Payload 只是 Base64Url 编码不是加密别人拿到 Token 直接解码就能看到全部内容而且 Token 会随每个请求在网络上传输塞得越多请求头越大浪费带宽。真要拿到用户的详细信息正确做法是只保留用户唯一标识接口需要时再去数据库或缓存查询。我给 Token 里加角色字段是为了避免每个接口都查数据库确认角色权限这是一个权衡但敏感字段绝对不进 Token。6.2 给 Token 一个合理的生命周期我在前面配置里写的是 120 分钟过期。实际项目要根据业务场景定交易类、支付类接口建议短一点30 分钟以内后台管理系统可以长一点8 小时甚至一天App 长期登录需要配 Refresh Token那又是一套机制这边不展开。特别提醒一点过期时间别设太长否则用户账号被锁定、密码被修改后手里 Token 在过期前仍然有效。这不是 JWT 设计缺陷而是无状态认证的自带属性。如果这个场景很重要需要在鉴权时增加黑名单校验或者改用服务端会话方案。6.3 把配置和密钥从代码中剥离源码仓库里出现明文密钥等于是把系统大门钥匙贴在了公告栏上。我后来把Jwt:SecretKey从appsettings.json里挪到了环境变量开发环境用用户机密文件。部署的时候由运维在服务器上注入环境变量。这样即使代码仓库泄露密钥也不会随之泄露。实际操作里环境变量读取方式和appsettings.json是兼容的Configuration会自动把环境变量映射为配置项。你只需要保证IServiceCollection里能读到同一个键名就行。6.4 换一套 Token 风格继续扩展这套 JwtBearer 配置跑通以后后续做权限系统扩展并不难。想在所有受保护接口前加统一日志可以在JwtBearerEvents的OnTokenValidated事件里加代码想在 401 时统一返回 JSON 格式而不是空响应可以在OnChallenge里重写响应体。这些扩展点比 Session 时代灵活得多建议有需要时去翻一下官方文档对应事件表。我个人在实际操作中的体会是JWT 这方案在 .NET Core 2.1 时代之所以流行核心在于无状态和跨端友好。如果你也正在维护老项目先按文中的顺序把最小链路搭通再逐步补角色细化、Token 刷新、日志追踪这些进阶功能。踩坑多半集中在中间件顺序和版本匹配这两点解决了后面基本一路畅通。
阅读完成 · 觉得有帮助?