文章目录

双 Token 原理概述
- Access Token:短期有效(如 30 分钟),用于接口鉴权,存放用户身份与权限信息。
- Refresh Token:长期有效(如 7 天),仅用于在 Access Token 过期后“换取”新的两个 Token。
- 无感刷新:用户在 Access Token 过期后,客户端自动携带 Refresh Token 请求刷新,服务器验证 Refresh Token 后下发新的 Access/Refresh Token,用户无须重新登录。
登录与颁发流程
-
客户端提交用户名/密码 → 服务端验证。
-
验证通过后:
- 创建 Access Token,设置 30 分钟过期;
- 创建 Refresh Token,设置 7 天过期;
-
将两者封装返回给客户端:
{ "accessToken": "eyJhbGci…", "accessExpire": 162…, "refreshToken": "eyJhbGc…", "refreshExpire": 162… }
访问拦截与刷新流程
接口访问
-
客户端在每次请求
Authorization: Bearer <accessToken>。 -
服务端拦截器解析 Access Token:
- 未过期 → 正常放行;
- 已过期 → 返回特定状态码 401/511。
无感刷新
-
客户端拦截到 401/511 → 同步或异步调用刷新接口
GET /auth/refresh Authorization: Bearer <refreshToken> -
服务端验证 Refresh Token:
- 合法且未过期 → 重新生成 Access & Refresh Token 并返回;
- 非法或已过期 → 返回 403 → 客户端跳转登录。
-
客户端更新本地存储中的两个 Token,并重试原请求。
Fake Code
过滤器(Gateway 或 Spring MVC)
@Component
public class AuthFilter implements Filter {
@Autowired private JwtUtil jwt;
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
String auth = request.getHeader("Authorization");
if (auth != null && auth.startsWith("Bearer ")) {
String token = auth.substring(7);
if (!jwt.isValid(token)) {
// Access Token 过期,返回 511
response.setStatus(511);
return;
}
}
chain.doFilter(req, res);
}
}
刷新控制器
@RestController
@RequestMapping("/auth")
public class AuthController {
@Autowired private JwtUtil jwt;
@PostMapping("/refresh")
public ResponseEntity<?> refresh(@RequestHeader("Authorization") String auth) {
String refresh = auth.substring(7);
if (!jwt.isValidRefreshToken(refresh)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
UserInfo info = jwt.parseRefreshToken(refresh);
String newAccess = jwt.createAccessToken(info);
String newRefresh = jwt.createRefreshToken(info);
return ResponseEntity.ok(Map.of(
"accessToken", newAccess,
"accessExpire", jwt.getExpire(newAccess),
"refreshToken", newRefresh,
"refreshExpire", jwt.getExpire(newRefresh)
));
}
}
客户端 Axios 拦截
axios.interceptors.response.use(
res => res,
async err => {
if (err.response?.status === 511) {
const refresh = sessionStorage.getItem("refreshToken")!;
const resp = await axios.post("/auth/refresh", null, {
headers: { Authorization: `Bearer ${refresh}` }
});
const { accessToken, refreshToken } = resp.data;
sessionStorage.setItem("accessToken", accessToken);
sessionStorage.setItem("refreshToken", refreshToken);
// 重试原请求
err.config.headers["Authorization"] = "Bearer " + accessToken;
return axios(err.config);
}
return Promise.reject(err);
}
);
安全性与性能考虑
- 防重放:Refresh Token 一旦使用即作废,服务端持久化黑名单或单次使用策略。
- 漏洞防护:将 Refresh Token 存放在 HttpOnly Cookie 中,避免 XSS 泄露。
- 并发刷新:客户端应保证同一时刻只有一次刷新调用,避免请求风暴。
- 性能监控:统计刷新接口调用量,异常时及时报警。
深入思考
无感刷新 Token(Silent Refresh)指在用户不感知的情况下,自动更新访问令牌以维持登录状态。本质是用短生命周期的 Access Token 做日常鉴权,用长期有效的 Refresh Token 去刷新 Access Token。要实现无感刷新需要解决以下核心问题:
- 1:刷新逻辑放在客户端还是服务端?
- 2:Access Token 过期后,如何得知过期时间?
- 3:刷新后如何将原请求继续发送并返回调用方?
方案分类
V1 客户端实现
初始版本
思路:每次请求时由 Gateway 过滤器拦截,检测 Access Token 是否过期;过期则返回自定义状态码(如 511),前端拦截到 511 后发起刷新请求,再重发原请求。
Fake Code : Gateway 过滤器(Spring Cloud Gateway)
@Component
public class CustomAccessFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String token = JWTHelper.getToken(exchange.getRequest().getHeaders().getFirst(TOKEN_HEADER));
if (token != null && JWTHelper.isOutDate(token)) {
// 不是刷新接口,则返回 511
if (!exchange.getRequest().getURI().getPath().equals(REFRESH_URI)) {
return ResponseUtils.out(exchange, ResultData.fail(511, "Need Refresh"));
}
}
return chain.filter(exchange);
}
// ...
}
判断过期使用 jjwt 解析异常捕捉:
public static boolean isOutDate(String token) {
try {
Date exp = Jwts.parser()
.setSigningKey(signKey)
.parseClaimsJws(token)
.getBody().getExpiration();
return exp.before(new Date());
} catch (JwtException e) {
return true;
}
}
Axios 响应拦截器
service.interceptors.response.use(
resp => resp.data.data,
async error => {
const status = error.response?.status;
if (status === 511) {
const newToken = await refresh();
if (newToken) {
window.sessionStorage.setItem('token', newToken);
error.config.headers['Authorization'] = 'Bearer ' + newToken;
return service(error.config);
}
}
return Promise.reject(error);
}
);
刷新方法 refresh() 需绕过自身拦截器,直接用原生 Axios 发请求,防止死循环。
问题
- 刷新是异步的,原请求已经结束,第二次请求结果无法传回调用处。
- 页面上一秒发多次请求时,还可能并发触发多次刷新。
V2 改进版本:定时器提前刷新
思路:在用户登录后启动定时器,周期性检查 Access Token 剩余时长,当低于阈值时(如 1 分钟),提前异步刷新。
定时器类
export class MyTimer {
private timerId: any = null;
start(delay: number, minCheck: number) {
this.timerId = setInterval(async () => {
const token = sessionStorage.getItem('token');
const exp = getExpirationTime(token!);
if (exp - Date.now() <= minCheck) {
await refresh();
}
}, delay);
}
stop() { clearInterval(this.timerId); }
}
function getExpirationTime(token: string): number {
return jwtDecode<{ exp: number }>(token).exp * 1000;
}
在登录成功后启动:
login().then(data => {
sessionStorage.setItem('token', data.token);
const timer = new MyTimer();
timer.start(30_000, 60_000);
});
问题与思考
- 前端依赖
jwt-decode库解析,与后端生成库可能不兼容,导致解码失败。 - 依赖前端解析不够安全、可靠。
V3最终版本:服务端携带过期时间 + 全局单例定时器
服务器端修改
在颁发 Token 时,同时返回其过期时间戳:
String token = ...;
Date exp = JWTHelper.getExpirationDate(token);
map.put("token", token);
map.put("tokenExpire", exp.getTime());
map.put("refreshToken", refreshToken);
单例定时器实现
class MyTimer {
private timerId: any = null;
private delay = 30_000;
private minCheck = 60_000;
private static instance: MyTimer;
static getInstance() {
return this.instance || (this.instance = new MyTimer());
}
start() {
this.timerId = setInterval(async () => {
const exp = +sessionStorage.getItem('tokenExpire' )!;
if (exp - Date.now() <= this.minCheck) {
try { await refresh(); }
catch {
sessionStorage.clear();
window.location.href = '/auth/login';
}
}
}, this.delay);
}
stop() { clearInterval(this.timerId); }
}
export const tokenMonitor = MyTimer.getInstance();
在每个页面渲染时调用,确保定时器不中断:
window.addEventListener('load', () => tokenMonitor.stop(), tokenMonitor.start());
服务器端实现(集中式)
另一种思路是在 Gateway 端完成刷新并重写原请求:
Mono<Boolean> refreshed = WebClient.create()
.get().uri(authServer + "/refresh?...").retrieve()
.bodyToMono(Boolean.class)
.doOnNext(ok -> { if (ok) exchange.getRequest()
.mutate().header("Authorization", newToken).build(); })
.map(ok -> ok);
if (refreshed.block()) {
return chain.filter(exchange);
}
优势:隐藏刷新逻辑,客户端无需感知。缺陷:复杂度高,需要同步阻塞或重构 Reactive 逻辑。
方案对比与选型建议
| 方案 | 优势 | 劣势 |
|---|---|---|
| 客户端响应拦截刷新 | 简单、灵活 | 异步时难以回传原请求结果 |
| 客户端定时器刷新 | 连续性好、无感 | 需前端维护定时器,兼容性和安全问题 |
| 服务端集中式刷新 | 隐藏复杂度、安全 | 实现复杂、需要拦截并重写请求 |
选型建议
- 高安全、高一致性:优先考虑服务端集中式刷新。
- 简单 Web 应用/移动端:客户端定时器方案足够,并可分担服务器压力。
总结与最佳实践
- 统一返回:服务端在颁发 Token 时携带过期时间,前端直接使用该字段,无需解码。
- 全局单例:客户端定时器使用单例模式,全局统一管理,防止多实例冲突。
- 页面无感:在页面加载时启动监控,确保跨路由无缝刷新。
- 异常降级:刷新失败应清理状态并跳回登录,以免死循环。
- 性能监控:可统计刷新调用次数并报警,避免频繁刷新带来额外压力。

基于双Token实现无感刷新的方案解析

被折叠的 条评论
为什么被折叠?



