分布式系统 Token 续期踩坑实录:自动续期 + 设备绑定这样做才安全

whdahanh 发布于 2025-09-29 188 次阅读


一、方案三:自动续期方案(用户完全无感知)

基础方案需要前端主动调用刷新接口,自动续期则是后端偷偷处理,用户操作时完全没感觉,体验最好,适合 SAAS 应用、前后端分离系统。

1. 核心思路:拦截器 + 滑动窗口

在请求拦截器里判断 Token 剩余时间,快过期时自动刷新,用响应头返回新 Token,前端偷偷替换:

@Component
public class TokenRenewInterceptor implements HandlerInterceptor {
    @Autowired
    private TokenService tokenService;

    // Token总有效期30分钟,剩余时间小于5分钟(或30%有效期)时续期
    private static final long MIN_REMAIN_TIME = 5 * 60 * 1000;
    private static final double REMAIN_RATIO = 0.3;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1. 提取Token(从请求头)
        String token = request.getHeader("Authorization").replace("Bearer ", "");
        if (token == null) {
            throw new SecurityException("Token不能为空");
        }

        // 2. 判断是否需要续期
        if (needRenew(token)) {
            // 3. 自动刷新Token
            String newToken = tokenService.autoRefreshToken(token);
            // 4. 新Token放在响应头,前端偷偷替换
            response.setHeader("X-New-Token", newToken);
        }

        return true;
    }

    // 判断是否需要续期:双阈值策略(绝对时间+相对比例)
    private boolean needRenew(String token) {
        // 解析Token过期时间和总有效期
        Date expireTime = JwtUtil.getExpireTime(token);
        long totalValidTime = JwtUtil.getTotalValidTime(token); // 30*60*1000
        long remainTime = expireTime.getTime() - System.currentTimeMillis();

        // 剩余时间小于5分钟 或 小于总有效期的30%,就需要续期
        return remainTime <= Math.min(MIN_REMAIN_TIME, (long) (totalValidTime * REMAIN_RATIO));
    }
}

前端拿到响应头的X-New-Token后,偷偷替换本地存储的 Token,用户完全没感知 —— 比如正在编辑文档,Token 续期了都不知道,体验拉满。

2. Redis 缓存续期:解决分布式一致性问题

分布式系统里,多个服务都要判断 Token 是否需要续期,用 Redis 缓存 Token 状态,避免每个服务都解析 JWT:

@Service
public class TokenService {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    // Token有效期30分钟,Redis缓存时间和Token一致
    private static final long TOKEN_EXPIRE = 30 * 60;

    // 自动刷新Token(结合Redis缓存)
    public String autoRefreshToken(String oldToken) {
        // 1. 先查Redis缓存,缓存不存在说明Token已失效
        String cacheKey = "token:cache:" + oldToken;
        String cachedToken = redisTemplate.opsForValue().get(cacheKey);
        if (cachedToken == null) {
            throw new SecurityException("Token已失效,请重新登录");
        }

        // 2. 解析用户名,生成新Token
        String username = JwtUtil.parseUsername(oldToken);
        String newToken = JwtUtil.createToken(username, TOKEN_EXPIRE * 1000);

        // 3. 关键:旧Token的缓存Key不变,值换成新Token(保证分布式服务拿到最新Token)
        redisTemplate.opsForValue().set(cacheKey, newToken, TOKEN_EXPIRE, TimeUnit.SECONDS);

        // 4. 旧Token加入黑名单(提前失效)
        String blacklistKey = "token:blacklist:" + oldToken;
        redisTemplate.opsForValue().set(blacklistKey, "invalid", TOKEN_EXPIRE + 5 * 60, TimeUnit.SECONDS);

        return newToken;
    }
}

为什么这么做:微服务 A 和微服务 B 都能通过 Redis 拿到最新 Token,不会出现 “服务 A 续期了,服务 B 还在用旧 Token” 的情况。https://wxa.wxs.qq.com/tmpl/oc/base_tmpl.html

3. Gateway 全局过滤器:微服务统一处理

微服务架构下,在 Gateway 网关加全局过滤器,所有服务的 Token 续期逻辑集中管理,不用每个服务都加拦截器:

