双 Token 刷新 + Redis 黑名单登出 完整实现

前沿

设计token 模式有 2种 一种是单token, 还有一种是双token

一、整体设计思路

1.1 为什么需要双 Token?

单个 Access Token 存在矛盾:

f7319939-c937-4e9e-8071-1c66d4f3d060

 解决方案:

a31ec817-d460-4dab-af02-5a420acc4b3a

1.2 为什么需要 Redis 黑名单?

JWT 是无状态的,签发后服务端无法主动失效。要实现"退出登录立即生效",需要借助 Redis:

  • 登出时:把当前 token 的 jti(唯一ID)或 token 本身写入 Redis 黑名单,TTL = token 剩余有效期

  • 请求时:拦截器先查 Redis,如果在黑名单里 → 拒绝

💡 Redis 的 key 会随 TTL 自动过期,不需要手动清理,非常优雅

 

二、改造 JwtConfig(新增字段)

  就是之前的单token 改写的方法

  yaml 文件目录下:

jwt:
  secret: MySecretKeyForJWT2025!@#$%^&*()_+SecureKeyHere
  expiration: 86400000            # Access Token: 24小时
  refresh-expiration: 604800000   # Refresh Token: 7天
  header: Authorization
  token-prefix: "Bearer "
  issuer: mental-health-assistant # 新增:签发者统一
  blacklist-prefix: "jwt:blacklist:"  # 新增:黑名单 key 前缀
  refresh-prefix: "jwt:refresh:"      # 新增:refresh token 存储前缀

config 文件目录

@Data
@Component
@ConfigurationProperties(prefix = "jwt")
public class JwtConfig {
    private String secret;
    private long expiration;
    private long refreshExpiration;
    private String header;
    private String tokenPrefix;
    private String issuer;              // 新增
    private String blacklistPrefix;     // 新增
    private String refreshPrefix;       // 新增
}

三、改造 JwtUtil —— 支持双 Token + jti

package org.example.aispingboot.util;

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.auth0.jwt.interfaces.DecodedJWT;
import org.example.aispingboot.config.JwtConfig;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;

import java.util.Date;
import java.util.UUID;

@Component
public class JwtUtil implements ApplicationContextAware {

    private static ApplicationContext ctx;

    // Token 类型标识
    public static final String TYPE_ACCESS  = "access";
    public static final String TYPE_REFRESH = "refresh";

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) {
        ctx = applicationContext;
    }

    private static JwtConfig cfg() {
        return ctx.getBean(JwtConfig.class);
    }

    /**
     * 生成 Access Token
     */
    public static String generateAccessToken(String userId, String username, Integer roleType) {
        return buildToken(userId, username, roleType, TYPE_ACCESS, cfg().getExpiration());
    }

    /**
     * 生成 Refresh Token
     */
    public static String generateRefreshToken(String userId, String username) {
        return buildToken(userId, username, null, TYPE_REFRESH, cfg().getRefreshExpiration());
    }

    /**
     * 统一构建
     */
    private static String buildToken(String userId, String username,
                                     Integer roleType, String type, long expireMillis) {
        JwtConfig c = cfg();
        Algorithm algorithm = Algorithm.HMAC256(c.getSecret());
        Date now = new Date();
        Date exp = new Date(now.getTime() + expireMillis);

        return JWT.create()
                .withJWTId(UUID.randomUUID().toString())   // 唯一ID,用于黑名单
                .withClaim("userId", userId)
                .withClaim("username", username)
                .withClaim("roleType", roleType)
                .withClaim("type", type)                   // 区分 access / refresh
                .withIssuedAt(now)
                .withExpiresAt(exp)
                .withIssuer(c.getIssuer())
                .sign(algorithm);
    }

    /**
     * 校验并解析(返回 DecodedJWT,失败抛异常)
     */
    public static DecodedJWT verify(String token) {
        JwtConfig c = cfg();
        Algorithm algorithm = Algorithm.HMAC256(c.getSecret());
        JWTVerifier verifier = JWT.require(algorithm)
                .withIssuer(c.getIssuer())
                .build();
        return verifier.verify(token);
    }

    /**
     * 安全校验(返回 null 表示无效,不抛异常)
     */
    public static DecodedJWT verifyQuietly(String token) {
        try {
            return verify(token);
        } catch (Exception e) {
            return null;
        }
    }

    // ========== 便捷取值 ==========
    public static String getUserId(DecodedJWT jwt)     { return jwt.getClaim("userId").asString(); }
    public static String getUsername(DecodedJWT jwt)   { return jwt.getClaim("username").asString(); }
    public static Integer getRoleType(DecodedJWT jwt)  { return jwt.getClaim("roleType").asInt(); }
    public static String getType(DecodedJWT jwt)       { return jwt.getClaim("type").asString(); }
    public static String getJti(DecodedJWT jwt)        { return jwt.getId(); }
    public static Date   getExpiresAt(DecodedJWT jwt)  { return jwt.getExpiresAt(); }
}

