前端与 Spring Boot 后端的无感知 Token 刷新:原理与全栈实践探究

🌷 古代能成就伟大事业的人,不仅有超凡的才能,也必定有坚韧不拔的意志
🎐 个人CSND主页——Micro麦可乐的博客
前端与 Spring Boot 后端无感知 Token 刷新 – 从原理到全栈实践
- 1. 前言
- 2. 为何需要无感知刷新
- 3 无感知刷新原理
- 3.1 无感知刷新流程
- 3.2 关键技术要点
- 4、前端实现
- 5. 后端实现
- 5.1 基础依赖(pom.xml)
- 5.2 数据库与实体(可选的用户存储)
- 5.3 Redis 存储 Refresh Token
- 5.4 JWT 工具类
- 5.5 刷新服务
- 5.6 控制器Controller
- 5.7 JWT 验证过滤器
- 6. 结语
1. 前言
在我们采用前后端分离架构的应用中,常用的身份认证方案是基于 JWT
(JSON Web Token )。在保证安全性的同时,短生命周期的 Access Token
会带来用户频繁登录的不便。为了解决这个问题,我们引入 Refresh Token
并结合无感知刷新 机制,让客户端在 Access Token
过期时能够自动刷新,无需用户手动重新登录,从而极大地提升用户体验。大家可以通过本文快速掌握无感知 Token
刷新的原理以及具体实现方式。
2. 为何需要无感知刷新
在基于Token
的用户认证系统中,通常会设计两种Token
:
Access Token :用于访问资源,有效期较短(一般为15-30分钟)
Refresh Token :用于获取新的Access Token
,有效期较长(一般为7天)
传统的Token机制存在两大痛点:
频繁强制用户退出 :当
Access Token
过期时,用户需要重新登录
安全隐患 :延长Access Token
的有效期会增加安全风险
无感知刷新解决了这些问题:
以用户体验为优先
将Access Token的有效期设置得较短(例如5-15分钟),如果不进行自动刷新,用户的登录状态会频繁过期,导致用户被迫“重新登录”,体验很差
平衡安全与性能
短生命周期的Access Token能够降低被截获滥用的风险
结合有效期较长的Refresh Token(相对),可以在安全和便捷之间找到一个良好的平衡点
实现前后端解耦
通过前端拦截器统一处理过期的场景,无需在各个业务请求中分散重复的逻辑
后端专注提供刷新接口和失效策略,无需关心前端的具体实现细节
3 无感知刷新原理
3.1 无感知刷新流程

3.2 关键技术要点
双 Token 机制
Access Token :短期有效,携带用户身份和权限信息
Refresh Token :长期有效,专门用于换取新的 Access Token
拦截与重试
1、前端在每次
API
请求中携带Access Token
;
2、如果响应状态为401 Unauthorized
(或后端自定义的过期状态码),前端拦截器会自动调用刷新token接口,使用Refresh Token
获取新的一对 Token;
3、获取成功后,前端重新发起之前失败的原始请求,用户不会察觉到这个过程。
后端安全策略
将 Refresh Token
存储到 Redis
中,并在刷新时进行一次性或者滑动过期的校验;旧的 Refresh Token
在刷新后会失效,防止被非法盗用。
4、前端实现
下面以 Axios
为例来演示拦截器的逻辑。我们可以将 Tokens
存储在 localStorage
中(为了方便演示,这里使用这种方式,实际项目中可以考虑更安全的 HttpOnly Cookie
)
// auth.js
import axios from 'axios';
// 创建基本的 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
标记防止无限循环;
刷新失败后,需要清除本地的登录状态并跳转到登录页面。
5. 后端实现
5.1 基础依赖(pom.xml)
<dependencies>
<!-- Spring Boot 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>
5.2 数据库与实体(可选的用户存储)
这里简单模拟用户,仅包含用户名和密码
-- 用户表(简化版)
CREATE TABLE user_account (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL
);
5.3 Redis 存储 Refresh Token
我们使用 Redis 的 String 类型,Key 为 refresh:{userId}
,Value 存储 JSON 格式的数据 { token, expireTime }
5.4 JWT 工具类
// JwtUtil.java
@Component
public class JwtUtil {
@Value("${jwt.secret}") private String secret;
@Value("${jwt.access.expire}") private long accessExpire; // 毫秒
@Value("${jwt.refresh.expire}") private long refreshExpire; // 毫秒
// 生成短期的 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();
}
}
5.5 刷新服务
// 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. 将 Refresh Token 存储到 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. 解析 Refresh Token
Claims claims = jwtUtil.parseToken(refreshToken);
Long userId = Long.parseLong(claims.getSubject());
// 2. 校验 Redis 中的 Refresh Token
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 中的 Refresh Token
redis.opsForValue().set(key, newRefresh, jwtUtil.getRefreshExpire(), TimeUnit.MILLISECONDS);
return new Tokens(newAccess, newRefresh);
}
}
5.6 控制器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;
}
5.7 JWT 验证过滤器
由于验证不是本文的重点,大家可以参考博主的《Spring Security》专栏进行学习,这里仅提供思路:在每次请求拦截时,解析 Access Token
并将用户信息放入 SecurityContext
,如果过期则交由前端的刷新逻辑来处理。
6. 结语
本文详细介绍了无感知 Token 刷新的核心原理,以及前端 Axios
拦截器和后端 Spring Boot + MyBatis-Plus + Redis
的完整示例代码。通过双 Token、Redis 校验和拦截重试的方式,能够在保证安全性的同时,为用户提供无感登录过期刷新的体验。
后续可以进行如下优化:
- Refresh Token 滑动过期 :每次刷新时延长其有效期;
- Refresh Token 一次性使用 :每个旧的 Refresh Token 只能使用一次;
- 前端多 tab 协调 :在同域下可以共享刷新状态,避免重复刷新;
- 安全加固 :结合 IP、UA 等进行风控,防止 Token 被盗用。
希望本文能够帮助大家快速在项目中实现无感知刷新方案,如果你在实践过程中有任何疑问或者更好的扩展思路,欢迎在评论区留言,最后希望大家多多支持博主!