@Component
@Order(-100)// 优先级高于其他过滤器
public class TokenRenewGlobalFilter implements GlobalFilter {
    @Autowired
    private TokenService tokenService;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 1. 提取Token(从请求头)
        ServerHttpRequest request = exchange.getRequest();
        String token = request.getHeaders().getFirst("Authorization");
        if (token == null || !token.startsWith("Bearer ")) {
            return chain.filter(exchange); // 无Token,后续过滤器处理
        }
        token = token.replace("Bearer ", "");

        // 2. 判断是否需要续期
        if (needRenew(token)) {
            try {
                // 3. 自动刷新Token
                String newToken = tokenService.autoRefreshToken(token);
                // 4. 新Token放在响应头
                ServerHttpResponse response = exchange.getResponse();
                response.getHeaders().add("X-New-Token", newToken);
            } catch (Exception e) {
                // 续期失败,不影响原请求(原Token可能还有效)
                log.warn("Token续期失败:{}", e.getMessage());
            }
        }

        return chain.filter(exchange);
    }

    // 判断是否需要续期(和拦截器逻辑一致)
    private boolean needRenew(String token) {
        Date expireTime = JwtUtil.getExpireTime(token);
        long totalValidTime = JwtUtil.getTotalValidTime(token);
        long remainTime = expireTime.getTime() - System.currentTimeMillis();
        return remainTime <= Math.min(5 * 60 * 1000, (long) (totalValidTime * 0.3));
    }
}

这样不管是订单服务、用户服务,还是商品服务,都不用管 Token 续期,全由 Gateway 统一处理,代码量减少 80%。

二、分布式环境的特殊挑战:多设备 + 跨服务

1. 多设备会话管理:避免 “注销一个设备,所有设备下线”

用户用手机和电脑同时登录,注销电脑端,手机端不能跟着下线,用 Redis Hash 存储每个用户的设备会话:

@Service
public class DeviceSessionService {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    // 用户登录时,存储设备会话(key:user:{userId}:devices,field:设备类型,value:refresh_token的stateId)
    public void saveDeviceSession(Long userId, String deviceType, String stateId) {
        String hashKe y= "user:" + userId + ":devices";
        // 存储设备对应的stateId(覆盖旧的,同一设备登录会顶掉之前的会话)
        redisTemplate.opsForHash().put(hashKey, deviceType, stateId);
        
        // 设置Hash过期时间(和refresh_token一致,7天)
        redisTemplate.expire(hashKey, 7 * 24 * 60 * 60, TimeUnit.SECONDS);
    }

    // 注销指定设备的会话
    public void logoutDevice(Long userId, String deviceType) {
        String hashKey = "user:" + userId + ":devices";
        // 1. 获取该设备对应的stateId
        String stateId = (String) redisTemplate.opsForHash().get(hashKey, deviceType);
        if (stateId == null) {
            return;
        }

        // 2. 使该设备的refresh_token失效
        String refreshKey = "token:refresh:" + stateId;
        redisTemplate.delete(refreshKey);

        // 3. 从Hash中删除该设备
        redisTemplate.opsForHash().delete(hashKey, deviceType);
    }

    // 强制用户所有设备下线(如改密码后)
    public void logoutAllDevices(Long userId) {
        String hashKey = "user:" + userId + ":devices";
        // 1. 获取所有设备的stateId
        Map<Object, Object> deviceMap = redisTemplate.opsForHash().entries(hashKey);
        
        // 2. 使所有refresh_token失效
        for (Object stateId : deviceMap.values()) {
            String refreshKey = "token:refresh:" + stateId;
            redisTemplate.delete(refreshKey);
        }

        // 3. 删除所有设备会话
        redisTemplate.delete(hashKey);
    }
}
https://wxa.wxs.qq.com/tmpl/oc/base_tmpl.html

比如用户在电脑端注销,只会删除 “PC” 设备对应的会话,手机端的 “Mobile” 设备不受影响,体验更友好。

2. 跨服务令牌验证:本地缓存 + 认证中心

