sprirng Boot 实现token无感刷新
背景
在前后端分离项目中,常用的身份识别认证方案是基于JWT(JSON WEB TOKEN)
。在保证安全性的同时,短生命周期Access token 又会带来频繁登录的痛点体验,为了解决这一问题,引入了Refresh Token
并结合 无感刷新
机制,让客户端在Access Token
过期时自动刷新,而无需用户手动重新登录,从而提升用户体验感
为什么要无感刷新
在基于Token
的用户认证系统中,通常会设计两种Token:
Access Token:用于访问资源,有效期短(通常15-30分钟)
Refresh Token:用于获取新Access Token
,有效期长(通常7天)
传统Token机制存在两大痛点:
频繁强制退出:
Access Token
过期时用户需重新登录
安全隐患:延长Access Token
有效期会增加安全风险
解决的问题:
用户体验优先:
Access Token 自动过期时间通常设置很短,若不自动刷新,登录状态就会频繁过期,用户被迫"重新登录",体验极差
安全与性能平衡:
短生命周期的 Access Token 能减少被截获滥用的风险
结合 Refresh Token(相对较长有效期),可以在安全与便捷间找到最佳点
前后端解耦:
通过前端拦截器统一处理过期场景,无须在各业务请求中散落重复逻辑
后端专注提供刷新接口与失效策略,无需关心前端实现细节
无感刷新原理
无感刷新流程
关键技术点
双 Token 机制
-
Access Token:短时有效,携带用户身份和权限
-
Refresh Token:长期有效,专用于换取新的 Access Token
拦截与重试
- 前端在每次 API 请求中携带 Access Token;
- 若响应为 401 Unauthorized(或后端自定义过期码),前端拦截器自动调用刷新token接口,用 Refresh Token 获取新一对 Token;
- 获取成功后,前端重新发起失败的原始请求,用户无感知。
后端安全策略
- 将 Refresh Token 写入 Redis,并在刷新时做一次性或者滑动过期(可选)校验;
- 旧 Refresh Token 刷新后失效,防止被盗用。
前端实现
下面以 Axios
为例演示拦截器逻辑。我们将 Tokens
保存在 localStorage
或者更安全的 [HttpOnly Cookie
] 中(此处示例用 localStorage 方便演示)
// auth.js
import axios from 'axios';
// Base Axios 实例
const api = axios.create({
baseURL: '/api',
});
// Token 存取
function getAccessToken() { return localStorage.getItem('access_token'); }
function getRefreshToken() { return localStorage.getItem('refresh_token'); }
function setTokens({ accessToken, refreshToken }) {
localStorage.setItem('access_token', accessToken);
localStorage.setItem('refresh_token', refreshToken);
}
// 请求拦截:自动附带 Access Token
api.interceptors.request.use(config => {
const token = getAccessToken();
if (token) config.headers['Authorization'] = `Bearer ${token}`;
return config;
});
// 响应拦截:遇到 401 刷新并重试
let isRefreshing = false;
let subscribers = [];
function onRefreshed(newToken) {
subscribers.forEach(cb => cb(newToken));
subscribers = [];
}
function addSubscriber(cb) {
subscribers.push(cb);
}
api.interceptors.response.use(
res => res,
error => {
const { config, response } = error;
if (response && response.status === 401 && !config._retry) {
if (isRefreshing) {
// 正在刷新,加入队列
return new Promise(resolve => {
addSubscriber(token => {
config.headers['Authorization'] = `Bearer ${token}`;
resolve(api(config));
});
});
}
config._retry = true;
isRefreshing = true;
// 调用刷新接口
return api.post('/auth/refresh', { refreshToken: getRefreshToken() })
.then(res => {
const { accessToken, refreshToken } = res.data;
setTokens({ accessToken, refreshToken });
isRefreshing = false;
onRefreshed(accessToken);
// 重试原请求
config.headers['Authorization'] = `Bearer ${accessToken}`;
return api(config);
})
.catch(err => {
// 刷新失败,跳转登录
isRefreshing = false;
window.location.href = '/login';
return Promise.reject(err);
});
}
return Promise.reject(error);
}
);
export default api;
要点说明
isRefreshing
和subscribers
用于解决多个并发401
时只发送一次刷新请求;
_retry
标记避免无限循环;
刷新失败后,需清除本地登录态并跳转到登录页。
后端实现
-
添加依赖
<dependencies> <!-- Spring Boot Starter Web --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis-Plus --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <!-- Redis --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- JWT --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> </dependencies>
-
数据库与实体(存储用户可选)
这里就简单模拟用户,仅有用户名和密码为例
-- 用户表(简化) CREATE TABLE user_account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL, password VARCHAR(255) NOT NULL );
-
Redis 存储 Refresh Token
我们用 ·Redis· 的 String,Key 为
refresh:{userId}
,Value 存 JSON{ token, expireTime }
-
JWT工具类
// JwtUtil.java @Component public class JwtUtil { @Value("${jwt.secret}") private String secret; @Value("${jwt.access.expire}") private long accessExpire; // ms @Value("${jwt.refresh.expire}") private long refreshExpire; // ms // 生成 Access Token(短期) public String generateAccessToken(Long userId) { return Jwts.builder() .setSubject(userId.toString()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + accessExpire)) .signWith(Keys.hmacShaKeyFor(secret.getBytes())) .compact(); } // 生成 Refresh Token(长期) public String generateRefreshToken(Long userId) { return Jwts.builder() .setSubject(userId.toString()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + refreshExpire)) .signWith(Keys.hmacShaKeyFor(secret.getBytes())) .compact(); } // 解析 Token public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(secret.getBytes()) .build() .parseClaimsJws(token) .getBody(); } }
-
刷新服务
// AuthService.java @Service public class AuthService { @Autowired private JwtUtil jwtUtil; @Autowired private StringRedisTemplate redis; public Tokens login(String username, String password) { // 1. 验证用户名密码(略,用 MyBatis-Plus 查询) Long userId = /* ... */; // 2. 生成双 Token String accessToken = jwtUtil.generateAccessToken(userId); String refreshToken = jwtUtil.generateRefreshToken(userId); // 3. 保存到 Redis String key = "refresh:" + userId; redis.opsForValue().set(key, refreshToken, jwtUtil.getRefreshExpire(), TimeUnit.MILLISECONDS); return new Tokens(accessToken, refreshToken); } public Tokens refresh(String refreshToken) { // 1. 解析 Claims claims = jwtUtil.parseToken(refreshToken); Long userId = Long.parseLong(claims.getSubject()); // 2. Redis 校验 String key = "refresh:" + userId; String cached = redis.opsForValue().get(key); if (cached == null || !cached.equals(refreshToken)) { throw new RuntimeException("Refresh Token 无效或已过期"); } // 3. 生成新 Token String newAccess = jwtUtil.generateAccessToken(userId); String newRefresh = jwtUtil.generateRefreshToken(userId); // 4. 覆盖 Redis redis.opsForValue().set(key, newRefresh, jwtUtil.getRefreshExpire(), TimeUnit.MILLISECONDS); return new Tokens(newAccess, newRefresh); } }
-
控制器Controller
// AuthController.java @RestController @RequestMapping("/api/auth") public class AuthController { @Autowired private AuthService authService; @PostMapping("/login") public Tokens login(@RequestBody LoginReq req) { return authService.login(req.getUsername(), req.getPassword()); } @PostMapping("/refresh") public Tokens refresh(@RequestBody Map<String,String> body) { return authService.refresh(body.get("refreshToken")); } } // DTOs @Data class LoginReq { private String username, password; } @Data @AllArgsConstructor class Tokens { private String accessToken; private String refreshToken; }
-
JWT 验证过滤器
由于验证并非本文的重点,小伙伴们可以参考博主的 《Spring Security》专栏学习,这里仅提供思路:
在每次请求拦截中,解析Access Token
并将用户信息放入SecurityContext
,若过期则交由前端刷新逻辑处理。