JWT 双 Token 续期 + 多设备登录 + 强制撤销:生产级 Spring Boot 实现

JWT 真做生产级登录态时坑特别多:Access/Refresh 双 token 续期、RT 轮转、复用检测、强制登出、改密码失效、多设备管理。本文给完整 Spring Boot + Redis 方案,附拦截器 + 黑名单 + user_version 双保险 + 前端拦截 + 8 条反模式。

JWT 看着简单,真做生产级登录态时坑特别多:Access Token 过期怎么续?Refresh Token 怎么轮转?用户登出后怎么真的"失效"那个还没过期的 JWT?多设备登录怎么管?本文把这些问题一次性讲清楚,附 Spring Boot + Redis 完整实现。

JWT 的"无状态"是个 trap

JWT 推销的时候总说"无状态、可扩展、不需要存储"。这是真的,但只对"短期 token"成立。生产里几乎所有需求都打这个前提的脸:

需求                              JWT 原生能力     需要外部状态?
=====================================================
登录态过期                         exp 字段           不需要
强制登出(token 立即失效)          ❌ 做不到          需要 blacklist
管理员封禁用户                     ❌ 做不到          需要查 DB
多设备登录列表                     ❌ 做不到          需要存 session
强制改密码后旧 token 失效          ❌ 做不到          需要 user_version

结论:生产 JWT 必然带 Redis,严格说不是"无状态认证",是"减少 session 查询的优化"。

Access Token + Refresh Token 双 token 模型

核心代码

@Service
public class JwtService {
    @Value("${jwt.secret}") private String secret;
    private final long AT_TTL = 15 * 60 * 1000L;        // 15 分钟
    private final long RT_TTL = 7 * 24 * 3600 * 1000L;  // 7 天

    @Autowired private StringRedisTemplate redis;

    public TokenPair login(Long userId, String deviceId) {
        String at = generateAccessToken(userId, deviceId);
        String rt = generateRefreshToken(userId, deviceId);

        // 存 Refresh Token 到 Redis,key 包含 deviceId,支持多设备
        String key = "rt:" + userId + ":" + deviceId;
        redis.opsForValue().set(key, rt, RT_TTL, TimeUnit.MILLISECONDS);

        return new TokenPair(at, rt);
    }

    private String generateAccessToken(Long userId, String deviceId) {
        long now = System.currentTimeMillis();
        return Jwts.builder()
            .setSubject(String.valueOf(userId))
            .claim("did", deviceId)
            .claim("typ", "at")
            .claim("ver", getUserVersion(userId))     // 用户版本号,改密码会 +1
            .setIssuedAt(new Date(now))
            .setExpiration(new Date(now + AT_TTL))
            .signWith(SignatureAlgorithm.HS256, secret)
            .compact();
    }

    private String generateRefreshToken(Long userId, String deviceId) {
        // RT 用 UUID,不签 JWT,方便 revoke
        return UUID.randomUUID().toString().replace("-", "");
    }

    public TokenPair refresh(String oldRT, String deviceId) {
        // 通过 RT 反查 userId(需要在 Redis 用 hash 存反向索引)
        String userIdStr = redis.opsForValue().get("rt_to_uid:" + oldRT);
        if (userIdStr == null) throw new InvalidTokenException("RT not found or expired");
        Long userId = Long.parseLong(userIdStr);

        // 校验 RT 是这个设备的
        String storedRT = redis.opsForValue().get("rt:" + userId + ":" + deviceId);
        if (!oldRT.equals(storedRT)) {
            // RT 不一致:要么被偷了在异地用,要么是过期的旧 RT
            // 安全策略:撤销该设备的所有 RT,强制重新登录
            revokeDevice(userId, deviceId);
            throw new InvalidTokenException("RT mismatch");
        }

        // 生成新 AT + 新 RT(轮转)
        String newAT = generateAccessToken(userId, deviceId);
        String newRT = generateRefreshToken(userId, deviceId);
        redis.opsForValue().set("rt:" + userId + ":" + deviceId, newRT, RT_TTL, TimeUnit.MILLISECONDS);
        redis.opsForValue().set("rt_to_uid:" + newRT, String.valueOf(userId), RT_TTL, TimeUnit.MILLISECONDS);
        redis.delete("rt_to_uid:" + oldRT);
        return new TokenPair(newAT, newRT);
    }
}

JWT 校验拦截器

