算年头,.Net Core 2.1都已经是微软官方停止支持的老框架了,但我发现直到今天,还有不少老项目在它上面跑着,而且最典型的需求之一就是“用JWT生成Token做身份认证”。不夸张地说,JWT已经成了前后端分离架构下做认证的事实标准。这篇文章我不打算贴一大堆官方文档,而是从一个做过的真实项目角度出发,把这套东西从原理到落地,再到排查问题,完整拆开揉碎讲一遍。
如果你正在维护一个.Net Core 2.1的老项目,或者刚接手这样的代码,又或者只是想搞明白JWT在服务端到底是怎么“验”的,这篇文章都适合你。我会把Token生成、中间件配置、Claims设计、常见401/403问题一次讲清楚,很多细节是官方文档里不会写的。
1. 为什么要用JWT而不是Session
很多从.Net Framework时代转过来的同学,第一时间想的是:以前用Session不也挺好的?确实,传统的Session认证方式在小规模单体应用里完全够用,但它存在一个比较麻烦的先天问题——Session存储在服务器内存里,客户端只保存一个SessionId。用户量一旦上来,或者你需要横向扩容部署多台服务器,Session就变成了一个坑:这台机器登录的,另一台机器不认识,得专门去做Session共享,比如用Redis存Session。这些工作能跑通,但复杂度明显上去了。
JWT的思路完全不同:用户登录成功后,服务器算出一个经过签名的Token,把它直接返回给客户端。客户端之后的每一次请求,在请求头里带上这个Token,服务器只要验签通过就放行。服务器本身不存Token,所以也就没有“分布式Session同步”这种烦恼。这种无状态的设计对于构建Web API、小程序后台、App接口,尤其是前后端完全分离的项目,非常友好。
1.1 无状态认证为什么更适合现在的架构
现在的项目很少是一个单体MVC走天下,前端一个Vue或React应用,后端一个Web API,甚至后端还有好几个服务。这种情况下如果用Session,会出现几个让人头疼的问题:
- 多端登录状态难以统一管理,Web端、小程序、App各有一套SessionId,处理起来很分裂。
- Session默认依赖Cookie传递,但App或小程序里对Cookie的支持并不像浏览器那样“开箱即用”。
- 服务端扩展时,会话数据同步总是绕不开的额外工作。
JWT把用户身份信息直接编码在Token里面,服务端无状态,接口层做了鉴权之后,剩下的就是把验签密钥配置好。多台机器只要密钥一致,都能验证同一个Token。这就让认证这块从“会话存储问题”简化成了“加密验签问题”,逻辑上干净得多。
1.2 JWT适合什么项目,不适合什么项目
每个技术选型都有它的边界。JWT比较适合:
- 纯API接口服务,尤其是前后端分离的项目。
- 登录状态需要被多个客户端(浏览器、小程序、App)共享的场景。
- 希望认证逻辑简单、服务端无状态、便于横向扩容的系统。
但不适合的场景也有不少:
- 服务端需要主动踢人下线的场景,比如后台管理系统要封禁某个用户,JWT在过期之前会一直有效,除非你引入黑名单机制。
- 对Token实时失效要求极高的场景,JWT的“无状态”反而是缺点,要兼容“主动失效”就得额外维护状态,绕来绕去又回到了起点。
- 同一个浏览器多标签切换、频繁刷新时,可能遇到Token过期的体验问题,也需要前端做额外处理。
我当时接手这个项目的时候,需求很明确:一套Web API给前端和后端管理系统共用,认证方式要简单可靠,还要支持后续多实例部署。JWT几乎是唯一不用绕路的选择。如果你的业务不是上面那几种特例,用JWT基本不会错。
2. JWT的结构拆解:三段式到底存了什么
JWT全称是JSON Web Token,它不是一个“加密”后的内容,而是一个经过Base64编码再加签名的字符串。这点必须从一开始就搞清楚——Token里能看到的信息都是明文的,任何人拿到Token都可以用Base64解码看到里面的内容。所以千万不要把密码、身份证号这类敏感数据直接塞到Token里。JWT的“安全”靠的是签名防止篡改,不是靠加密防偷看。
一个标准的JWT由三部分组成,用点号隔开,长这样:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjoxNjI2OTM1MjAwfQ.8gGxUgfiL9JcP3a7HmJZnlBx5Q0qV6dGtZvsjyc3Ws2.1 Header和Payload的作用
第一部分是Header,里面记录了签名算法和Token类型。最常见的组合是{"alg":"HS256","typ":"JWT"},表示用HMAC-SHA256做签名,类型是JWT。
第二部分是Payload,也就是承载的数据部分,里面放的是你声明的Claims。通常包括:
sub:主题,一般放用户唯一ID。name:用户名或显示名。role:角色信息。iat:Token签发时间。exp:Token过期时间。
这里要注意一个细节:在.Net Core 2.1的JwtSecurityTokenHandler中,如果你使用JwtRegisteredClaimNames.Sub这类系统定义的Claim名称,它在序列化和反序列化时可能会被自动映射成XML Schema风格的长名称(比如http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier)。这会导致你后面从HttpContext.User里按sub去查时取不到值。我之前在这个问题上卡了一段时间,后面细讲。
2.2 签名是怎么算出来的
第三部分Signature就是签名,它的计算过程是:
- 把Base64URL编码后的Header和Payload用点号拼接。
- 使用约定的密钥(比如一串自定义的secret字符串)作为钥匙。
- 使用Header中声明的算法对拼接结果做HMAC-SHA256运算。
- 把运算后的二进制结果再做一次Base64URL编码。
服务端每次收到Token后,用同样的密钥和同样的算法自己重新算一遍签名,然后和Token自带的签名比对。只要有一点不同,就说明Token被篡改过或者密钥不对,直接拒绝。
这个机制保证了Token的完整性,但不保证数据的保密性。因此敏感信息要慎重放入Claims,密钥也绝不能硬编码在代码里。
2.3 前端存储Token的三种方式
Token生成之后要交给前端保存,前端在每次请求时把它放进请求头的Authorization字段,格式是Bearer <token>。前端存储方式主要有三种,各有取舍:
- localStorage:最简单,但存在XSS攻击下被读取的风险。适合内部系统、或者前端团队对XSS防护有把握的场景。
- sessionStorage:关闭浏览器Tab就失效,适合比较短的会话场景,但用户刷新后又要重新登录。
- Cookie+HttpOnly:相对安全,因为脚本拿不到Cookie,可以有效避免XSS读取,但你需要处理CSRF的问题,而且这种方式又回到了“类似Session”的模式,跨域调用时还要精细配置Cookie策略。
我个人的习惯是:中大型系统优先Cookie+HttpOnly,小规模内部工具用localStorage也能接受。但不管哪种方式,Token都应该设置合理的过期时间,而不是一劳永逸。
3. 实战接入:从生成Token到中间件验签
接下来是真正的动手环节。目标很明确:做一个登录接口,用户用用户名密码换Token,然后受保护的接口必须带着Token才能访问。整个流程就两大块:签发Token的代码,和验证Token的中间件配置。下面所有代码我都是在.Net Core 2.1环境下验证跑通的。
3.1 安装依赖包
首先在项目里引入官方JWT认证扩展包。在NuGet控制台里执行:
Install-Package Microsoft.AspNetCore.Authentication.JwtBearer -Version 2.1.2选2.1.2这个版本是为了匹配.Net Core 2.1的运行时,避免高版本包依赖更高版本的框架。版本不对的话会出现各种奇怪的编译错误,所以这里别顺手装最新版。
3.2 封装一个Token生成器
创建一个JwtHelper类,集中管理Token的生成逻辑。这么做的好处是,以后改密钥、改过期时间、加Claims,都只需要动这一个文件。
using System; using System.Collections.Generic; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using Microsoft.IdentityModel.Tokens; public class JwtHelper { private readonly string _secret; private readonly string _issuer; private readonly string _audience; public JwtHelper(string secret, string issuer, string audience) { _secret = secret; _issuer = issuer; _audience = audience; } public string CreateToken(int userId, string userName, string role, int expireMinutes = 120) { var claims = new List<Claim> { new Claim(ClaimTypes.NameIdentifier, userId.ToString()), new Claim(ClaimTypes.Name, userName), new Claim(ClaimTypes.Role, role), new Claim("jti", Guid.NewGuid().ToString()) }; var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret)); var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( issuer: _issuer, audience: _audience, claims: claims, notBefore: DateTime.Now, expires: DateTime.Now.AddMinutes(expireMinutes), signingCredentials: credentials ); return new JwtSecurityTokenHandler().WriteToken(token); } }这里我用了ClaimTypes.NameIdentifier、ClaimTypes.Name、ClaimTypes.Role这些标准Claim类型,而不是直接用sub、name、role的缩写。原因是.Net Core的ClaimsPrincipal在解析Token时,会把JWT里的短名称映射到这些标准长名称上,你写ClaimTypes.NameIdentifier,后面读取时直接用User.FindFirst(ClaimTypes.NameIdentifier)就能拿到值,不容易踩坑。
需要注意的是,new JwtSecurityTokenHandler().WriteToken(token)在部分场景下会触发一个警告,提示用JwtSecurityTokenHandler的线程安全单例模式更好,但2.1环境下直接new一次问题不大,因为调用频率不算高。如果你追求严谨,也可以把Handler提出来做成静态字段。
3.3 在Startup里配置鉴权中间件
接下来是核心配置。打开Startup.cs,在ConfigureServices方法里添加认证服务:
public void ConfigureServices(IServiceCollection services) { // 其他服务配置 services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { var secret = Configuration["Jwt:Secret"]; var issuer = Configuration["Jwt:Issuer"]; var audience = Configuration["Jwt:Audience"]; options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = issuer, ValidateAudience = true, ValidAudience = audience, ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret)), ValidateLifetime = true, ClockSkew = TimeSpan.FromSeconds(30) }; }); services.AddMvc(); }然后在Configure方法里,必须在UseMvc之前加上UseAuthentication:
public void Configure(IApplicationBuilder app, IHostingEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseAuthentication(); app.UseMvc(); }UseAuthentication这行是很多新手漏掉的重灾区。少了它,鉴权中间件根本不会执行,接口上的[Authorize]特性形同虚设,而且也不会报错,你会看到所有请求都能直接访问。这种“静默失效”的问题排查起来非常费劲。
3.4 密钥为什么不能硬编码
上面的配置里,密钥我是从Configuration["Jwt:Secret"]读取的,这就意味着密钥应该放在appsettings.json里,或者更进一步放在环境变量、配置中心里。
最简单的做法是在appsettings.json中添加:
{ "Jwt": { "Secret": "请替换成至少32位的随机字符串", "Issuer": "YourProjectName", "Audience": "YourProjectClient" } }密钥的长度和随机性直接决定了HS256签名的强度。建议至少32个字符,用大小写字母+数字+特殊符号混排。我之前见过有人把密钥设成123456这种,等于把门锁拆了。生产环境里还可以考虑用环境变量覆盖appsettings,这样代码仓库里不保留真实密钥,安全性会好很多。
还有一点:JWT的密钥和加密密码不是一个概念,它不需要可读性,只需要随机、长度足够、不会被猜到。如果有条件,建议定期轮换,轮换时需要保证新旧密钥在过渡期内都能通过验签,否则正在使用的用户会被突然踢下线。
3.5 登录接口与受保护接口的完整范例
现在写一个最典型的Controller例子。假设有个AccountController负责登录,有个UserController负责返回用户资料。
[Route("api/[controller]")] [ApiController] public class AccountController : ControllerBase { private readonly JwtHelper _jwtHelper; public AccountController(JwtHelper jwtHelper) { _jwtHelper = jwtHelper; } [HttpPost("login")] [AllowAnonymous] public IActionResult Login([FromBody] LoginRequest request) { // 这里省略真实的用户名密码校验逻辑 // 实际项目中应该查库、比对密码哈希,不能用明文 if (request.UserName == "admin" && request.Password == "123456") { var token = _jwtHelper.CreateToken(1001, "admin", "Admin"); return Ok(new { token, expires = DateTime.Now.AddMinutes(120) }); } return Unauthorized(new { message = "用户名或密码错误" }); } }注意[AllowAnonymous]特性,它表示这个接口不需要Token就能访问。登录接口肯定要加这个,否则就变成鸡生蛋的问题了。
然后是受保护接口:
[Route("api/[controller]")] [ApiController] public class UserController : ControllerBase { [HttpGet("profile")] [Authorize] public IActionResult GetProfile() { var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; var userName = User.FindFirst(ClaimTypes.Name)?.Value; var role = User.FindFirst(ClaimTypes.Role)?.Value; return Ok(new { userId, userName, role }); } }[Authorize]特性标记的接口,请求时必须带有合法的Token,否则中间件直接返回401。这就是整套认证闭环。
关于登录接口还有一点要提醒:永远不要用明文密码比对,生产环境必须用哈希算法存储和校验密码。JWT只负责“你登录过之后怎么识别你”,至于“密码是否安全”是另一码事,两件事都不要松懈。
4. 实操中高频问题与排查思路
代码跑通只是第一步,真正让人头疼的是那些光看文档根本发现不了的细节问题。我在做这个项目时,光是调通整个链路就踩了四五个坑。下面把这些高频问题整理出来,每一条都附上原因分析和排查方向。
4.1 一直401:最常见的三层原因
如果你配置看起来都对,但请求时始终返回401,优先检查三个点:
- 是否在
Configure里加了app.UseAuthentication()。漏掉这句,[Authorize]不会被触发,但也可能表现为“永远通过”而不是401,取决于你的配置。 TokenValidationParameters的ValidIssuer、ValidAudience和IssuerSigningKey是否与生成Token时一致。有一个对不上,验签直接失败。- 前端请求头是否真的带了
Authorization: Bearer <token>。有时候前端把Token放在自定义Header里,比如X-Token,这是不行的。JWT中间件默认只从Authorization头读取。
我用过的最快排查方式:先用Postman手动试一次,把Token手动复制到请求头里,如果Postman能通而页面不通,那问题一定在前端请求封装那一层。
4.2 Claims取不到值:名称映射的坑
这是.Net Core里非常容易踩的坑。你在生成Token时明明写了一个ClaimTypes.Name,但接口里用User.FindFirst(ClaimTypes.Name)却查不到。
原因在于JwtSecurityTokenHandler在解析Token时,默认开启了InboundClaimTypeMap映射机制。微软把JWT里那些标准的短名称(比如sub)映射成了.NET自己的长名称,有些映射还会造成重复。比如你生成Token时用了ClaimTypes.Name,它的底层URI是http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name,而这个URI本身就有映射,解析后ClaimType可能变成别的长字符串。
解决方法有三个:
- 生成Token和读取Claims都使用
JwtRegisteredClaimNames.Sub这种短名称,并在读取时用短名称去取。 - 在
AddJwtBearer的配置里,把options.MapInboundClaims = false,关闭自动映射,这样解析出来的ClaimType就会保持原样。 - 统一用自定义的Claim名称,比如
new Claim("uid", userId.ToString()),读取时User.FindFirst("uid")。这种方式最直观,也不容易受映射机制影响。
我当时为了实现登录用户名和角色能直接读取,选择了自定义名称的方案,绕开了映射问题。你如果代码已经跑起来了,建议优先用自定义名称,维护成本最低。
4.3 ClockSkew:为什么Token刚过期还能用
JWT中间件有个默认配置叫ClockSkew,默认值是5分钟。意思是就算Token的过期时间已经过了,中间件还会额外容忍5分钟的偏差,防止服务器和客户端时钟不一致导致正常用户突然被踢下线。
也就是说,你设置了Token有效期2小时,用户实际可以用2小时05分钟。这在大多数业务场景下是没问题的,但如果你的业务对过期时间要求非常严格(比如金融类操作、临时授权链接),可以把ClockSkew调小:
options.TokenValidationParameters.ClockSkew = TimeSpan.FromSeconds(30);如果完全不希望有宽限,可以设成TimeSpan.Zero。这个属性很容易被忽略,但如果你发现Token过期了还能访问接口,先看看是不是它。
4.4 过期Token的处理策略
Token过期是必然的。前端在请求时如果收到401,通常有两种处理策略:
一是前端拦截401响应,自动跳转到登录页,让用户重新登录。这是最简单粗暴的方案,适合普通后台管理系统。
二是引入刷新Token机制:登录时给两个Token,一个短期AccessToken(比如2小时),一个长期RefreshToken(比如7天)。AccessToken过期后,前端用RefreshToken去换新的AccessToken,用户无感知地续期。这个方案体验好,但复杂度也明显增加——RefreshToken需要在服务端存储,因为它可以被撤销、必须保证一次有效。
如果你是第一次在这个项目里做JWT,我建议先做第一种,把基础链路跑通。刷新Token是后续优化的事,不要一开始就背上复杂度。
4.5 跨域请求下的Bearer传递问题
前后端分离的项目基本都离不开跨域配置。在.Net Core 2.1里,你需要手动启用CORS:
public void ConfigureServices(IServiceCollection services) { services.AddCors(options => { options.AddPolicy("AllowAll", policy => policy.AllowAnyOrigin() .AllowAnyMethod() .AllowAnyHeader()); }); } public void Configure(IApplicationBuilder app, IHostingEnvironment env) { app.UseCors("AllowAll"); app.UseAuthentication(); app.UseMvc(); }有几个细节需要注意:
UseCors和UseAuthentication的顺序:实际上CORS的处理和认证先后关系不大,但必须确保CORS中间件配在UseMvc之前。- 如果你的前端启用了
credentials: "include"去携带Cookie,那么AllowAnyOrigin()就不够用了,必须换成WithOrigins("具体的前端地址")并加上.AllowCredentials()。这是浏览器安全策略的限制。 - 自定义请求头的名称如果在CORS策略里没有被允许,会触发预检请求失败。如果你改用了自定义Token头,这里要显式Allow这个Header。
4.6 “用户名或密码错误”到底该返回什么状态码
一个容易被忽略的小细节:登录失败时,很多人习惯返回400或200加一个错误码字段,但我建议返回401,语义上最准确。前端可以根据状态码决定是弹出“用户名或密码错误”还是直接跳转登录页,逻辑上更清晰。
当然,也要注意生产环境的安全习惯。不管用户不存在还是密码错误,返回的提示文案最好统一为“用户名或密码错误”,避免通过不同的提示信息猜测出有效用户名。
5. 后续扩展与安全加固
基础JWT认证做完,项目能跑起来了,但距离“可靠”还有一段距离。下面这几个点是生产环境里大概率会遇到的问题,提前处理能省很多事。
5.1 给Token加角色限制
[Authorize]不止能判断“是否登录”,还能判断“是否有权限”。比如你希望只有Admin角色才能删除数据,可以这样写:
[HttpDelete("{id}")] [Authorize(Roles = "Admin")] public IActionResult Delete(int id) { // 只有Admin角色才能走到这里 }多个角色用逗号分隔:[Authorize(Roles = "Admin,Editor")]。角色Claim在生成Token时就必须带上,中间件会把它解析到User.IsInRole("Admin")的判断中。
需要注意:如果你关闭了Claim映射(MapInboundClaims=false),角色判断可能受影响,因为IsInRole默认查的是ClaimTypes.Role的映射值。这种情况下建议保留默认映射,或者使用自定义策略。在没踩过坑之前,建议先按默认配置来。
5.2 让Token“短命”是JWT安全的第一原则
JWT无状态的特点意味着服务端没法主动让Token失效。所以在安全策略上,Token的过期时间一定要短。我建议:
- 普通内部后台系统:2小时比较合适。
- 面向用户的App接口:可以适当延长到12-24小时。
- 涉及金额、权限变更的操作:Token生命周期越短越好,配合刷新机制使用。
如果你担心用户频繁登录体验太差,可以通过刷新Token机制来平衡安全性和体验。
还有一个小技巧:如果某个用户要改密码,最好的做法是让客户端立刻清理本地Token,并且在服务端维护一个“Token版本号”或“密码修改时间戳”,验签时检查Token签发时间是否早于密码修改时间,早于就拒绝。
5.3 不要让一个密钥打天下
很多项目图省事,所有环境共用一套JWT密钥。这带来的问题是:只要开发环境的密钥泄露,生产环境也一起沦陷。正确做法是每个环境(开发、测试、生产)使用不同密钥,由环境变量注入。
再进阶一点,当你想轮换密钥时,需要小心处理。JwtBearer中间件只支持一个TokenValidationParameters,但你可以通过自定义IssuerSigningKeyResolver来实现多密钥兼容。在过渡期内,旧密钥签发的Token依然有效,新密钥签发的Token也能通过,等旧Token全部自然过期后,再下线旧密钥。这个过程在大型系统里很常见。
options.TokenValidationParameters.IssuerSigningKeyResolver = (token, securityToken, kid, parameters) => { var keys = new List<SecurityKey> { new SymmetricSecurityKey(Encoding.UTF8.GetBytes("新密钥")), new SymmetricSecurityKey(Encoding.UTF8.GetBytes("旧密钥")) }; return keys; };上面的代码是把新旧密钥都配置进去,两个密钥都能通过验签,两边同时有效。这在做密钥轮换时是一个很好的过渡方案。
5.4 用JwtBearerEvents记录鉴权失败原因
当接口返回401的时候,服务端日志里往往没有任何记录,排查起来很被动。这里可以配置JwtBearerEvents,在认证失败时把原因写进日志:
options.Events = new JwtBearerEvents { OnAuthenticationFailed = context => { // context.Exception 就是认证失败的具体异常 Console.WriteLine($"认证失败:{context.Exception?.Message}"); return Task.CompletedTask; }, OnChallenge = context => { // 在这里可以自定义401响应的内容 return Task.CompletedTask; } };通过OnAuthenticationFailed拿到Exception,你会很直观地看到是签名不匹配、过期了,还是Issuer/Audience校验失败。这个能力在联调阶段尤其有用。
5.5 统一返回格式而不是直接裸返回401
默认情况下,[Authorize]拦截后返回的401是空体,前端拿到一个没有任何信息的响应,体验很差。通过OnChallenge事件,你可以统一返回JSON格式:
OnChallenge = async context => { context.HandleResponse(); context.Response.StatusCode = 401; context.Response.ContentType = "application/json"; await context.Response.WriteAsync(JsonConvert.SerializeObject(new { code = 401, message = "未授权或Token已过期" })); }这样做的好处是前后端约定了一套统一的错误响应结构,前端只需要处理code=401这一类情况,不用针对不同平台解析不同错误格式。
写在最后
JWT这套东西在.Net Core 2.1里实现起来并不复杂,核心就是“一个签发方法 + 一段中间件配置”,难的是那些藏在细节里的坑:中间件顺序、Claims映射、时钟偏差、密钥管理、跨域传递。我在实际项目中踩过不少回,最深刻的体会就是——先把“生成→传递→验签→取Claims”这条链路完整跑通,再去做权限细化、刷新Token这些扩展,否则一旦出问题,你根本分不清是哪个环节挂了。
老项目虽然技术栈旧,但业务价值还在。如果你也是在维护.Net Core 2.1的老系统,希望这篇文章能帮你把这套认证机制彻底搞清楚。下次遇到401不要慌,按照上面列的几个排查点逐项确认,基本都能找到答案。