四、Redis 配置

4.1 依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

4.2 yml

spring:
  data:
    redis:
      host: localhost
      port: 6379
      database: 0
      # password: xxx
      timeout: 3000ms

4.3 RedisTemplate 序列化配置

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);

        StringRedisSerializer keySer = new StringRedisSerializer();
        GenericJackson2JsonRedisSerializer valSer = new GenericJackson2JsonRedisSerializer();

        template.setKeySerializer(keySer);
        template.setHashKeySerializer(keySer);
        template.setValueSerializer(valSer);
        template.setHashValueSerializer(valSer);
        template.afterPropertiesSet();
        return template;
    }
}

五、Token 服务层(核心逻辑)

把"生成、存储、刷新、登出"都收敛到一个 Service。

 

package org.example.aispingboot.service;

import com.auth0.jwt.interfaces.DecodedJWT;
import lombok.Data;
import org.example.aispingboot.config.JwtConfig;
import org.example.aispingboot.util.JwtUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.Date;
import java.util.concurrent.TimeUnit;

@Service
public class TokenService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    private JwtConfig jwtConfig;

    /**
     * 登录成功后签发双 Token
     */
    public TokenPair issueTokens(String userId, String username, Integer roleType) {
        String accessToken  = JwtUtil.generateAccessToken(userId, username, roleType);
        String refreshToken = JwtUtil.generateRefreshToken(userId, username);

        // 把 refreshToken 存入 Redis,key = jwt:refresh:{userId}
        // 用途:登出时删除;刷新时校验是否为最新签发的那个
        redisTemplate.opsForValue().set(
                jwtConfig.getRefreshPrefix() + userId,
                refreshToken,
                jwtConfig.getRefreshExpiration(),
                TimeUnit.MILLISECONDS
        );

        TokenPair pair = new TokenPair();
        pair.setAccessToken(accessToken);
        pair.setRefreshToken(refreshToken);
        pair.setTokenType("Bearer");
        pair.setExpiresIn(jwtConfig.getExpiration() / 1000); // 秒
        return pair;
    }

    /**
     * 用 Refresh Token 换取新的 Access Token
     */
    public TokenPair refresh(String refreshToken) {
        // 1. 校验签名 + 过期
        DecodedJWT jwt = JwtUtil.verifyQuietly(refreshToken);
        if (jwt == null) {
            throw new RuntimeException("Refresh Token 无效或已过期");
        }

        // 2. 校验类型
        if (!JwtUtil.TYPE_REFRESH.equals(JwtUtil.getType(jwt))) {
            throw new RuntimeException("Token 类型错误");
        }

        // 3. 校验 Redis 中是否一致(防止旧 refresh token 被复用)
        String userId = JwtUtil.getUserId(jwt);
        Object stored = redisTemplate.opsForValue().get(jwtConfig.getRefreshPrefix() + userId);
        if (stored == null || !stored.toString().equals(refreshToken)) {
            throw new RuntimeException("Refresh Token 已失效,请重新登录");
        }

        // 4. 生成新 Access Token(不轮换 refresh,简化版)
        String username = JwtUtil.getUsername(jwt);
        String newAccess = JwtUtil.generateAccessToken(userId, username, null);

        TokenPair pair = new TokenPair();
        pair.setAccessToken(newAccess);
        pair.setRefreshToken(refreshToken);   // 原样返回
        pair.setTokenType("Bearer");
        pair.setExpiresIn(jwtConfig.getExpiration() / 1000);
        return pair;
    }

    /**
     * 登出:把 Access Token 加入黑名单 + 删除 Refresh Token
     */
    public void logout(String accessToken) {
        DecodedJWT jwt = JwtUtil.verifyQuietly(accessToken);
        if (jwt == null) return;

        // 1. Access Token 加入黑名单,TTL = 剩余有效期
        String jti = JwtUtil.getJti(jwt);
        Date exp = JwtUtil.getExpiresAt(jwt);
        long ttl = exp.getTime() - System.currentTimeMillis();
        if (ttl > 0) {
            redisTemplate.opsForValue().set(
                    jwtConfig.getBlacklistPrefix() + jti,
                    "1",
                    ttl,
                    TimeUnit.MILLISECONDS
            );
        }

        // 2. 删除 Redis 中的 refresh token
        String userId = JwtUtil.getUserId(jwt);
        redisTemplate.delete(jwtConfig.getRefreshPrefix() + userId);
    }

    /**
     * 判断某个 jti 是否在黑名单中
     */
    public boolean isBlacklisted(String jti) {
        Boolean exists = redisTemplate.hasKey(jwtConfig.getBlacklistPrefix() + jti);
        return Boolean.TRUE.equals(exists);
    }

    // ========== DTO ==========
    @Data
    public static class TokenPair {
        private String accessToken;
        private String refreshToken;
        private String tokenType;
        private Long expiresIn;
    }
}

 

