JWT 双 Token 机制实战:2 小时 + 7 天,让登录状态既安全又无感
JWT 双 Token 机制实战2 小时 7 天让登录状态既安全又无感 本文是我在视频推流项目中设计登录认证体系的完整复盘包含JWT Redis 白名单/黑名单 双 Token 续期 Token Rotation全套实现。配套源码仓库github.com/JiaqiChen3518/video_stream登录认证里有一个经典的两难Token 有效期设长比如 7 天用户体验好但 token 泄露后攻击者有整整 7 天的时间冒充用户Token 有效期设短比如 2 小时安全但用户每隔两小时就要重新登录一次体验稀碎。这篇文章讲我们项目的解法——双 Token 机制短时效的 access token 负责日常访问长时效的 refresh token 负责续命。外加一套 Redis 白名单/黑名单机制解决 JWT签发后无法撤销的天然缺陷。一、方案总览登录成功 ├──► access token有效期 2 小时── 每次请求携带负责身份认证 └──► refresh token有效期 7 天── 平时躺在前端只在续期时用 access 快过期 ├── 剩余 1 小时 → 过滤器自动续期用户无感知响应头带回新 token └── access 已失效 → 用 refresh token 换新Token Rotation旧的 refresh 同时作废二、JWT 本身30 秒回顾JWT 由三段组成Header.Payload.Signature。我们用它只存必要声明绝不存密码等敏感信息StringtokenJWT.create().withIssuer(ISSUER)// 签发者.withSubject(userId.toString())// 主题用户ID.withClaim(username,username).withClaim(role,role).withClaim(tokenId,tokenId)// ★ 全局唯一的token身份证后文的关键.withIssuedAt(now).withExpiresAt(expireTime)// 2小时后过期.sign(Algorithm.HMAC256(SECRET));// HMAC256签名防篡改JWT 的价值无状态、自包含——服务端不用查库就能验证身份验签名即可。但它有个著名缺陷签发出去就失控了。签名没过期的 token 永远有效想踢人下线用户改密码后作废旧 token都做不到。三、Redis 补救白名单 黑名单 多设备管理我们的方案JWT 照发但每个 token 生成时把tokenId登记到 Redis。验证时除了验签名还要查 Redis——用一点点状态换回完全的控制权// ① 生成 access token 时登记白名单StringtokenIdUUID.randomUUID().toString();StringtokenJWT.create()...withClaim(tokenId,tokenId)...sign(ALGORITHM);RedisUtil.set(token:access:tokenId,token,7200);// 白名单2小时有效RedisUtil.sadd(user:token:userId,tokenId,7200);// 用户→token集合支持多设备登录验证时做三重检查publicstaticbooleanverifyToken(Stringtoken){DecodedJWTjwtVERIFIER.verify(token);// 第一重签名过期时间StringtokenIdjwt.getClaim(tokenId).asString();if(RedisUtil.get(token:access:tokenId)null){// 第二重白名单被删被踢下线returnfalse;}if(isTokenInBlacklist(tokenId)){// 第三重黑名单returnfalse;}returntrue;}登出黑名单的 TTL 为什么恰好是剩余时间JWT 无法销毁但可以让它失效——把 tokenId 放进黑名单publicstaticvoidinvalidateToken(Stringtoken){DecodedJWTjwtdecodeToken(token);StringtokenIdjwt.getClaim(tokenId).asString();longremainMsjwt.getExpiresAt().getTime()-System.currentTimeMillis();if(remainMs0){// 黑名单过期时间 原token的剩余时间RedisUtil.set(token:blacklist:tokenId,1,(int)(remainMs/1000));RedisUtil.del(token:access:tokenId);// 顺手从白名单删除RedisUtil.srem(user:token:userId,tokenId);// 从用户的token集合移除}}黑名单 TTL token 剩余有效期的细节很妙token 反正 2 小时后自己过期黑名单没必要记一辈子——到期自动消失黑名单集合永远保持极小。四、自动续期让用户永远无感access token 只有 2 小时难道用户每小时都要被踢去登录不需要。我们在 JWT 过滤器里加一段滑动续期逻辑// JwtFilter.doFilter 中StringtokengetToken(request);// 从 Authorization: Bearer 中取if(!JwtUtil.verifyToken(token)){thrownewUnauthorizedException(Token无效或已过期);}// ★ 剩余有效期 1小时就地续期用户完全无感StringrenewedTokenJwtUtil.checkAndRenewToken(token);if(!renewedToken.equals(token)){response.setHeader(X-Renewed-Token,renewedToken);// 新token放响应头带回tokenrenewedToken;}// 解析用户信息 → 存入ThreadLocal → 放行UserContext.setUserId(userId);UserContext.setRole(role);续期逻辑本身publicstaticStringcheckAndRenewToken(Stringtoken){DecodedJWTjwtdecodeToken(token);longremainingjwt.getExpiresAt().getTime()-System.currentTimeMillis();if(remainingRENEWAL_THRESHOLDremaining0){// 阈值1小时invalidateToken(token);// 作废旧token进黑名单returngenerateAccessToken(userId,username,role);// 签发新token}returntoken;// 还早不动}设计上有三个讲究阈值续期1 小时而不是每次请求都换避免每个请求都签发新 token 写 Redis 的浪费续期 作旧 发新旧 token 进黑名单——如果旧 token 已被攻击者截获它立刻失效新 token 通过响应头X-Renewed-Token带回前端拦截器替换本地存储即可用户全程无感知。五、Token Rotationrefresh token 的重生如果用户 2 天没上线access token 彻底过期自动续期也救不了它只对还没死的 token 生效。此时前端拿出 7 天有效期的 refresh token 调/user/refresh换新publicstaticString[]refreshAccessToken(StringrefreshToken){DecodedJWTjwtVERIFIER.verify(refreshToken);StringoldTokenIdjwt.getClaim(tokenId).asString();// refresh token 也要查Redis白名单防伪造if(RedisUtil.get(token:refresh:oldTokenId)null)returnnull;invalidateRefreshToken(userId,oldTokenId);// ★ 旧的refresh token立即作废StringnewAccessTokengenerateAccessToken(userId,username,role);StringnewRefreshTokengenerateRefreshToken(userId);// ★ 连refresh一起换新returnnewString[]{newAccessToken,newRefreshToken};}这里用的是Token Rotation令牌轮换刷新时同时换发新的 access token 和 refresh token旧的 refresh token 立即作废。安全收益refresh token 有效期长达 7 天是攻击者的重点目标。轮换机制让偷到旧 refresh token 的攻击者只能用一次——下次用户刷新时旧 token 已失效攻击者用旧 token 刷新会失败可配合服务端检测失效 token 被使用的异常判定 token 泄露并强制全端下线每次刷新都轮换refresh token 的实际暴露窗口被压缩到两次刷新之间。六、过滤器链认证逻辑放哪ThreadLocal 怎么清整个认证在 Filter 层统一完成Controller 和 Service 里看不到一行 token 解析代码publicvoiddoFilter(ServletRequestrequest,ServletResponseresponse,FilterChainchain){// ① 白名单路径直接放行登录/注册/视频列表/搜索等公开接口if(isExcludePath(requestURI)){chain.doFilter(request,response);return;}// ② 取token → ③ 验证 → ④ 自动续期 → ⑤ 解析用户身份// 校验失败抛 UnauthorizedException由异常过滤器统一转JSON// ⑥ 身份存入ThreadLocal业务层通过 UserContext.getUserId() 随处可取UserContext.setUserId(userId);UserContext.setRole(role);try{chain.doFilter(request,response);}finally{UserContext.clear();// ★ 必须清理}}finally { UserContext.clear(); }是 Tomcat 线程复用陷阱的标准答案Tomcat 的线程池会复用工作线程如果不清 ThreadLocal上一个请求的用户信息会渗漏给下一个复用该线程的请求——A 用户可能读到 B 用户的身份。这类 bug 极难排查而防御只需要一行 finally。过滤器的顺序也有讲究我们的配置是异常处理 跨域(CORS) JWT。异常过滤器放最外圈保证任何一环抛出的异常包括 JWT 校验失败都能被统一兜底成 JSON 错误响应。七、完整时序一次登录后的一周服务端前端用户服务端前端用户alt[剩余1小时][剩余1小时]loop[日常请求]2小时后access过期7天未登录refresh也过期登录账号密码access(2h) refresh(7d)携带access token验签白名单黑名单正常响应响应 X-Renewed-Token前端替换用refresh换新旧refresh作废签发新双token重新登录八、设计取舍我们放弃了什么方案优点我们为什么没用纯 JWT无 Redis完全无状态验证零 IO无法踢人下线、无法强制失效改密码/token 泄露都无解Session Cookie服务端完全可控有状态、跨域麻烦、不适合前后端分离 App 多端JWT Redis 白名单本文兼顾 JWT 的简洁与服务端的控制权每次验证多一次 Redis 查询可用本地缓存白名单优化还有个务实的小决定验证要查 Redis理论上比纯 JWT 多一次 IO。但 access token 的验证 QPS 与业务接口同量级一次 Redis 查询微秒级换来随时踢人的能力对视频社区这种有管理后台的系统是划算的。九、面试快问快答Q1为什么需要双 Token单 Token 不行吗单 Token 的长短有效期矛盾无法调和长了不安全短了体验差。双 Token 把日常访问短和续期凭证长分离access 泄露窗口小refresh 使用频率低暴露少。Q2JWT 有什么缺点你们怎么解决最大缺点是无法撤销无状态的双刃剑。我们通过 tokenId Redis 白名单解决删除白名单 踢下线黑名单集合处理登出和续期作旧。代价是每次验证多一次 Redis 查询。Q3什么是 Token Rotation刷新令牌时同时轮换 access 和 refresh token旧 refresh 立即作废。安全收益refresh token 被盗后只能使用一次服务端还能通过失效 token 被使用检测泄露。Q4ThreadLocal 存用户信息有什么坑Tomcat 线程池复用线程请求结束必须remove()/clear()否则上一个请求的用户信息渗漏给下一个请求。务必写在 finally 里。Q5为什么黑名单要设置过期时间黑名单的寿命只需要覆盖原 token 的剩余有效期——token 自己过期后黑名单记录就是垃圾数据。设置剩余时间为 TTL黑名单集合可以永远保持极小无需清理任务。 本篇完整源码JwtUtil.java JwtFilter.java UserContext.java 下一篇预告《缓存穿透、击穿、雪崩——一次用真实代码讲透缓存三大问题》如果这篇对你有帮助欢迎点赞收藏关注