我花3天给Web项目补上JWT认证,踩了2个生产大坑
上周收尾一个SaaS商户后台项目,客户是做本地生活服务的,几百个门店。他们原本用传统的Session Cookie做登录态,结果最近被安全团队审计点名:接口裸露在外,没有有效的认证机制,敏感操作甚至能通过改参数越权。客户急得让我一周内把接口安全加固做完,规模不大,后端Spring Boot单体,大概有80多个API端点需要统一接入。说实话,如果只是加个Token校验,半天就能搞定,但这次要求是“生产级”,得包含刷新令牌、权限粒度控制和异常兜底,我才发现水挺深。
从方案争论到落地,我选了双Token而不是单Token
一开始组内就吵了一架。老张主张用单JWT,签发后长效14天,理由是减少刷新频率、降低服务端状态管理。我当时觉得“对,无状态多省心”,结果试了一圈发现,一旦用户Token泄露,这14天内所有请求都合法,没法服务端主动吊销。更糟的是权限变更后(比如某账号被降级),用户依然带着旧Token畅通无阻,直到过期。
我最终推翻了那个方案,改成“短效访问令牌 + 长效刷新令牌”的双Token结构。访问Token有效期15分钟,刷新Token有效期7天且只存Redis。选Redis存刷新Token不是因为它比JWT安全,而是为了我能做到“踢人下线”和“Token黑名单”——这在生产环境是刚需。当时我觉得这样就行,结果发现错了:刷新Token如果也做成无状态,审计方不接受,必须可撤销。
核心签发逻辑我封装在JWTUtil里,关键代码片段:
```javapublic class JWTUtil {private static final String SECRET = System.getenv("JWT_SECRET");private static final long ACCESS_TTL = 15 60 1000;private static final long REFRESH_TTL = 7 24 60 60 1000;
public String generateAccessToken(Long userId, List roles) {Instant now = Instant.now();return Jwts.builder().claim("uid", userId).claim("roles", roles).setIssuedAt(Date.from(now)).setExpiration(Date.from(now.plus(ACCESS_TTL, ChronoUnit.MILLIS))).signWith(Keys.hmacShaKeyFor(SECRET.getBytes()), SignatureAlgorithm.HS256).compact();}
public String generateRefreshToken(Long userId) {String token = UUID.randomUUID().toString().replace("-", "");redisTemplate.opsForValue().set("refresh:" + userId, token, REFRESH_TTL, TimeUnit.MILLISECONDS);return token;}}```
这里有个细节:刷新Token我故意没用JWT格式,而是用UUID。因为刷新Token本身不携带任何业务信息,它唯一的作用就是“凭它换新Token”,用UUID+Redis键值对更轻量,也避免了解析JWT带来的额外复杂度。
踩坑实录:过滤器顺序和异常吞没让我排查了两天
真正的坑不在Token生成,而在Spring Security的过滤器链和全局异常处理。
我最初把JwtAuthenticationFilter放在UsernamePasswordAuthenticationFilter之前,以为越早拦截越好。结果发现:所有请求(包括静态资源、健康检查)都被我的过滤器扫了一遍,其中一次对null header的解析直接抛了NPE,被Spring Security的默认ErrorPage机制吞掉了,前端只收到500,日志里却没有任何堆栈。坑死了。我花了大半天加debug日志、打点、复现,才定位到是过滤器顺序问题+异常未被捕获。
正确做法是:把自定义过滤器放在UsernamePasswordAuthenticationFilter之后、BasicAuthenticationFilter之前,并且必须在doFilterInternal里包try-catch,对Token解析失败、过期、签名错误等细分异常分别抛出不同的AuthenticationException子类,让全局异常处理器@ExceptionHandler精准映射HTTP状态码。
另一个隐藏坑是CORS预检请求(OPTIONS)被我的过滤器拦截了,导致前端跨域全部失败。我加了白名单:如果method是OPTIONS且path匹配/api/**,直接return,不调用filterChain.doFilter。
刷新令牌的兜底逻辑我也加上了竞态保护:
```java@PostMapping("/auth/refresh")public ResponseEntity refreshToken(@RequestBody RefreshRequest req) {String stored = redisTemplate.opsForValue().get("refresh:" + req.getUserId());if (stored == null || !stored.equals(req.getRefreshToken())) {return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();}// 原子性撤销旧Token,防止并发刷新boolean deleted = redisTemplate.delete("refresh:" + req.getUserId());if (!deleted) {return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();}String newAccess = jwtUtil.generateAccessToken(req.getUserId(), rolesService.getRoles(req.getUserId()));String newRefresh = jwtUtil.generateRefreshToken(req.getUserId());return ResponseEntity.ok(new TokenResponse(newAccess, newRefresh));}```
权限粒度怎么落地的
角色只到RBAC层还不够,客户要求“门店店长只能查自己门店数据,总部运营能查全部”。我没在Controller里写if-else判断(那会散落一地),而是在MyBatis拦截器里做数据源SQL改写:从SecurityContext里取出当前用户绑定的storeIds,动态拼接WHERE条件。
有意思的是,这个方案上线后性能没下降,因为storeIds列表最长也就20个门店,IN查询走索引完全没问题。如果客户是“按区域动态权限”,比如华南区经理能查华南所有门店,那就得走策略模式+缓存了,这个我留了扩展点但本期没做。
最后验证阶段,我用Burp Suite模拟了Token重放、过期、篡改签名三种攻击,全部被拦截并返回预期状态码。客户安全团队复检通过,整个加固过程3天,其中2天耗在调试和回归测试上,纯编码其实不到6小时。
本文基于实际项目经验整理,欢迎在评论区交流技术问题。