@Component
public class JwtFilter extends OncePerRequestFilter {
    @Autowired private StringRedisTemplate redis;
    @Autowired private UserService userService;

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp, FilterChain chain)
            throws ServletException, IOException {
        String auth = req.getHeader("Authorization");
        if (auth == null || !auth.startsWith("Bearer ")) {
            chain.doFilter(req, resp);
            return;
        }
        String token = auth.substring(7);
        try {
            Claims claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
            Long userId = Long.parseLong(claims.getSubject());
            String deviceId = claims.get("did", String.class);
            Integer tokenVer = claims.get("ver", Integer.class);

            // 校验 1: 黑名单(强制登出)
            if (Boolean.TRUE.equals(redis.hasKey("blacklist:" + token))) {
                throw new InvalidTokenException("token revoked");
            }

            // 校验 2: 用户版本号(改密码 / 账号封禁后 +1)
            Integer currentVer = userService.getUserVersion(userId);
            if (!Objects.equals(tokenVer, currentVer)) {
                throw new InvalidTokenException("token version stale");
            }

            // 注入 SecurityContext
            UsernamePasswordAuthenticationToken authToken =
                new UsernamePasswordAuthenticationToken(userId, null, List.of());
            SecurityContextHolder.getContext().setAuthentication(authToken);
        } catch (ExpiredJwtException e) {
            resp.setStatus(401);
            resp.getWriter().write("{\"error\":\"token_expired\"}");
            return;
        } catch (Exception e) {
            resp.setStatus(401);
            resp.getWriter().write("{\"error\":\"invalid_token\"}");
            return;
        }
        chain.doFilter(req, resp);
    }
}

强制登出(立即失效)

@Service
public class LogoutService {
    @Autowired private StringRedisTemplate redis;

    public void logout(String accessToken, Long userId, String deviceId) {
        // 把 AT 加入黑名单,有效期=AT 剩余时间
        Claims claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(accessToken).getBody();
        long remainMs = claims.getExpiration().getTime() - System.currentTimeMillis();
        if (remainMs > 0) {
            redis.opsForValue().set("blacklist:" + accessToken, "1", remainMs, TimeUnit.MILLISECONDS);
        }

        // 撤销该设备的 RT
        String rtKey = "rt:" + userId + ":" + deviceId;
        String rt = redis.opsForValue().get(rtKey);
        if (rt != null) {
            redis.delete("rt_to_uid:" + rt);
            redis.delete(rtKey);
        }
    }

    public void logoutAllDevices(Long userId) {
        // 把用户版本号 +1,所有 AT 失效
        userService.incrementUserVersion(userId);

        // 撤销所有设备的 RT
        Set<String> keys = redis.keys("rt:" + userId + ":*");
        if (keys != null) keys.forEach(redis::delete);
    }
}

RT 轮转 + 重放检测

用户的 RT 应该每次 refresh 都换(rotation)。如果用旧 RT 来 refresh,说明这个 RT 可能被人窃取了同时被两边用:

public TokenPair refreshWithReuseDetection(String oldRT, String deviceId) {
    String key = "rt:" + userId + ":" + deviceId;
    String currentRT = redis.opsForValue().get(key);

    if (currentRT == null) {
        throw new InvalidTokenException("RT expired");
    }
    if (!oldRT.equals(currentRT)) {
        // RT 用过的 / 被替换的 → 极有可能是攻击者拿了快照
        // 立刻撤销所有设备(安全派),或者只撤销该设备(便利派)
        revokeAllDevices(userId);
        log.error("RT REUSE DETECTED uid={} deviceId={}", userId, deviceId);
        throw new SecurityException("RT reuse detected, all sessions revoked");
    }
    // ... 正常轮转 ...
}

Web 端 token 存哪里(安全)

存储位置                   优点                    缺点
========================================================
localStorage              简单                   XSS 可读,不推荐
sessionStorage            页面关闭就丢            XSS 可读
内存(JS 变量)             刷新就丢,需 RT 续      最安全 + 体验最好,推荐
httpOnly cookie           XSS 读不到              CSRF 风险,需要 SameSite
secureFlag cookie         强制 HTTPS              同上
// 推荐方案:AT 在内存,RT 在 httpOnly cookie
// 后端 /login 返回:
res.cookie('rt', refreshToken, {
    httpOnly: true,
    secure: true,           // 只在 HTTPS 下传
    sameSite: 'strict',     // 防 CSRF
    maxAge: 7 * 24 * 3600 * 1000
});
res.json({ access_token: at });   // AT 在 body 里,前端存内存

