做了这么多年后端,前端换了一套又一套,认证那块翻来覆去还是绕不开JWT。最近在给某个跨平台系统做认证模块重构,又把ASP.NET Core下的JWT和刷新令牌机制完整撸了一遍。先说结论:很多项目只是简单签发了Access Token,过期了就让用户重新登录,这种方案短期没毛病,但业务稍微正规一点,你很快就会发现——为什么用户明明点了退出,手里的令牌还能用?为什么15分钟就过期,用户骂体验差;延长到2小时,又怕泄露后被人钻空子。
这篇文章不聊高层理论,直接把ASP.NET Core里JWT认证和刷新令牌机制的完整链路拆开讲清楚:为什么需要刷新令牌、双令牌怎么签发、刷新端点怎么写、令牌轮转和黑名单怎么落地,以及我在实际踩坑中总结的排查思路。适合正在做用户体系、API安全加固或者刚接手认证模块的后端开发者,也适合想搞清楚双令牌机制“为什么存在”的朋友。
1. 为什么需要刷新令牌:传统JWT方案的死穴
1.1 不可撤销的访问令牌
先花几十秒把JWT的结构过一下,因为后面所有问题都源于它的结构。JWT分三段:Header、Payload、Signature,用点号连接。Header告诉服务器这是一个什么类型的令牌、用什么算法签名;Payload存放Claims,比如用户Id、角色、Jti这些自定义信息;Signature是服务器用密钥对前两段加签名,保证内容没被篡改。
关键点在于:JWT是“自包含”的。服务器校验一个JWT时,只需要验证签名和过期时间,不需要查数据库,这就是它能做分布式认证的基础,很多微服务架构就是靠这个特性免去了集中式Session。
但自包含同时也带来了一个致命弱点:令牌一旦签发,在过期之前是没法主动作废的。你说“我改密码了,把旧令牌全废掉”,服务器做不到,因为校验时根本不查库。你说“这个用户被管理员踢下线了”,同样做不到,旧令牌该有效还是有效。传统的用户会话,退出登录相当于服务端把Session销毁,但JWT方案里“退出登录”是个非常尴尬的动作,前端把令牌一删就完事,服务端其实什么都没发生。
1.2 过期时间的两难局面
既然令牌不能主动撤销,那只能靠“有效期短”来降低风险。Access Token有效期设得非常短,就算泄露了,攻击者能用它干坏事的时间窗口也很有限。但这里面有一个矛盾:有效期设短了,用户用着用着令牌就过期了,体验很差,总让用户重新登录更不现实;有效期设长了,比如一周甚至一个月,泄露后的危险窗口又被拉得太大。
我见过有的项目直接把Access Token的有效期设成7天,站的角度是为了少打扰用户。但说实话,如果这令牌一旦从某个前端日志里暴露出来,那这一个星期里后端根本没有任何拦截手段,你只能干瞪眼。这就是“签发时间短→体验差”和“签发时间长→风险高”之间天然的跷跷板困境。
1.3 刷新令牌机制的核心思想
刷新令牌机制(Refresh Token机制)解决的就是这个矛盾。思路非常简单:发两个令牌。
访问令牌(Access Token)用来访问业务接口,生命周期很短,通常15分钟到30分钟,就算泄露,能搞破坏的时间窗口被压缩到很小。刷新令牌(Refresh Token)用来换取新的Access Token,生命周期可以很长,7天、14天、甚至30天,但它的用途非常单一,只能访问那个刷新的端点,不能访问普通业务接口。
用户拿到双令牌之后,Access Token过期了,前端不用弹登录框,而是拿着Refresh Token去刷新接口,换一个新的Access Token,继续使用。用户无感知,体验好了;Access Token期限短,风险也控制在可接受范围内。而且,只要你把刷新令牌的校验做成服务端可控,你就能在刷新流程里实现“主动踢人”“改密码后强制下线”“旧令牌废掉”这些操作。
一句话:刷新令牌机制就是把JWT的不可撤销性给补上了一块拼图,在你没法舍弃JWT的时候,可以绕开它的最大短板。
2. ASP.NET Core中配置JWT认证管线
2.1 环境准备与NuGet包
代码以ASP.NET Core 8为例,其实6/7/8的写法基本一致。如果你的项目还没接入认证,需要先装包。这里我用的是传统的JWT Bearer方案:
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer这个包负责的是“如何校验JWT”,就是把你自定义的令牌验证逻辑接入ASP.NET Core的认证体系。装完包之后,在Program.cs里就能拿到AddJwtBearer这个扩展方法了。
另外说一下,新版本的ASP.NET Core引入了Microsoft.Identity.Web这类第三方认证库,主要用于微软生态的OAuth接入。做自研的用户体系、双令牌流程,JwtBearer就够用,不需要引入额外的东西,少一个依赖少一份坑。
2.2 appsettings.json参数设计
在动手写代码前,先把JWT相关参数沉淀到配置文件,别直接写在代码里。最简单的配置结构可以长这样:
{ "JwtSettings": { "Issuer": "MallSystem", "Audience": "MallSystem.Client", "SecretKey": "your-super-long-secret-key-should-be-at-least-32-characters", "AccessTokenExpireMinutes": 15, "RefreshTokenExpireDays": 7 } }Issuer就是发行者标识,一般写项目名;Audience是受众标识,写客户端名称。这两项在验证阶段都会作为合法校验条件,别随便填。
SecretKey是签名密钥,这里往配置文件里写的是开发用的占位值,生产环境一定要换成环境变量、Kestrel配置或者密钥管理服务里的值。密钥长度至少32个字符,因为HS256算法要求密钥不少于256位,否则有些运行环境会直接抛错,这个我在实际操作中遇到过。
配置文件建议再加一个选项:AccessTokenExpireMinutes和RefreshTokenExpireDays分开管理。不要图省事只用同一个过期时间,那就失去分令牌的意义了。访问令牌15分钟是相对常见的选择,如果你的业务里用户操作频率很低,可以放到30分钟,但一般不建议超过1小时。刷新令牌的期限按业务性质来:内部管理后台可以短,对外C端应用可以长一些。
2.3 注册JWT Bearer服务
Program.cs里注册服务是标准的模板化写法,但有几个参数值得留意。为了演示方便,我先把配置绑定成一个实例:
builder.Services.Configure<JwtSettings>(builder.Configuration.GetSection("JwtSettings")); var jwtSettings = builder.Configuration.GetSection("JwtSettings").Get<JwtSettings>();然后注册认证服务:
builder.Services.AddAuthentication(options => { options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = jwtSettings.Issuer, ValidAudience = jwtSettings.Audience, IssuerSigningKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(jwtSettings.SecretKey)), ClockSkew = TimeSpan.FromSeconds(30) }; });你以为搞定了?其实还差两步,一个是授权中间件的注册,另一个是Controller或Endpoint里的授权标记。这四个参数里面,真正需要花时间说清楚的:
ValidateIssuerSigningKey,很多人只是抄了代码,不知道它在干什么。签名的验证实际上用的是IssuerSigningKey提供的对称密钥,拿它去解Signature段,如果解出来的内容和Header、Payload的哈希对不上,说明令牌被改过,直接拒绝。这也是防止“攻击者自己伪造一个令牌”的关键——因为他没有这个密钥。
ClockSkew,这个参数默认值是5分钟。意味着一个已经过期不超过5分钟的令牌,依然被当作有效。为什么要有这个容差?因为服务器之间的时间可能有轻微误差,JWT校验时以签发方的签发时间、过期时间为准,如果接收方时钟快了5秒,令牌刚签出来就被判过期,体验非常糟,所以留一个容差。但是在自身系统里,如果你的服务器只有一台,时间同步没问题,建议把ClockSkew调小,比如30秒。否则一个15分钟过期的令牌,实际能用15分30秒,问题不大;但如果你设的是5分钟默认值,实际使用时长就是20分钟,这就失去了缩短过期时间的一半意义。
注册完认证之后,别忘了加授权中间件,顺序必须是先认证后授权:
app.UseAuthentication(); app.UseAuthorization();顺序写反的话,应用会在没有身份信息的情况下直接做权限判断,得到一个非常难排查的“明明登录了但总是401/403”的问题。这类问题在StackOverflow上搜,一搜一大堆。
3. 签发双重令牌:Access Token与Refresh Token
3.1 自定义Token服务类
为了方便管理,我习惯把令牌签发逻辑抽成一个服务类,项目结构大致是Services/JwtTokenService.cs。这个类负责创建Access Token和Refresh Token,同时提供哈希计算的方法,因为后续刷新令牌入库时不会存明文。
先看生成Access Token的核心逻辑:
public string GenerateAccessToken(User user, string jti) { var claims = new List<Claim> { new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), new Claim(JwtRegisteredClaimNames.Jti, jti), new Claim(ClaimTypes.Name, user.Username), new Claim(ClaimTypes.Role, user.Role.ToString()) }; var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.SecretKey)); var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( issuer: _settings.Issuer, audience: _settings.Audience, claims: claims, expires: DateTime.UtcNow.AddMinutes(_settings.AccessTokenExpireMinutes), notBefore: DateTime.UtcNow, signingCredentials: credentials); return new JwtSecurityTokenHandler().WriteToken(token); }Jti(JWT ID)这个Claim比较容易被新手忽略,它的作用非常关键。每一个Access Token都应该有一个唯一的ID,后面做黑名单、做重放检测全靠它。在生成时可以用Guid.NewGuid().ToString(),也可以放到参数里传进去,方便在事件处理器中读取。
在Claims里加角色信息这点,如果你项目里已经有标准的Claims身份体系,需要注意角色Claim的类型。ASP.NET Core的授权中间件默认读ClaimTypes.Role这个类型,而JwtRegisteredClaimNames里没有对应的东西,所以自定义Claim就按ClaimTypes.Role来。这样后续直接[Authorize(Roles = "Admin")]就能跑通。如果Claim类型写错了,角色授权会莫名其妙地全部失败。
生成Refresh Token的代码更简单:
public string GenerateRefreshToken() { var bytes = new byte[64]; RandomNumberGenerator.Fill(bytes); return Convert.ToBase64String(bytes); }Refresh Token不是一个JWT,不需要签名,不需要Claims,它就是一个足够长的随机字符串。因为它是“不透明令牌”,服务器只认它登记过的那条记录。64字节随机数的熵很高,基本不可能靠暴力猜出来,这也是它适合长期存储的原因之一。
3.2 登录接口:双令牌如何发放
登录接口的逻辑流程通常是:接收用户名和密码,验证账号密码,查用户信息,然后同时签发Access Token和Refresh Token,把Refresh Token保存起来,最后把双令牌返回给前端。
[HttpPost("login")] public async Task<IActionResult> Login(LoginRequest request) { var user = await _userService.ValidateAsync(request.Username, request.Password); if (user is null) { return Unauthorized(new { message = "用户名或密码错误" }); } var jti = Guid.NewGuid().ToString(); var accessToken = _tokenService.GenerateAccessToken(user, jti); var refreshToken = _tokenService.GenerateRefreshToken(); var tokenHash = _tokenService.ComputeSha256Hash(refreshToken); await _refreshTokenStore.CreateAsync(new RefreshTokenRecord { UserId = user.Id, TokenHash = tokenHash, ExpiresAt = DateTime.UtcNow.AddDays(_settings.RefreshTokenExpireDays), CreatedAt = DateTime.UtcNow }); return Ok(new AuthResponse { AccessToken = accessToken, RefreshToken = refreshToken, ExpiresIn = jwtSettings.AccessTokenExpireMinutes * 60 }); }这里有个细节,返回体里建议带一个ExpiresIn字段,单位为秒。前端拿到这个值后可以提前几分钟自己驱动一个定时器,在Access Token快要过期时主动调用刷新接口,避免真正过期之后请求撞墙。不加这个字段的话,前端只能猜测过期时间或者每次等401被动了再去刷新,体验会差一截。
很多项目里用户实体长在某个租户下,Access Token的Claims里也会加一些租户Id、权限列表之类的信息。记住一个原则:JWT是放在请求头里的,每个请求都会携带它,Claims里不要塞太重的东西。角色可以放,权限列表就建议别塞了,权限一变,旧Token里的列表就是错的,还没法撤销。权限校验放到业务层去查数据库或缓存,比放在Token里靠谱得多。
3.3 前端侧建议:Access Token放内存还是Storage
按照刷新的流程,前端的标准做法是:Access Token放在内存变量里,每次请求时带上;Refresh Token放在HttpOnly的Cookie里。为了理解这个方案,我把几个常见的存储位置对比一下:
| 存储位置 | 优点 | 缺点 | 建议 |
|---|---|---|---|
| localStorage | 刷新浏览器不丢 | XSS脚本可以直接读取 | 不建议放Access Token |
| sessionStorage | 关闭标签页即没 | XSS脚本仍可读取 | 不建议放Access Token |
| 内存变量 | XSS无法读取 | 刷新页面后丢失 | 放Access Token的可行选择 |
| HttpOnly Cookie | JS读不到,XSS拿不到 | 需要处理CSRF | 放Refresh Token比较合适 |
我见过不少项目无论Access Token还是Refresh Token都塞进localStorage,前端写起来是方便,但一旦站点上被注入了一条XSS脚本,攻击者直接把localStorage里的令牌掏走,双令牌机制的安全价值就全被架空了。
Access Token放内存的特点是刷新页面后会丢,需要前端在应用初始化时携带Refresh Token去刷新接口重新拿Access Token。带来的额外好处是页面刷新后自动续期,用户体验并不差。Refresh Token放HttpOnly Cookie之后,前端无法直接读取它,刷新接口会自动带上Cookie,安全级别高了一截。当然用了Cookie就得处理CSRF,这个后面在问题排查环节再展开说。
4. 刷新令牌的存储与轮转实现
4.1 数据表设计:RefreshToken实体
如果你的系统用户量不大、并发也不高,用关系数据库管理刷新令牌是完全可以的,而且逻辑最直观。EF Core下面实体可以这么设计:
public class RefreshToken { public Guid Id { get; set; } public Guid UserId { get; set; } public string TokenHash { get; set; } public DateTime CreatedAt { get; set; } public DateTime ExpiresAt { get; set; } public DateTime? RevokedAt { get; set; } public string ReplacedByTokenHash { get; set; } public string ReasonRevoked { get; set; } }TokenHash字段不是存Refresh Token明文的。道理跟密码不能存明文一样,如果数据库泄露,Token明文就等于把用户登录态全交出去。把Token哈希后入库,数据库泄露了攻击者也没法拿哈希反向用,因为哈希是单向的。
ReplacedByTokenHash这个字段很关键,它记录了“当前Token被谁顶替了”。配合轮转机制,当用户拿着旧Token来刷新时,服务器会对比它登记过的“接力令牌”哈希,用于重放检测。ReasonRevoked存一个字符串,比如“更换密码”“用户退出”“被新令牌替换”,后端排查时一眼能看出这个被吊销的令牌是为什么失效的。
4.2 核心端点:刷新令牌拿去换新令牌
刷新端点是整个刷新机制的大脑,所有主动下线的能力都靠这几十行逻辑做出来。先看核心流程:
[HttpPost("refresh")] public async Task<IActionResult> Refresh(TokenRefreshRequest request) { var tokenHash = _tokenService.ComputeSha256Hash(request.RefreshToken); var record = await _context.RefreshTokens .FirstOrDefaultAsync(t => t.TokenHash == tokenHash); if (record is null) { return Unauthorized(new { message = "无效的刷新令牌" }); } if (record.RevokedAt != null) { return Unauthorized(new { message = "刷新令牌已吊销" }); } if (record.ExpiresAt < DateTime.UtcNow) { return Unauthorized(new { message = "刷新令牌已过期" }); } var user = await _context.Users.FindAsync(record.UserId); if (user is null || user.Status != UserStatus.Active) { return Unauthorized(new { message = "用户状态异常" }); } var jti = Guid.NewGuid().ToString(); var newAccessToken = _tokenService.GenerateAccessToken(user, jti); var newRefreshToken = _tokenService.GenerateRefreshToken(); var newTokenHash = _tokenService.ComputeSha256Hash(newRefreshToken); record.RevokedAt = DateTime.UtcNow; record.ReplacedByTokenHash = newTokenHash; record.ReasonRevoked = "ReplacedByNewToken"; await _context.RefreshTokens.AddAsync(new RefreshToken { UserId = user.Id, TokenHash = newTokenHash, CreatedAt = DateTime.UtcNow, ExpiresAt = DateTime.UtcNow.AddDays(_settings.RefreshTokenExpireDays) }); await _context.SaveChangesAsync(); return Ok(new AuthResponse { AccessToken = newAccessToken, RefreshToken = newRefreshToken, ExpiresIn = _settings.AccessTokenExpireMinutes * 60 }); }这个逻辑里检查顺序有讲究。我先检查“是否存在”,再检查“是否吊销”,再检查“是否过期”。理论上过期和吊销可以合在一起返回“刷新令牌无效”,但分开写的好处是排查问题时能通过返回信息快速定位是哪一层出了问题。
轮转滑块(Rolling Refresh)体现在哪里?旧Refresh Token在刷新后立刻被吊销,同时生成一个全新的Refresh Token。这样每一次刷新都是“一换一”,旧Token瞬间失效。用户连续使用一周,Refresh Token的TokenHash换了一路,但登录状态始终在线,体验上是“永久登录”,安全上却始终保持短期令牌的窗口。
4.3 令牌轮转与重放检测
轮转机制真正发威的地方是重放检测。设想一下,攻击者盗走了用户的Refresh Token,他拿着去刷新接口换了一组新Token正常使用。这时候用户本人的App也来刷新了,用户手里的旧Token已经是被吊销的状态。正常情况下服务器会返回401,用户重新登录一次就完事。
但如果你把检查做成这样:如果发现一个已经吊销的Token出现在刷新请求里,并且它是在“ReplacedByNewToken”状态下被吊销的,那说明有人拿着已经被消费过的旧Token来尝试刷新。这种请求只有两种可能:要么是你自己的客户端因为某种并发请求重复提交了旧Token,要么就是有攻击者在重放盗来的Token。
应对方式我见到的比较稳妥的做法是:检测到这类重放请求时,不单独拒绝这一个请求,而是把这个家庭树上的所有Refresh Token全部吊销。也就是说,攻击者刚换出来的那个新Refresh Token也跟着作废,对方下一次刷新必然失败。代价是当前用户的所有客户端都得重新登录,但安全性换取的是值得的,至少攻击者没法再继续悄无声息地用了。
实现上就是在“RevokedAt不为空且ReplacedByTokenHash不为空”的情况下,再往前走一步:用这个TokenHash作为线索,找到持有它的用户Id,然后把该用户的所有有效刷新Token记录都标记吊销。
4.4 Redis存储与并发刷新问题
如果项目是多实例部署、或者刷新频率较高,数据库方式会遇到两个问题:一是并发刷新同一旧Token时,两个请求同时读到记录未吊销,随后都执行“吊销并新建”的操作,会生成两条新Token,只有最后落库那一条是有效的,另一条在并发窗口内其实已经被写入,但前端拿到哪个不一定;二是高并发场景下单条记录的查询更新带来不必要的锁竞争。
Redis方案能更好规避这个问题。用IDistributedCache实现一套简单的“Redis Token存储”,核心思想是把刷新令牌的哈希作为Key,用户Id作为Value,Key的过期时间就是Refresh Token的有效期。
public class RedisRefreshTokenStore { private readonly IDistributedCache _cache; public async Task SaveAsync(string tokenHash, Guid userId, TimeSpan ttl) { await _cache.SetStringAsync($"refresh_token:{tokenHash}", userId.ToString(), new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = ttl }); } public async Task<Guid?> GetUserIdAsync(string tokenHash) { var value = await _cache.GetStringAsync($"refresh_token:{tokenHash}"); return value is null ? null : Guid.Parse(value); } public async Task RevokeAsync(string tokenHash) { await _cache.RemoveAsync($"refresh_token:{tokenHash}"); } }因为Key就是Token的哈希,天然具备唯一性,并发生成不同Token不会互相覆盖。但这套方案的缺点是:你没法直接根据UserId去吊销该用户的所有刷新令牌。要支持“管理员踢人下线”,得额外再维护一个索引,比如每个用户维护一个当前有效Token哈希的Set,吊销时把Set里的Key全部删掉。好在这里不做无意义拓展,假设是单机或小型集群,数据库方案对大多数场景已经够了。
5. 安全加固:注销、黑名单与存储位置
5.1 退出登录:Refresh Token吊销
现在回答开头说的问题:用户点了退出登录,怎么让令牌真的失效?
退出接口要做两件事:吊销前端的Refresh Token,并让当前这枚Access Token在剩余的生命周期内也失效。前者直接操作数据库,把TokenHash对应的记录标为吊销;后者需要一点特殊手段,因为Access Token本身不可撤销,你可以在校验阶段加一层黑名单拦截。
Access Token黑名单的基本思路是利用Jti。签发Access Token的时候给每个Token附上Jti,在JwtBearer的OnTokenValidated事件中查一下Redis或数据库,看看这个Jti是不是已经被拉黑了:
options.Events = new JwtBearerEvents { OnTokenValidated = async context => { var jti = context.Principal?.FindFirst(JwtRegisteredClaimNames.Jti)?.Value; if (string.IsNullOrEmpty(jti)) { context.Fail("令牌缺少Jti"); return; } if (await _blacklistStore.IsRevokedAsync(jti)) { context.Fail("令牌已被吊销"); } } };这个jti的缓存过期时间不需要太长,Access Token本身就15分钟,黑名单存20分钟就足够覆盖了。每次退出登录时,把当前Access Token的Jti丢进缓存,到点自动消失,避免了无止境的无效记录堆积。
5.2 Refresh Token的滑动过期策略
关于刷新令牌的有效期设计,这里有两种策略值得权衡:固定过期还是滑动过期。固定过期是创建一个Refresh Token时就定好有效天数(比如7天),到期前如果用户一直在活跃,App会连续刷新,理论上7天后的某一次刷新会失败,用户被要求重新登录。
滑动过期是每次刷新时重新计算过期时间,把有效期“续”到未来7天。也就是说只要用户一直活跃,Refresh Token就不过期,这就是很多App“登录一次永不离线”的实现基础。滑动过期的风险在于,如果Refresh Token被攻击者盗走,只要对方持续刷新,这个登录态就能一直保持,直到你主动吊销为止。
我的建议是默认固定过期,7天就是7天,到期重新登录也没什么大不了。如果你做的是一个需要极低打扰率的产品,可以给固定过期时间设长一点,比如14天或30天,也能守住安全边界。滑动过期不是不能用,但你要配合“设备数限制”“异常刷新行为监控”一起做,否则是在放大风险。
还有一点容易踩坑:刷新令牌的过期时间,和它的“绝对有效期限”要分开。比如Refresh Token的ExpiresAt是7天后,但用户有可能在第5天刷新了,生成了一个全新的Refresh Token,那么这个新Token的ExpiresAt应该是从刷新时刻重新计算7天,而不是沿用旧记录剩余的2天。如果不小心把旧记录的过期时间带过去了,用户会提前感知到登录失效,而且排查起来还不容易发现,因为看似一切都正常。
5.3 双令牌与Cookie、CSRF的配合
把Refresh Token放HttpOnly Cookie后,还要补上CSRF的防护。因为Cookie会自动随请求发送,攻击者构造一个恶意页面,诱使用户点击,浏览器就会带着用户的Cookie去请求你的刷新接口,成功完成一次刷新操作。
应对思路可以这样:在Cookie的配置里设置SameSite=Strict或SameSite=Lax,基本能挡住大部分跨站请求伪造;刷新接口本身再做一个CSRF Token校验,比如自定义Header加一个随机值,前端从服务端获取后放在请求头里带回来。因为第三方站点无法读取你站点上设置的Custom Header,这样能挡住CSRF。
另外,刷新接口本身还要防暴力破解或批量刷。Refresh Token在请求体里或Cookie里,如果被批量重放,至少要做限流。我习惯在刷新接口上按用户维度做一层限流:同一个UserId每分钟最多刷新N次,超过就拒绝,并提示“操作过于频繁”。这样即便攻击者拿到令牌,也无法无限次利用。
6. 常见问题与排查技巧实录
6.1 高频问题速查表
| 症状 | 原因 | 处理方式 |
|---|---|---|
| 接口一直返回401,但配置看起来正常 | ClockSkew默认值过大或Issuer/Audience错误 | 核对ValidIssuer、ValidAudience是否与签发时的Issuer、Audience一致 |
| 明明签发了角色Claim,[Authorize(Roles=“Admin”)]还是没效果 | Claim类型不对,用了自定义字符串而非ClaimTypes.Role | 保证Roles用的Claim类型是ClaimTypes.Role |
| 刷新Token时提示“刷新令牌已吊销” | 该Token已经被消费过,重放或并发导致 | 检查ReplacedByTokenHash是否有值,考虑并发问题 |
| 登录成功后马上请求接口,仍返回401 | 认证中间件顺序不对 | 确保app.UseAuthentication()在app.UseAuthorization()之前 |
| Access Token明明过期了,却还能用 | ClockSkew太大,默认有5分钟容差 | 调小ClockSkew为30秒左右 |
| 换密钥重启后所有旧Token都失效 | 密钥变了,签名校验必然失败 | 生产环境不要随意更换密钥;如需更换,提前规划过渡策略 |
| 刷新接口偶发更新丢失 | 并发刷新同一旧Token,后写覆盖先写 | 使用Redis原子删除/新增,或数据库加版本号 |
6.2 几个值得展开的实战教训
有一次在排查某系统日志时发现,一部分用户每天早上第一次请求必先遇到一次401。后端查了一圈,配置正确、Token签发正常。后来发现原因是前端把Access Token放在sessionStorage里,晚上页面长时间挂着,第二天打开时Token早就过期了,前端没有发起刷新,而是先发起了一个业务请求,401被当成“登录失效”,导致用户被弹回登录页。后续改成项目启动时先静默刷新一次Access Token,这个现象就消失了。
另有次在并发压测时,刷新接口出现了大量“令牌已吊销”的告警。原因是移动端在Access Token过期瞬间会有多个请求并发重试,其中一个触发了刷新,另外几个带着旧Token也来尝试刷新。一个请求成功后旧Token被吊销,其他并发请求自然全部失败。解决方案是在前端把“刷新动作”做成单例,并发调用只保留一个真实的刷新请求,其他请求等待这个结果再继续执行,而不是各自都去调一次刷新接口。
还有一次被坑得比较深的,是把Refresh Token的过期时间也做进了Access Token的Claims里。表面上没什么问题,但后来发现,用户刷新后拿到了新Access Token,旧Claims里还残留着过期时间信息,前端某些组件在解析时误用了这个旧时间,导致页面状态展示错乱。我的建议很直接:不要在Access Token里放任何只对刷新令牌有意义的信息,各管各的。
6.3 日志与监控的埋点建议
上线双令牌之后,日志这块千万别省。刷新接口至少要记这些数据:请求时间、用户Id、Jti、Refresh Token哈希前几位、刷新来源IP、操作结果(成功、无效、吊销、过期)。记录哈希前几位就够了,不要记完整个Token,否则日志本身就成了新的泄露面。
我见过一个挺有效的异常规则:同一个用户在一分钟内,使用超过两个不同的Refresh Token刷新成功,直接告警。正常情况下一个用户的活跃客户端最多两三个,一分钟后刷新时只会用同一个Token。多个不同的Token连续刷新,说明这个用户的登录态可能被多个设备或攻击者在同时消费。配合设备列表和Token家族树,这类数据能帮你尽早发现被盗号的迹象。
日志记录的另一个用处是排障。很多“登录状态莫名其妙消失”的反馈,不用去猜,直接把用户在那段时间的刷新记录翻出来看,是吊销了,过期了,还是被重放检测误伤了,一目了然。
我个人在实际操作中的体会是:JWT本身不是洪水猛兽,真正危险的是“签发完之后就当甩手掌柜”的用法。双令牌机制多写不了多少代码,但它把主动权夺回来了——用户可以退出,管理员可以踢人,异常可以追踪,短期令牌泄露的影响面被限制在一个很小的窗口里。如果你的项目正在用单令牌方案,并且出现“用户退出还能访问”“过期了被强制下线体验差”这类问题,刷新令牌这套改造值得认真做一遍。就算暂时没遇到,下一次安全评审前,也建议先把这套机制补齐。