多服务间验证 Token,每次都查 Redis 或认证中心会影响性能,用本地缓存做一层快速验证:

@Service
public class CrossServiceTokenValidator {
    @Autowired
    private AuthCenterClient authCenterClient; // 认证中心Feign客户端
    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    // Caffeine本地缓存:存储最近10000个有效Token,5分钟过期
    private final LoadingCache<String, Boolean> tokenLocalCache = Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(5, TimeUnit.MINUTES)
            .build(key -> {
                // 缓存未命中时,查Redis或认证中心
                String redisKey = "token:valid:" + key;
                if (Boolean.TRUE.equals(redisTemplate.hasKey(redisKey))) {
                    return true;
                }
                // Redis没查到,查认证中心(最终数据源)
                return authCenterClient.validateToken(key);
            });

    // 跨服务验证Token
    public boolean validateTokenAcrossService(String token) {
        try {
            // 1. 先查本地缓存(快)
            return tokenLocalCache.get(token);
        } catch (Exception e) {
            // 2. 缓存查不到,直接查认证中心(兜底)
            return authCenterClient.validateToken(token);
        }
    }
}

本地缓存能挡住 80% 的验证请求,只有 20% 的请求会查 Redis 或认证中心,性能提升明显 —— 之前做 SAAS 系统,没加本地缓存时,Token 验证 QPS 只有 500,加了之后能到 5000。

三、5 种 Token 续期方案全对比(该怎么选?)

方案安全性用户体验实现复杂度适用场景性能影响
单 Token 基础版★☆☆☆☆★★☆☆☆内部测试系统
单 Token + 黑名单★★☆☆☆★★★☆☆企业内网、短期活动
双 Token 基础版★★★☆☆★★★★☆常规 Web/APP
双 Token + 三验证★★★★★★★★☆☆金融、支付系统
自动续期 + Gateway★★★★☆★★★★★SAAS 应用、微服务、高体验要求中高

选型口诀

  • 安全第一(金融 / 支付):双 Token + 三验证 + 设备绑定
  • 体验第一(SAAS/APP):自动续期 + Gateway + 本地缓存
  • 简单快速(内部系统):单 Token + 黑名单
  • 分布式系统:必加 Gateway 全局处理 + 本地缓存

四、避坑指南:这 5 个坑千万别踩

  1. access_token 有效期太长
    最长不要超过 30 分钟,越长风险越高 —— 之前见过有人设 2 小时,结果 Token 被劫持,攻击者用了 1 个多小时才被发现。
  2. refresh_token 可重复使用
    每次刷新必须生成新的 refresh_token,旧的立即失效,否则被劫持后能无限刷新。
  3. 没处理设备变更
    手机登录后,突然在电脑上用同一个 refresh_token 刷新,必须要求重新验证(如短信验证码),防止 Token 被盗用。
  4. 并发刷新没加锁
    高峰期多个请求同时刷新,会生成多个有效 Token,用 Redisson 分布式锁控制,只能有一个请求成功。
  5. 敏感操作不二次验证
    付款、改密码等敏感操作,不能只验证 Token,还要加短信 / 验证码二次验证:
// 敏感操作二次验证
public void processPayment(String token, String smsCode) {
    // 1. 验证Token(基础)
    if (!tokenService.validateToken(token)) {
        throw new SecurityException("Token失效");
    }
    // 2. 验证短信验证码(二次验证)
    Long userId = JwtUtil.parseUserId(token);
    if (!smsService.verifyCode(userId, smsCode)) {
        throw new SecurityException("短信验证码错误");
    }
    // 3. 执行付款操作
    paymentService.pay(userId);
}

最后:好的 Token 续期像 “自主神经系统”

用户平时感受不到它的存在,但在 Token 快过期时,会自动续期;在设备变更时,会默默验证;在敏感操作时,会严格把关。

从单 Token 到双 Token,再到自动续期,核心不是技术多复杂,而是平衡安全、体验和性能 —— 不要为了安全牺牲体验,也不要为了体验忽略安全。



微信扫描下方的二维码阅读本文

此作者没有提供个人介绍
最后更新于 2025-09-29