双 Token 刷新 + Redis 黑名单登出 完整实现
前沿
设计token 模式有 2种 一种是单token, 还有一种是双token
一、整体设计思路
1.1 为什么需要双 Token?
单个 Access Token 存在矛盾:

解决方案:

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:
这样即使 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); }
十一、总结对照表


浙公网安备 33010602011771号