六、认证 Controller

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    @Autowired private UserService userService;
    @Autowired private TokenService tokenService;
    @Autowired private JwtConfig jwtConfig;

    /**
     * 登录
     */
    @PostMapping("/login")
    public Result<TokenService.TokenPair> login(@RequestBody LoginDTO dto) {
        User user = userService.findByUsername(dto.getUsername());
        if (user == null || !passwordEncoder.matches(dto.getPassword(), user.getPassword())) {
            return Result.error("用户名或密码错误");
        }
        TokenService.TokenPair pair = tokenService.issueTokens(
                String.valueOf(user.getId()),
                user.getUsername(),
                user.getRoleType()
        );
        return Result.success(pair);
    }

    /**
     * 刷新 Access Token
     */
    @PostMapping("/refresh")
    public Result<TokenService.TokenPair> refresh(@RequestBody RefreshDTO dto) {
        try {
            TokenService.TokenPair pair = tokenService.refresh(dto.getRefreshToken());
            return Result.success(pair);
        } catch (Exception e) {
            return Result.error(401, e.getMessage());
        }
    }

    /**
     * 登出
     */
    @PostMapping("/logout")
    public Result<Void> logout(HttpServletRequest request) {
        String header = request.getHeader(jwtConfig.getHeader());
        if (header != null && header.startsWith(jwtConfig.getTokenPrefix())) {
            String token = header.substring(jwtConfig.getTokenPrefix().length());
            tokenService.logout(token);
        }
        return Result.success();
    }

    @Data
    public static class RefreshDTO {
        private String refreshToken;
    }
}

七、改造拦截器(加入黑名单校验)

@Component
public class JwtInterceptor implements HandlerInterceptor {