// 前端 axios 拦截器:401 自动 refresh
axios.interceptors.response.use(null, async error => {
    if (error.response?.status === 401 && !error.config._retried) {
        error.config._retried = true;
        const { data } = await axios.post('/auth/refresh');   // cookie 自动带 RT
        store.setAccessToken(data.access_token);
        error.config.headers.Authorization = `Bearer ${data.access_token}`;
        return axios(error.config);    // 重放原请求
    }
    return Promise.reject(error);
});

常见反模式

反模式 1:把敏感信息塞 JWT payload
   JWT 只是 base64 编码,不是加密。手机号 / 邮箱 / 角色都能被前端解析。
   ✅ 只放 userId,其他字段去 DB / Redis 拉

反模式 2:AT 设很长(比如 30 天)
   AT 一旦泄露,30 天内都能用。
   ✅ AT 短(15min - 1h),RT 长(7-30 天)+ 可撤销

反模式 3:用 RSA / ES256 但还是签发后只对称校验
   性能差还没意义。微服务网关签发用 RSA 私钥,后端服务用公钥校验才合理
   ✅ 单体应用 HS256 够,微服务网关用 RS256 / ES256

反模式 4:登出只删 cookie 不拉黑 token
   token 还是有效的,只是浏览器不带了。攻击者抓包之前的 token 还能用。
   ✅ 必须服务端拉黑

反模式 5:RT 不轮转
   一个 RT 用 7 天,被偷了攻击者用一周
   ✅ 每次 refresh 都换新 RT

多设备管理 + "我的设备"列表

@Service
public class DeviceService {
    @Autowired private StringRedisTemplate redis;

    public void recordDevice(Long userId, String deviceId, DeviceInfo info) {
        String key = "user_devices:" + userId;
        // hash 结构: deviceId -> info_json
        redis.opsForHash().put(key, deviceId, JSON.toJSONString(info));
        redis.expire(key, 30, TimeUnit.DAYS);
    }

    public List<DeviceInfo> listDevices(Long userId) {
        Map<Object, Object> all = redis.opsForHash().entries("user_devices:" + userId);
        return all.entrySet().stream()
            .map(e -> {
                DeviceInfo d = JSON.parseObject(e.getValue().toString(), DeviceInfo.class);
                d.setDeviceId(e.getKey().toString());
                d.setActive(redis.hasKey("rt:" + userId + ":" + d.getDeviceId()));
                return d;
            })
            .collect(Collectors.toList());
    }

    public void revokeDevice(Long userId, String deviceId) {
        String rtKey = "rt:" + userId + ":" + deviceId;
        redis.delete(rtKey);
        redis.opsForHash().delete("user_devices:" + userId, deviceId);
    }
}

性能注意点

操作                  耗时    备注
=========================================
JWT 签名 HS256       0.05ms   纯 CPU 操作,无 IO
JWT 验签 HS256       0.05ms   同上
Redis 黑名单查询     0.2ms    每个请求一次
Redis 用户版本查询    0.2ms    可以本地缓存 5s 减少
DB 查用户角色        2-10ms   建议缓存

优化:用户版本号在 JVM 内缓存 5 秒,降低 Redis QPS 50%

JWT 选型总结

  1. 登录态必须双 token,AT 短 RT 长
  2. RT 必须轮转 + 复用检测
  3. 必须支持服务端撤销(黑名单 + user_version 双保险)
  4. 敏感信息不进 payload
  5. Web 端 RT 用 httpOnly + SameSite,AT 在内存
  6. App 端 RT 用 keychain / keystore,AT 在内存
  7. 多设备管理用 deviceId 维度的 RT
  8. 所有 token 操作要有审计日志

这套方案我们维护了 3 年多,期间出过 token 泄露事件被复用检测捕获,出过用户改密码后还能用旧 token 的 bug(就是没做 user_version),都通过这个体系迭代解决。JWT 是个看起来简单实际很深的话题,值得花一周认真做扎实。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

缓存穿透击穿雪崩三件套实战:从 5000 QPS 崩溃到 p99 180ms

2026-5-19 11:22:07

技术教程

FastAPI 单实例 QPS 上不去:asyncio 隐性阻塞的 5 个真实坑

2026-5-19 11:26:00

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索