
直接在苍穹外卖项目里JWT token 这块算是整套权限体系里最有存在感的一环。我第一次做这个项目的时候后端写完登录接口前端拿了 token 塞进浏览器结果请求一打过去就被拦截器拒了耽误了半个下午才定位到是密钥校验的问题。后来把 JWT 这套在项目里彻底捋顺了才发现它其实就绕不开三件事签发、校验、传递。这篇文章把苍穹外卖里的 JWT token 实现从头拆到尾包含设计思路、核心代码、拦截器注册、常见坑排查以及续签、登出这类进阶玩法适合正在做苍穹外卖项目或者想搞懂 SpringBoot 后端如何用 JWT 做登录鉴权的同学参考。1. 整体设计与思路拆解1.1 为什么苍穹外卖需要 JWT 而不是 Session很多人在学习阶段最先接触的是 Session服务端把登录状态存在内存里再给浏览器回一个 JSESSIONID。单机部署的时候这套没问题但一旦服务拆成多台用户请求落到另一台机器上Session 就找不到了还得引入 Spring Session、Redis 共享会话之类的方案维护成本一下就上来了。苍穹外卖做的是前后端分离结构后端只提供 JSON 接口不再渲染页面这种情况下 Session 的 Cookie 语义在跨域场景里也很别扭。JWT 的思路刚好绕开了服务端状态管理用户登录成功后服务端把用户身份信息签名进一个自包含的令牌里后面每次请求带着这个令牌来服务端只负责验签验过了就从令牌里读出“你是谁”。简单来说Session 是“服务端记着你”JWT 是“你自己证明你是你”。这就带来了一个很直接的体验差异集群部署时 JWT 天然无状态不需要共享会话存储移动端也好、小程序端也好拿到 token 往请求头里一放就行不需要折腾 Cookie 跨域问题。苍穹外卖同时面向管理端和用户端管理端在电脑浏览器跑用户端在微信小程序里跑用 JWT 一套机制通吃两端是当时比较省事的选型。1.2 苍穹外卖的认证链路设计整个链路拆开看其实是四段式登录签发、请求携带、后端校验、用户信息存取。登录阶段用户提交账号密码后端校验通过后把用户主键empId 或 userId作为自定义声明塞进 JWT 的 payload再设置过期时间用密钥签名生成一段字符串返回给前端。携带阶段前端拿到 token 后存储在本地并在后续每次请求的请求头里带上Authorization: token值或者自定义的token: 值。校验阶段后端写一个拦截器HandlerInterceptor在请求进入 Controller 前拦截从请求头取出 token调用 JWT 工具类验签解析。验签通过就放行失败就直接返回 401 错误信息。信息存取阶段解析出来的用户 id 不能直接扔了或者每次都在 Controller 重新解析一遍最好存进 ThreadLocal 线程变量里当前线程后续所有代码都能拿到当前登录用户是谁。这里面有个反直觉的点校验阶段大多数人只关心 token 能不能验过但真正决定体验的是第四段ThreadLocal 用不好就会出现用户 A 的操作记录到用户 B 头上这类问题还特别难排查。1.3 依赖选型与版本差异苍穹外卖课程里用的是 jjwt 库也就是io.jsonwebtoken:jjwt。这个库要注意版本0.9.x 和 0.11.x 的 API 风格差异比较大网上很多老帖子用的是 0.9.1 的写法比如这样Jwts.builder() .setSubject(苍穹外卖) .setClaims(claims) .setExpiration(expiration) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();但是切到 0.11.5 之后signWith(SignatureAlgorithm.HS256, string密钥)这种写法会报错或者报警告因为新版本要求传入Key对象。如果你在项目里遇到“弱密钥异常”或者编译时方法签名对不上先看看自己依赖的是哪个版本。实操建议新版 jjwt 建议先通过Keys.hmacShaKeyFor(secretKey.getBytes())生成安全的签名密钥对象再用signWith(key)签名。苍穹外卖项目也兼容旧写法但新项目直接上 0.11.5 更省心。2. 核心细节解析与实操要点2.1 JWT 的结构JWT 是一串用点号分成三段的字符串看起来像eyJhbGciOiJIUzI1NiJ9.eyJlbXBJZCI6MX0.xxxxx。三段分别是 Header、Payload、Signature。Header里面声明了类型和签名算法比如{alg:HS256,typ:JWT}。Payload业务声明都在这里可以放用户 id、用户名、过期时间exp、签发时间iat等。Signature用 Header 里声明的算法把前两段拼上密钥一起算出来的签名用来防止内容被篡改。我一直建议项目里把“token 里放什么字段”当成接口文档一样规范起来。苍穹外卖里管理端 token 的 payload 一般放empId用户端放userId过期时间分别设置成不同时长——管理端通常给 2 到 12 小时用户端会给更宽松的 7 天左右。但要注意一个红线不要往 payload 里放密码、手机号这类敏感数据。JWT 默认只是 Base64 编码不是加密任何人拿到 token 都可以解码看内容。放密码等于把密码明文发给全互联网。2.2 生成 token 的关键参数密钥、过期时间、声明内容这三个参数必须有明确的规划而不能拍脑袋写死。先说密钥。HS256 算法要求密钥至少 256 位也就是 32 个字节常见做法是一串 32 位以上的随机字符串比如sky-take-out-secret-key-please-change-me这种。密钥太短新版 jjwt 会直接抛WeakKeyException。还有一点密钥在开发环境写进 yml 文件没什么问题但上线前一定要挪到环境变量或配置中心里别把生产密钥提交到 Git 仓库。再说过期时间。JWT 一旦签发在没有额外机制的情况下是不好在服务端主动作废的所以过期时间不能设得太长否则 token 泄露后被人盗用的窗口就太大了。苍穹外卖课程里管理端常见设置是3600 * 1000这种毫秒值也就是 1 小时我自己做项目时管理端一般给 2 小时用户端给 7 天这类数值我会写到常量类里统一管理而不是散落在各个方法中。最后是 claims 内容。自定义声明要尽量精简只放能唯一定位用户身份的字段比如 empId。放其他容易变化的数据会导致每次登录生成出来的 token 都不同不利于排查问题。2.3 拦截器加 ThreadLocal 的设计校验 JWT 并不一定非要用拦截器用 Spring MVC 的拦截器算是最常见、最贴合苍穹外卖项目风格的方案。拦截器在请求进入 Controller 之前执行preHandle在里面解析 token 并保存用户信息再放行。ThreadLocal 在这里扮演的角色很关键。Java Web 项目每个请求默认由 Tomcat 线程池里的一个线程处理同一个线程在处理完一个请求后会被复用去处理下一个请求。如果把用户 id 存进普通变量Tomcat 线程池复用之后下一个请求可能读到上个请求的残留数据——这就是“串号”的根源。用 ThreadLocal 把数据限制在当前线程内配合finally中清理就能做到每个请求的用户信息隔离。注意拦截器的afterCompletion阶段一定要调用ThreadLocal.remove()清理数据。忘了这一步平时测不出来问题但并发一上来就会出现偶尔拿到别的用户信息这种玄学 bug特别难定位。2.4 白名单设计思路不是所有接口都需要登录才能访问。苍穹外卖里登录接口本身、小程序端的某些基础查询接口还有 Swagger 文档相关的静态资源路径都应该放在白名单里。我见过不少同学把白名单写成一个常量列表在拦截器里逐个判断请求路径是否以某个前缀开头这没问题。但要注意一点路径匹配要用精确逻辑别用简单的String.contains做判断。比如你放行了/admin/employee/login如果写成contains(/admin)判断那所有/admin开头的接口全被放行了拦截器形同虚设。更好的做法要么用 AntPathMatcher 做规则匹配要么维护一个“完全放行 前缀放行”的集合逐个精确对比。3. 实操过程与核心环节实现3.1 依赖配置与常量定义第一个步骤是引入依赖在 pom.xml 里增加dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.11.5/version /dependency如果使用 0.11.x它拆成了jjwt-api、jjwt-impl、jjwt-jackson三个模块不过直接引入jjwt聚合包也可以。然后在全局配置类里定义常量我会新建一个JwtProperties用ConfigurationProperties读取 yml 配置sky: jwt: admin-secret-key: itcast admin-ttl: 7200000 admin-token-name: token这里的admin-secret-key是管理端签发 token 用的密钥admin-ttl是过期时间毫秒值admin-token-name是前端请求头里携带 token 的 key 名。用户端通常也配一套只是密钥不同、过期时间不同目的是防止管理端 token 拿到用户端接口上冒用。3.2 编写 JwtUtil 工具类JwtUtil 不需要做成 Spring 容器里的 Bean工具类用静态方法就足够了。核心方法就两个创建 token 和解析 token。创建 token 的逻辑是先填 Header、Payload再填过期时间最后签名。解析 token 的逻辑是用同一把密钥验签验过了返回 Claims验不过抛异常。下面这套代码兼容 0.11.5 版本可以直接抄public class JwtUtil { // 创建 token public static String createJWT(String secretKey, long ttlMillis, MapString, Object claims) { SecretKey key Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); long expMillis System.currentTimeMillis() ttlMillis; Date exp new Date(expMillis); return Jwts.builder() .setClaims(claims) .setExpiration(exp) .signWith(key, SignatureAlgorithm.HS256) .compact(); } // 解析 token public static Claims parseJWT(String secretKey, String token) { SecretKey key Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); } }这是一个比较标准的写法。需要注意setClaims必须放在setExpiration前面如果先用setExpiration再setClaims后者会覆盖掉过期时间。这个细节在旧版本里已经是个暗坑到了新版本依然存在我建议所有字段都先塞进同一个Map再通过setClaims一次性设置避免覆盖。3.3 登录接口改造与 token 签发登录接口本身并不复杂。Controller 接收EmployeeLoginDTO取出用户名和密码用 MD5 加密后的密码去和数据库对比。课程里密码存的是 MD5直接比对加密串就行不需要去解密。密码校验通过后构建 claims 并调用 JwtUtil 签发 tokenMapString, Object claims new HashMap(); claims.put(JwtClaimsConstant.EMP_ID, employee.getId()); String token JwtUtil.createJWT( jwtProperties.getAdminSecretKey(), jwtProperties.getAdminTtl(), claims );返回给前端的结构里除了 token最好还带上员工信息对象。前端拿到后可以存用户昵称、头像等展示数据省得每次刷新页面都要重新拉用户详情。这里有一个我踩过的坑千万不能在登录接口里把密码回传前端哪怕在返回对象里多一个password字段也不行。苍穹外卖的返回对象如果直接用了实体类需要手动把密码置空或者干脆新建 VO 只包含需要的字段。不然抓包一看明文密码MD5串直接暴露在响应里等于白加密。3.4 拦截器实现与注册写一个实现HandlerInterceptor接口的拦截器类核心是preHandle和afterCompletion两个方法public class JwtTokenAdminInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行非控制器方法比如静态资源映射 if (!(handler instanceof HandlerMethod)) { return true; } String token request.getHeader(jwtProperties.getAdminTokenName()); try { Claims claims JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token); Long empId Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString()); BaseContext.setCurrentId(empId); return true; } catch (Exception ex) { response.setStatus(401); response.getWriter().write(NOT_LOGIN); return false; } } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 清理线程变量防止串号 BaseContext.removeCurrentId(); } }注册拦截器在WebMvcConfiguration里做同时把白名单路径配进去Configuration public class WebMvcConfiguration implements WebMvcConfigurer { Autowired private JwtTokenAdminInterceptor jwtTokenAdminInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtTokenAdminInterceptor) .addPathPatterns(/admin/**) .excludePathPatterns(/admin/employee/login) .excludePathPatterns(/admin/shop/status); } }这个配置解决了一个重要问题拦截器只拦截/admin/**下面需要登录的接口登录接口和店铺状态接口小程序端需要公开查询营业状态单独放行。如果漏配了静态资源放行Swagger 页面和前端静态文件也会被拦截看上去就像页面白屏。补充OPTIONS请求一定要主动放行。浏览器跨域请求前会发一个预检请求这个预检请求不带 token如果被拦截器拦了前端会看到 CORS 错误但退到后端去查时会发现“不该拦的也被拦了”。在preHandle开头加一个if (OPTIONS.equalsIgnoreCase(request.getMethod())) return true;就能解决。4. 常见问题与排查技巧实录4.1 token 失效与过期问题热词里有一大堆“token exchange failed”、“token could not be refreshed”之类的问题虽然那大多是 OAuth/OIDC 协议里的现象但苍穹外卖这种单系统 JWT 场景里token 失效的典型症状其实更简单前端明明登录了请求却一直返回 401。排除思路按顺序来先确认浏览器/小程序请求头里是否真的带了token字段。很多前端代码里写在请求拦截器里但登录页发起的第一个请求没走携带逻辑就会漏带。再确认 token 是否已经过期。把 token 粘到 jwt.io 上看 exp 字段或者在后端解析时打印异常类型。过期时间设太短用户挂机一会儿再操作就白屏所以我建议管理端至少 2 小时起步。最后检查服务端和本地电脑的系统时间是否同步。JWT 校验时会比对exp和当前时间服务器时间慢一分钟客户端拿到的新 token 可能“还没生效”就被认为过期了。4.2 签名不匹配与密钥长度问题这类报错一般在控制台看到SignatureException或WeakKeyException。WeakKeyException十有八九是密钥太短换了 32 字节以上的随机字符串就好。SignatureException则要排查两端密钥是否一致你写死在 yml 里的密钥和解析时读到的密钥只要有一个字符不一样就验不过。我们项目里出现过开发环境密钥和测试环境密钥不一致前端在测试环境登录拿的 token打到开发环境直接被拒还以为是代码逻辑出问题后退到配置层才发现是密钥不统一。遇到这类问题最快的方式是写一个临时接口或者在测试类里打日志把 Header 解码后的 alg 值打印出来。如果 alg 显示none说明伪造者想用无签名算法绕过这在生产库里应该直接拒绝。4.3 ThreadLocal 串号与内存泄漏这个 bug 是典型的“线上偶现、本地复现不了”型问题。现象是A 用户登录后请求里偶尔返回 B 用户的数据。排查方向先看 ThreadLocal 是否在afterCompletion里清理了。Tomcat 的工作线程是复用的线程 A 处理完请求后如果不 remove下一次该线程被分配给用户 B 时ThreadLocal 里存的还是 A 的 idB 的请求全程读到的都是 A 的身份。还有一种情况是异步任务里读 ThreadLocal子线程拿不到父线程的值这也是很多人在苍穹外卖扩展定时任务时踩的坑。如果要在异步线程里传递用户身份要么显式传参要么用 TransmittableThreadLocal 这类专门工具不能默认它自动传递。4.4 拦截器放行与静态资源问题如果配置了/admin/**拦截规则但 Swagger 文档打不开或者登录后跳转页面仍然 404先排查白名单里有没有把静态资源路径加进去。Spring Boot 项目的静态资源映射默认在/static/**、/public/**这些路径下而 controller 方法和资源映射在handler instanceof HandlerMethod的判断上就能区分出来所以拦截器里先放行非 HandlerMethod 是第一步保护。另一个容易犯的错是把放行前缀写得太粗比如直接放行/admin域名后面跟任何路径都会匹配不上/admin/**因为 Spring 的 Ant 路径匹配规则里/admin和/admin/**是两个不同的匹配段。想放行所有/admin开头的请求必须写成/admin/**。4.5 常见问题速查表现象可能原因处理办法请求返回 401请求头缺 token / token 过期 / 密钥不一致按顺序检查请求头、exp 时间、密钥配置控制台打印 WeakKeyException密钥少于 32 字节换用 32 字节以上随机字符串前端报 CORS 错误预检 OPTIONS 请求被拦截器拦截在拦截器里放行 OPTIONS 请求用户数据串号ThreadLocal 未清理或跨线程传递afterCompletion 中 remove异步任务显式传参登录后页面拿不到用户信息Controller 再次解析 token 失败统一从 BaseContext 取不要二次解析静态资源/Swagger 404拦截器未放行 HandlerMethod 之外的资源在 preHandle 开头判断handler instanceof HandlerMethodtoken 能被篡改却不报错签名算法被换成 none生产环境强制只接受 HS256验签失败直接拒绝5. 进阶token 续签、登出与安全加固5.1 滑动续期方案热词里“jwt实现token续签”被反复提到这确实是单 token 方案最大的短板token 一旦过期用户就被强制退出体验很差。续签的做法一般有两种。第一种是滑动过期也叫自动续期。每次请求进来解析 token 时判断剩余有效期是否低于某个阈值比如总有效期的四分之一如果低于就签发一个新 token 放响应头里前端检测到新 token 就替换本地存储。这种做法用户完全无感实现也不复杂缺点是每个请求都要走一次“是否该续”的逻辑代码里要留意别每次请求都发新 token否则就是疯狂刷接口。第二种是双 token 机制登录时同时签发 access_token短时效比如 2 小时和 refresh_token长时效比如 7 天。access_token 过期后前端拿着 refresh_token 去换新的 access_token。这种方案在苍穹外卖这种教学项目里一般不会展开但真实商业项目里几乎都是这个思路。要注意 refresh_token 本身也需要防泄漏通常存在 httpOnly 的 Cookie 里而不是暴露给前端 JS 随意读取。5.2 登出与黑名单JWT 无状态的特点决定了服务端没法直接“删除”一个已签发的 token。用户点退出登录最简单的方式是前端把本地 token 丢掉之后的请求自然不带 token 了。但这样有个隐患token 在被丢弃前如果被截获过依然能在有效期内使用。如果想要更严格的登出语义可以在 Redis 里维护一个 token 黑名单登出时把当前 token 的 jtiJWT ID塞进去并设置与剩余有效期一致的过期时间。拦截器解析 token 后先去查一次黑名单命中就直接拒绝。还有一种方案是完全反过来——用 Redis 存有效 token 白名单登出就删除但这本质上又回到了服务端有状态管理和 JWT 的优势相冲突适合对安全要求极端的系统。我的建议是普通项目以“前端删除 短过期时间 敏感操作二次校验”组合使用黑名单机制在有安全合规要求时再加不用一开始就背上。5.3 密钥管理与接口安全加固密钥是 JWT 体系的命门。像苍穹外卖这种教学项目密钥直接写在 yml 里没问题但如果你要把这个项目改造成真实部署至少要做到三点生产环境的密钥从环境变量或配置中心读取不要提交进 Git 仓库。管理端和用户端用不同密钥即使一个被盗另一个还能守住。定期轮换密钥配合双 token 机制能在不打断用户的情况下完成轮换。接口层面也有一些和 token 配套的安全细节。比如修改密码、退款这类敏感操作建议再叠加验证码或原密码二次校验网关层可以针对单 IP 加接口频控管理端接口严格按 RBAC 做菜单权限控制不能只靠一个“登录了就行”的 token 判断。JWT 只解决“你是谁”的问题不解决“你能不能干这件事”的问题。最后再分享一个我在项目里的实际配置经验控制台里经常会看到 Failed to parse JWT 之类的异常堆栈为了排查方便我习惯在拦截器里用log.warn(JWT parse error, uri: {}, msg: {}, request.getRequestURI(), ex.getMessage())记录一条 Warning 日志人手排查时会非常快而且不会把完整的敏感 token 打印出来。把日志做到位JWT 这个环节基本就算闭环了。