    @Autowired private JwtConfig jwtConfig;
    @Autowired private TokenService tokenService;

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) throws Exception {

        if (!(handler instanceof HandlerMethod)) return true;

        // 1. 取 Header
        String authHeader = request.getHeader(jwtConfig.getHeader());
        if (authHeader == null || !authHeader.startsWith(jwtConfig.getTokenPrefix())) {
            return writeUnauthorized(response, "缺少 Token");
        }
        String token = authHeader.substring(jwtConfig.getTokenPrefix().length());

        // 2. 校验签名 + 过期
        DecodedJWT jwt = JwtUtil.verifyQuietly(token);
        if (jwt == null) {
            return writeUnauthorized(response, "Token 无效或已过期");
        }

        // 3. 校验类型(必须是 access)
        if (!JwtUtil.TYPE_ACCESS.equals(JwtUtil.getType(jwt))) {
            return writeUnauthorized(response, "Token 类型错误");
        }

        // 4. 校验黑名单(登出后的 token 拒绝)
        if (tokenService.isBlacklisted(JwtUtil.getJti(jwt))) {
            return writeUnauthorized(response, "Token 已失效,请重新登录");
        }

        // 5. 塞入上下文
        UserContext.set(new LoginUser(
                JwtUtil.getUserId(jwt),
                JwtUtil.getUsername(jwt),
                JwtUtil.getRoleType(jwt)
        ));
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest req, HttpServletResponse resp,
                                Object handler, Exception ex) {
        UserContext.clear();
    }

    private boolean writeUnauthorized(HttpServletResponse response, String msg) throws IOException {
        response.setStatus(401);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("{\"code\":401,\"message\":\"" + msg + "\"}");
    }
}

八、完整时序图

8.1 登录

客户端                     服务端                    Redis
  │                          │                        │
  │ POST /api/auth/login     │                        │
  │─────────────────────────>│                        │
  │                          │ 校验密码                │
  │                          │ 生成 access + refresh  │
  │                          │ SET refresh:{userId}   │
  │                          │───────────────────────>│
  │ {accessToken,            │                        │
  │  refreshToken,           │                        │
  │  expiresIn}              │                        │
  │<─────────────────────────│                        │

8.2 业务请求

客户端                     拦截器                     Redis
  │                          │                        │
  │ GET /api/xxx             │                        │
  │ Header: Bearer access    │                        │
  │─────────────────────────>│                        │
  │                          │ 校验签名+过期           │
  │                          │ 查黑名单 jti           │
  │                          │───────────────────────>│
  │                          │<──────不存在───────────│
  │                          │ 塞入 UserContext       │
  │                          │ 放行                   │
  │ 200 OK                   │                        │
  │<─────────────────────────│                        │

8.3 Access Token 过期 → 刷新

客户端                     服务端                    Redis
  │                          │                        │
  │ 请求 → 401 过期           │                        │
  │<─────────────────────────│                        │
  │                          │                        │
  │ POST /api/auth/refresh   │                        │
  │ {refreshToken}           │                        │
  │─────────────────────────>│                        │
  │                          │ 校验签名+类型+过期       │
  │                          │ 对比 Redis 中存的值     │
  │                          │───────────────────────>│
  │                          │<──────一致─────────────│
  │                          │ 生成新 access          │
  │ {accessToken, ...}       │                        │
  │<─────────────────────────│                        │
  │                          │                        │
  │ 用新 access 重试业务请求  │                        │
  │─────────────────────────>│                        │

8.4 登出

客户端                     服务端                    Redis
  │                          │                        │
  │ POST /api/auth/logout    │                        │
  │ Header: Bearer access    │                        │
  │─────────────────────────>│                        │
  │                          │ SET blacklist:{jti}    │
  │                          │   TTL=剩余有效期         │
  │                          │───────────────────────>│
  │                          │ DEL refresh:{userId}   │
  │                          │───────────────────────>│
  │ 200 OK                   │                        │
  │<─────────────────────────│                        │
  │                          │                        │
  │ 再带旧 access 请求 → 401  │                        │
  │<─────────────────────────│                        │

 

