前端与 Spring Boot 后端无感 Token 刷新 – 从原理到全栈实践(2026年最佳实践)
在2026年,JWT(JSON Web Token) + Refresh Token 的组合仍是前后端分离架构中最主流的认证方案。其中,“无感 Token 刷新”是指用户在使用过程中,Access Token 过期时,前端自动用 Refresh Token 换取新 Access Token,而不打断用户操作(如跳转登录页)。
这套方案的核心是双 Token 机制 + 前端拦截器,能有效解决 Token 过期问题,同时提升用户体验。下面从原理、后端实现(Spring Boot 3.x)、前端实现(React 示例)、全栈集成实践四个维度系统拆解。所有代码基于2026年主流版本(Spring Boot 3.3+ / React 18+),并注重安全性(如 HTTPS、Secure Cookie)。
一、原理详解(双 Token 机制 + 无感刷新流程)
核心概念
- Access Token:短期有效(e.g., 15~30分钟),用于 API 认证。包含用户ID、角色等信息(JWT 格式)。
- Refresh Token:长期有效(e.g., 7~30天),仅用于刷新 Access Token。存储在 HttpOnly Cookie 中(防 XSS),不暴露给 JS。
- 无感刷新:前端 Axios/Fetch 拦截器检测 401 响应 → 自动用 Refresh Token 请求新 Access Token → 重发原请求。
刷新流程图(文字版)
1. 用户登录 → 后端返回 Access Token (Header) + Refresh Token (HttpOnly Cookie)
2. 前端请求 API → 带 Access Token (Header: Authorization: Bearer xxx)
3. 后端验证 Access Token:
- 有效 → 处理请求
- 过期 → 返回 401 Unauthorized
4. 前端拦截 401 → 检查是否有 Refresh Token (Cookie) → 用 Refresh Token 请求 /refresh API
5. 后端验证 Refresh Token:
- 有效 → 返回新 Access Token + 新 Refresh Token (可选: 滚动刷新)
- 无效 → 返回 401 → 前端跳转登录
6. 前端拿到新 Access Token → 存储 (localStorage 或内存) → 重发原请求
7. 用户无感知,继续操作
优缺点表格(2026年视角)
| 维度 | 优点 | 缺点/风险 | 缓解策略(企业实践) |
|---|---|---|---|
| 安全性 | Refresh Token 不暴露 JS,防 XSS/CSRF | CSRF 攻击可能(Cookie 自动发送) | CSRF Token + SameSite=Strict Cookie |
| 用户体验 | 无需手动登录,操作流畅 | 刷新失败需处理(如网络问题) | 前端加重试机制 + Loading 提示 |
| 性能 | 刷新开销小(O(1) 查询) | 高并发下 Refresh API 压力大 | Redis 缓存 Token + 限流 (Sentinel) |
| 兼容性 | 支持移动端/H5/App | Cookie 跨域问题 | CORS 配置 + 域名一致 |
关键安全点:Refresh Token 存数据库/Redis(带过期时间),Access Token 无状态(JWT 自验证)。避免黑白名单复杂,用 UUID + 用户ID 绑定。
二、后端实现(Spring Boot 3.x + Spring Security 6.x + JWT)
依赖准备(pom.xml 片段)
<dependencies>
<!-- Spring Security + JWT -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version> <!-- 2026主流版 -->
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<!-- Redis(存储 Refresh Token) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies>
配置类(SecurityConfig.java + JwtUtil.java)
// JwtUtil.java - JWT 生成/验证工具
import io.jsonwebtoken.*;
import java.util.Date;
import java.util.UUID;
public class JwtUtil {
private static final String SECRET_KEY = "your-secret-key-2026"; // 生产用环境变量
private static final long ACCESS_EXPIRATION = 30 * 60 * 1000; // 30min
private static final long REFRESH_EXPIRATION = 7 * 24 * 60 * 60 * 1000; // 7天
public static String generateAccessToken(String userId) {
return Jwts.builder()
.subject(userId)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + ACCESS_EXPIRATION))
.signWith(Keys.hmacShaKeyFor(SECRET_KEY.getBytes()))
.compact();
}
public static String generateRefreshToken() {
return UUID.randomUUID().toString(); // 简单UUID,生产加盐
}
public static String getUserIdFromToken(String token) {
return Jwts.parser()
.verifyWith(Keys.hmacShaKeyFor(SECRET_KEY.getBytes()))
.build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
}
public static boolean validateToken(String token) {
try {
getUserIdFromToken(token);
return true;
} catch (Exception e) {
return false;
}
}
}
// SecurityConfig.java - 核心配置
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // 前后分离关闭CSRF,或用CSRF Token
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll() // 登录/刷新公开
.anyRequest().authenticated()
)
.addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); // 自定义JWT过滤器
return http.build();
}
}
自定义JWT过滤器(JwtAuthenticationFilter.java)
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws Exception {
String token = request.getHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);
if (JwtUtil.validateToken(token)) {
String userId = JwtUtil.getUserIdFromToken(token);
// 设置认证上下文
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(userId, null, null);
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
filterChain.doFilter(request, response);
}
}
认证控制器(AuthController.java)
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import java.util.concurrent.TimeUnit;
@RestController
public class AuthController {
@Autowired
private RedisTemplate<String, String> redisTemplate;
// 登录接口
@PostMapping("/api/auth/login")
public ResponseEntity<?> login(@RequestBody LoginRequest req, HttpServletResponse res) {
// 模拟验证用户名密码
if ("user".equals(req.getUsername()) && "pass".equals(req.getPassword())) {
String userId = "user-123"; // 从DB取
String accessToken = JwtUtil.generateAccessToken(userId);
String refreshToken = JwtUtil.generateRefreshToken();
// 存 Refresh Token 到 Redis(key: refreshToken, value: userId, 过期7天)
redisTemplate.opsForValue().set(refreshToken, userId, 7, TimeUnit.DAYS);
// Set Cookie: HttpOnly + Secure + SameSite
Cookie cookie = new Cookie("refreshToken", refreshToken);
cookie.setHttpOnly(true);
cookie.setSecure(true); // HTTPS only
cookie.setPath("/");
cookie.setMaxAge(7 * 24 * 60 * 60);
cookie.setAttribute("SameSite", "Strict");
res.addCookie(cookie);
return ResponseEntity.ok(new TokenResponse(accessToken));
}
return ResponseEntity.status(401).body("Invalid credentials");
}
// 刷新接口
@PostMapping("/api/auth/refresh")
public ResponseEntity<?> refresh(HttpServletRequest req, HttpServletResponse res) {
// 从 Cookie 取 Refresh Token
String refreshToken = null;
Cookie[] cookies = req.getCookies();
if (cookies != null) {
for (Cookie c : cookies) {
if ("refreshToken".equals(c.getName())) {
refreshToken = c.getValue();
break;
}
}
}
if (refreshToken == null) {
return ResponseEntity.status(401).body("No refresh token");
}
// Redis 验证 Refresh Token
String userId = redisTemplate.opsForValue().get(refreshToken);
if (userId != null) {
// 生成新 Access Token
String newAccessToken = JwtUtil.generateAccessToken(userId);
// 可选: 滚动刷新 - 生成新 Refresh Token,删除旧的
String newRefreshToken = JwtUtil.generateRefreshToken();
redisTemplate.delete(refreshToken);
redisTemplate.opsForValue().set(newRefreshToken, userId, 7, TimeUnit.DAYS);
Cookie newCookie = new Cookie("refreshToken", newRefreshToken);
// 同上设置属性
newCookie.setHttpOnly(true);
newCookie.setSecure(true);
newCookie.setPath("/");
newCookie.setMaxAge(7 * 24 * 60 * 60);
newCookie.setAttribute("SameSite", "Strict");
res.addCookie(newCookie);
return ResponseEntity.ok(new TokenResponse(newAccessToken));
}
return ResponseEntity.status(401).body("Invalid refresh token");
}
}
// LoginRequest & TokenResponse 类(简单DTO)
class LoginRequest {
private String username;
private String password;
// getter/setter
}
class TokenResponse {
private String accessToken;
public TokenResponse(String accessToken) { this.accessToken = accessToken; }
// getter
}
后端注意:生产加异常处理、日志、限流。Redis Key 用前缀如 “refresh:” + token。
三、前端实现(React + Axios 示例)
依赖准备(package.json 片段)
"dependencies": {
"axios": "^1.6.0", // 2026主流
"js-cookie": "^3.0.5" // 操作Cookie(但Refresh Token HttpOnly,JS不可读,只需发请求)
}
Axios 拦截器(api.js)
import axios from 'axios';
import Cookies from 'js-cookie';
const api = axios.create({
baseURL: '/api',
withCredentials: true, // 携带Cookie
});
let isRefreshing = false; // 防止并发刷新
let failedQueue = []; // 失败请求队列
const processQueue = (error) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve();
}
});
failedQueue = [];
};
// 请求拦截器:加 Access Token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 响应拦截器:处理 401 无感刷新
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// 已在刷新,加入队列等待
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then(() => api(originalRequest))
.catch((err) => Promise.reject(err));
}
originalRequest._retry = true;
isRefreshing = true;
try {
// 请求刷新(自动带 Cookie 中的 Refresh Token)
const { data } = await api.post('/auth/refresh');
localStorage.setItem('accessToken', data.accessToken);
// 重发队列中所有请求
processQueue(null);
return api(originalRequest);
} catch (refreshError) {
processQueue(refreshError);
localStorage.removeItem('accessToken');
// 跳转登录
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
export default api;
登录组件(Login.jsx 示例)
import React, { useState } from 'react';
import api from './api';
function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const handleLogin = async () => {
try {
const { data } = await api.post('/auth/login', { username, password });
localStorage.setItem('accessToken', data.accessToken);
// 跳转首页
window.location.href = '/home';
} catch (err) {
console.error('登录失败');
}
};
return (
<div>
<input value={username} onChange={(e) => setUsername(e.target.value)} />
<input value={password} onChange={(e) => setPassword(e.target.value)} type="password" />
<button onClick={handleLogin}>登录</button>
</div>
);
}
export default Login;
前端注意:Access Token 存 localStorage(易 XSS),生产可存内存或 IndexedDB。Refresh Token HttpOnly,JS 无需操作。
四、全栈实践 & 测试指南
- 启动后端:Spring Boot 运行,配置 Redis(localhost:6379)。
- 启动前端:React App,代理到后端端口(vite.config.js 加 proxy)。
- 测试流程:
- 登录:POST /api/auth/login → 得 Access Token + Cookie(Refresh Token)
- 访问保护 API:GET /api/protected → 如过期,自动刷新
- 模拟过期:等30min,或手动改 Token
- 常见坑 & 优化:
- 并发刷新:用 isRefreshing + 队列防重复请求。
- 注销:删除 Redis Refresh Token + 清 localStorage + 删 Cookie。
- 移动端:App 用 Secure Storage 存 Token。
- 高级:集成 OAuth2 或 Spring Security OAuth2 Client。
这套方案在2026年已非常成熟,适用于中大型 Web/App 项目。如果你用 Vue/Angular,前端拦截器类似(Vue 用 Axios,Angular 用 HttpInterceptor)。
有具体问题(如代码报错、Vue 版本)?或想加功能(如滑动过期)?告诉我,我继续细化~