一次 JWT Token 过期续期的踩坑记录

项目用 JWT 做认证,Token 有效期设了 2 小时。用户上午登录,下午用的时候突然被踢回登录页------Token 过期了。产品说"体验太差了,能不能别老让人重新登录"。

JWT 基本原理

css 复制代码
Header.Payload.Signature

三段用 . 拼接,每段都是 Base64 编码:

  • Header :算法类型({"alg": "HS256", "typ": "JWT"}
  • Payload :用户信息 + 过期时间({"userId": 123, "exp": 1727000000}
  • SignatureHMACSHA256(header + payload, secret) 签名,防篡改

服务端不存 session,只存一个 secret。验证的时候重新算一遍签名,对得上就信任 Payload 里的数据。

后端签发和验证(Java + Spring Boot)

java 复制代码
@Component
public class JwtUtil {

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration}")
    private long expiration; // 毫秒

    public String generateToken(Long userId, String role) {
        return Jwts.builder()
            .setSubject(String.valueOf(userId))
            .claim("role", role)
            .setIssuedAt(new Date())
            .setExpiration(new Date(System.currentTimeMillis() + expiration))
            .signWith(SignatureAlgorithm.HS256, secret)
            .compact();
    }

    public Claims parseToken(String token) {
        return Jwts.parser()
            .setSigningKey(secret)
            .parseClaimsJws(token)
            .getBody();
    }

    public boolean isTokenExpired(String token) {
        try {
            Claims claims = parseToken(token);
            return claims.getExpiration().before(new Date());
        } catch (Exception e) {
            return true;
        }
    }
}

拦截器校验

java 复制代码
@Component
public class AuthInterceptor implements HandlerInterceptor {

    @Autowired
    private JwtUtil jwtUtil;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                             Object handler) {
        String token = request.getHeader("Authorization");
        if (token == null || !token.startsWith("Bearer ")) {
            response.setStatus(401);
            return false;
        }

        token = token.substring(7);

        if (jwtUtil.isTokenExpired(token)) {
            response.setStatus(401);
            response.setHeader("Token-Expired", "true"); // 告诉前端是过期不是未登录
            return false;
        }

        Claims claims = jwtUtil.parseToken(token);
        request.setAttribute("userId", Long.parseLong(claims.getSubject()));
        request.setAttribute("role", claims.get("role", String.class));
        return true;
    }
}

无感续期方案

用户不应该在正常使用过程中被踢下线。方案是双 Token

  • Access Token:短有效期(2 小时),用于 API 鉴权
  • Refresh Token:长有效期(7 天),只用于换取新的 Access Token
java 复制代码
// 登录时同时签发两个 Token
public LoginVO login(String username, String password) {
    // 验证用户名密码...
    String accessToken = jwtUtil.generateToken(user.getId(), user.getRole());
    String refreshToken = jwtUtil.generateRefreshToken(user.getId());

    // Refresh Token 存 Redis,7 天过期
    redis.setex("refresh:" + user.getId(), 7 * 86400, refreshToken);

    return new LoginVO(accessToken, refreshToken);
}

// 刷新接口
@GetMapping("/auth/refresh")
public Result<TokenVO> refresh(@RequestHeader("Refresh-Token") String refreshToken) {
    Claims claims = jwtUtil.parseToken(refreshToken);
    Long userId = Long.parseLong(claims.getSubject());

    // 校验 Redis 里的 Refresh Token 是否一致(防止旧 Token 被重用)
    String stored = redis.get("refresh:" + userId);
    if (stored == null || !stored.equals(refreshToken)) {
        return Result.error(401, "Refresh Token 无效");
    }

    // 签发新的 Access Token
    String newAccessToken = jwtUtil.generateToken(userId, claims.get("role", String.class));
    return Result.ok(new TokenVO(newAccessToken, refreshToken));
}

前端自动续期

js 复制代码
// request.js
let isRefreshing = false
let pendingRequests = []

service.interceptors.response.use(
  (response) => response,
  async (error) => {
    const status = error.response?.status
    const isTokenExpired = error.response?.headers['token-expired'] === 'true'

    if (status === 401 && isTokenExpired) {
      if (isRefreshing) {
        // 已经在刷新 Token 了,把请求挂起等待
        return new Promise((resolve) => {
          pendingRequests.push((token) => {
            error.config.headers.Authorization = `Bearer ${token}`
            resolve(service(error.config))
          })
        })
      }

      isRefreshing = true
      try {
        const refreshToken = localStorage.getItem('refreshToken')
        const { data } = await authApi.refresh(refreshToken)

        localStorage.setItem('token', data.accessToken)

        // 重发所有挂起的请求
        pendingRequests.forEach(cb => cb(data.accessToken))
        pendingRequests = []

        // 重发当前请求
        error.config.headers.Authorization = `Bearer ${data.accessToken}`
        return service(error.config)
      } catch (e) {
        // Refresh Token 也过期了,跳登录
        localStorage.clear()
        window.location.href = '/login'
      } finally {
        isRefreshing = false
      }
    }

    return Promise.reject(error)
  }
)

核心逻辑:Access Token 过期 → 自动用 Refresh Token 换新的 → 重发失败的请求。用户全程无感知。

安全注意事项

  • Refresh Token 要存 Redis 并支持主动失效:用户改密码或登出时删掉 Redis 里的 Refresh Token
  • Access Token 不要存敏感信息:JWT 的 Payload 只是 Base64 编码,任何人都能解码看到内容
  • secret 要够长:至少 256 位,不然可以被暴力破解
  • HTTPS 必须开:Token 在 Header 里传输,HTTP 明文会被中间人截获

总结

JWT 本身很简单,坑在续期策略。双 Token 方案(短 Access + 长 Refresh)+ 前端自动刷新 + 请求队列,可以做到用户无感续期。Refresh Token 存 Redis 支持主动失效,比纯 JWT 的无状态方案更安全。

相关推荐
二月龙3 小时前
App 瘦身实战:图片、so 库、资源压缩,安装包体积减小 40%
后端
大勇前进3 小时前
Kotlin 协程实战避坑:90% 的人都会踩的生命周期泄漏问题
后端
苏三说技术4 小时前
Spring AI、LangChain4j、AgentScope、Embabel,哪个AI框架更好?
后端
烽学长4 小时前
(附源码)基于Springboot+vue的图书阅读分享系统的设计与实现
java·spring boot·后端
isfox4 小时前
Python 单例模式:一个类只能有一个实例,到底怎么实现?
后端
青山木4 小时前
RocketMQ 入门到原理(三):消息存储原理
java·后端·中间件·架构·rocketmq
小白男神4 小时前
MySQL进阶学习三(视图)
后端·mysql
猿人谷4 小时前
Jev:当 AI 不再生成 Token,而是直接做决策
后端·langchain·aigc