九、前端配合(Axios 自动刷新)

前端要做的核心:401 时自动用 refreshToken 换新 access,然后重放原请求。

 

import axios from 'axios'

const request = axios.create({ baseURL: '/api', timeout: 10000 })

// 是否正在刷新 token
let isRefreshing = false
let pendingQueue = []   // 挂起的请求队列

request.interceptors.request.use(config => {
  const token = localStorage.getItem('accessToken')
  if (token) config.headers.Authorization = `Bearer ${token}`
  return config
})

request.interceptors.response.use(
  res => res.data,
  async error => {
    const { response, config } = error

    // 非 401 或已重试过,直接抛出
    if (response?.status !== 401 || config._retry) {
      return Promise.reject(error)
    }

    const refreshToken = localStorage.getItem('refreshToken')
    if (!refreshToken) {
      // 无 refreshToken,跳登录
      localStorage.clear()
      location.href = '/login'
      return Promise.reject(error)
    }

    // 已经在刷新中 → 挂起排队
    if (isRefreshing) {
      return new Promise(resolve => {
        pendingQueue.push(newToken => {
          config.headers.Authorization = `Bearer ${newToken}`
          config._retry = true
          resolve(request(config))
        })
      })
    }

    // 开始刷新
    isRefreshing = true
    config._retry = true
    try {
      const { data } = await axios.post('/api/auth/refresh', { refreshToken })
      const newAccess = data.accessToken
      localStorage.setItem('accessToken', newAccess)

      // 唤醒队列
      pendingQueue.forEach(cb => cb(newAccess))
      pendingQueue = []

      // 重放当前请求
      config.headers.Authorization = `Bearer ${newAccess}`
      return request(config)
    } catch (e) {
      localStorage.clear()
      location.href = '/login'
      return Promise.reject(e)
    } finally {
      isRefreshing = false
    }
  }
)

export default request

 

十:遇到的问题 :

Q1:Refresh Token 要不要轮换(Rotation)?

推荐轮换(更安全):每次刷新都签发新的 refresh token,旧的作废。改造 TokenService.refresh:

// 5. 生成新的 refresh token 并覆盖 Redis
String newRefresh = JwtUtil.generateRefreshToken(userId, username);
redisTemplate.opsForValue().set(
        jwtConfig.getRefreshPrefix() + userId,
        newRefresh,
        jwtConfig.getRefreshExpiration(),
        TimeUnit.MILLISECONDS
);
pair.setRefreshToken(newRefresh);

这样即使 refresh token 泄露,攻击者用过一次后真实用户再用会失败(可感知被盗)。

Q2:黑名单用 jti 还是整个 token?

  • jti:更短,Redis key 小,推荐 ✅

  • 整个 token:无需生成 jti,但 key 很长

Q3:Redis 挂了怎么办?

  • 降级方案:黑名单查不到就放行(安全性略降,但可用)

  • 或者用本地 Caffeine 缓存兜底

Q4:多设备登录

如果希望"一个账号只能在一处登录",可以:

  • key 用 jwt:refresh:{userId} → 新登录会覆盖旧的,旧 refresh 失效

  • 若允许多设备,key 改成 jwt:refresh:{userId}:{deviceId}

Q5:并发刷新

多个请求同时 401,都去刷新 → 前端用 isRefreshing + 队列 解决(见上面代码)。后端也可以加锁:

// 刷新时用 Redis 分布式锁防止并发
String lockKey = "jwt:refresh:lock:" + userId;
Boolean locked = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (!Boolean.TRUE.equals(locked)) {
    throw new RuntimeException("刷新过于频繁,请稍后重试");
}
try {
    // ... 刷新逻辑
} finally {
    redisTemplate.delete(lockKey);
}

十一、总结对照表

6581a081-d2d6-4bfd-a4b2-28a2fbd55889

 

posted @ 2026-09-26 10:49  -鹿-  阅读(4)  评论(0)    收藏  举报