发布于2026-05-29 阅读(0)
扫一扫,手机访问
Token无感刷新,简单说,就是让用户在无感知的情况下,自动把访问令牌(Token)更新一下,维持住登录状态。听起来挺美,但真正动手做起来,坑不少。
常见的做法是:用短期的token做权限认证,再配一个更长期的refreshToken专职负责刷新。但这条路走下来,总会撞上几个经典问题:
下面针对这些问题,梳理一下可以参考的思路。
思路是这样:每次客户端发请求,服务端的gateway先拦下来,判断token是否过期。过期了,返回一个特定的状态码(比如511)通知客户端;没过期,放行,正常走业务逻辑。
前端那边呢,拦截响应状态码,根据码值决定下一步动作。于是,axios拦截器就登场了。
环境是SpringBoot3 + Ja va17,通过继承GlobalFilter来实现过滤逻辑。
@Component
public class MyAccessFilter implements GlobalFilter, Ordered
{
@Override
public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String uri = request.getURI().getPath();
HttpMethod method = request.getMethod();
// OPTION直接放行
if(method.matches(HttpMethod.OPTIONS.name()))
return chain.filter(exchange);
// 登录请求直接放行
if(SecurityAccessConstant.REQUEST_LOGGING_URI.equals(uri) && method.matches(HttpMethod.POST.name()))
return chain.filter(exchange);
// 获取token
String token = JWTHelper.getToken(request.getHeaders().getFirst(SecurityAccessConstant.HEADER_NAME_TOKEN));
if(null != token){
// 判断token是否过时
if(!JWTHelper.isOutDate(token)){
return chain.filter(exchange);
} else {
if(!SecurityAccessConstant.REQUEST_REFRESH.equals(uri)) // 当前不是刷新请求,返回511
return ResponseUtils.out(exchange , ResultData.fail(ResultCodeEnum.NEED_TO_REFRESH_TOKEN.getCode(),
ResultCodeEnum.NEED_TO_REFRESH_TOKEN.getMessage()));
// 当前是刷新请求,但refreshToken都过期了
return ResponseUtils.out(exchange , ResultData.fail(ResultCodeEnum.RC401.getCode(), ResultCodeEnum.RC401.getMessage()));
}
}
return ResponseUtils.out(exchange , ResultData.fail(ResultCodeEnum.RC401.getCode(), ResultCodeEnum.RC401.getMessage()));
}
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE;
}
}
1.1.1.1 解答Q2:怎么获取过期时间?
正常情况下解析token会报错。所以在解析的时候截获异常,如果catch到JwtException,就认为token无效过期了,返回true。否则正常解析,取出过期时间和当前时间比较。
// 判断当前token是否过期
public static boolean isOutDate(String token){
try {
Jws claimsJws = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token);
Date expirationDate = claimsJws.getBody().getExpiration();
return expirationDate.before(new Date());
} catch (JwtException e) {
return true;
}
}
在拦截器里,根据响应码做判断。401就清空用户数据,跳到登录页;511就用refreshToken去刷新。
// 响应拦截器
service.interceptors.response.use(
function(response) {
console.log(response)
return response.data.data;
},
async function(error) {
if(error.response == undefined)
return Promise.reject(error);
const status = error.response.status
const authStore = useAuthStore()
let message = ''
switch(status){
case 401:
authStore.reset()
window.sessionStorage.removeItem('isAuthenticated')
window.sessionStorage.removeItem('token')
window.sessionStorage.removeItem('refreshToken')
message = 'token 失效,请重新登录'
window.location.href = '/auth/login';
break;
case 511:
try {
const data = refresh()
if(data !== null){
data.then((value) => {
if(value !== ''){
console.log("刷新 token 成功", value);
window.sessionStorage.setItem("token",value)
error.config.headers['Authorization'] = 'Bearer ' +value;
return service(error.config);
}
console.log(value);
}).catch((error) => {
console.error(error);
});
}
} catch (err) {
console.log("请求刷新 token 失败", err);
router.push("/login");
}
break;
case '403':
message = '拒绝访问'
break;
case '404':
message = '请求地址错误'
break;
case '500':
message = '服务器故障'
break;
default:
message = '网络连接故障'
}
Message.error(message)
return Promise.reject(error);
}
);
这里直接用axios原声发异步请求,而不是用request.ts里导出的方法。因为那个方法自带请求拦截器,每次请求前都会自动带上token——但这里要刷新token,老token已经失效了,再用它就会死循环。
/** * 刷新token * 成功返回新token,失败返回空字符串'' */ export async function refresh() : Promise{ const refreshToken = window.sessionStorage.getItem("refreshToken") console.log("in >>> " ,refreshToken) if(refreshToken == undefined) return '' try { const response = await axios({ method: 'GET', url: 'http://127.0.0.1:9001/api/simple/cloud/access/refresh', headers: { Authorization: `Bearer ${refreshToken}`, }, }); if (response.data) { return response.data.data; } else { return ''; } } catch (error) { console.log(error); return ''; } }
代码里塞了不少console.log,跑起来能更直观地看流程。结合测试结果一起分析。
下面的截图是正常情况。注意这里token以hizFIGg结尾,refreshToken以suvm-EgQ结尾。正常返回就是200 OK。

再看看异常情况。因为token太长拆了两张图。从左图开始分析:
in >>>进入refresh方法,成功打出refreshToken,仍是suvm-EgQ结尾。左图分析完毕,进入右图:
V0dYcMA结尾;refreshToken仍是suvm-EgQ(说明refreshToken只用来刷新,本身不刷新)。getAllUser方法已经结束,返回的结果就是那个511 error。而refresh是异步调用,它的执行穿插在里面。

最终结果里,没有出现之前“>>>>>”那部分输出。用新token发第二次请求得到的数据,已经无法返回到原来的getAllUser方法调用处——因为原方法早就结束了。通俗点说,这个二次调用结果毫无意义,用户还是得手动刷新页面或再点一次。
这就是Q3的核心问题:异步调用导致原方法提前终止。如果改成阻塞式调用,响应会变慢,影响用户体验。有更好的想法欢迎讨论。
既然异步路子走不通,换个思路如何:不在失败时去刷新,而是提前检查本地存储的token有没有过期。如果发现token剩余时间小于某个临界点,就主动异步调用刷新方法,更新本地token。这样一来,只要服务端gateway拦截到token失效的请求,直接要求重新登录就行。关键点在于引入一个定时器。
在TypeScript里,定时器主要通过setInterval和setTimeout实现。这里用setInterval,按指定间隔重复执行某段逻辑。
当setInterval被调用时,它从被调用那一刻开始计时,每隔指定毫秒数就执行一次。所以这个定时器应该在登录成功后启动。
通过这个类,MyTimer.start方法启动setInterval,每隔delay毫秒检查一次,判断当前token剩余时间是否小于minCheck,如果小于,就异步刷新。
import { refresh } from "@/api/system/auth/index"
import { jwtDecode } from "jwt-decode";
export class MyTimer {
private timerId: any | null = null;
start(delay: number, minCheck : number): void {
this.timerId = setInterval(async () => {
const currentToken = window.sessionStorage.getItem('token');
console.log("timer++++")
if (currentToken) {
let expirationTime = 0;
expirationTime = getExpirationTime(currentToken);
const timeRemaining = expirationTime - Date.now();
if (timeRemaining <= minCheck) {
await refresh();
}
} else {
await refresh();
}
}, delay);
}
stop(): void {
if (this.timerId !== null) {
clearInterval(this.timerId);
this.timerId = null;
}
}
}
function getExpirationTime(rawToken:string) : number{
const res = jwtDecode(rawToken)
return res.exp as number
}
只关注新增的定时器部分,其他是权限和token的常规存储逻辑。
import { MyTimer } from "@/utils/tokenMonitor"
const submit = () => {
if (validate()) {
login(formData)
.then((data: UserInfoRes) => {
if (data) {
const token = data.token;
const authStore = useAuthStore()
authStore.setToken(token)
window.sessionStorage.setItem('token', token)
window.sessionStorage.setItem('refreshToken', data.refreshToken)
authStore.setIsAuthenticated(true)
window.sessionStorage.setItem('isAuthenticated', 'true')
authStore.setName(data.name)
authStore.setButtons(data.buttons)
authStore.setRoles(data.roles)
authStore.setRouters(data.routers)
// 引入计时器
const clock = new MyTimer();
clock.start(1000*30,1000*30);
init({ message: "logged in success", color: 'success' });
push({ name: 'dashboard' })
}
})
.catch(() => {
init({ message: "logged in fail , please check carefully!", color: '#FF0000' });
});
} else {
Message.error('error submit!!')
return false
}
}
照理说应该没问题,能正确解析token。但一跑就报错了:
InvalidTokenError: Invalid token specified: invalid json for part #2
换成jwt.verify()用密钥解码也不行,页面都加载不出来。

翻了不少资料后总结出原因:后端token是用jjwt库生成的,不同库的底层编码实现有差异,导致a库加密的token无法被b库解密。
找到了病根,解决方案就清晰了:要么用与jjwt逻辑一致的库来解码,要么换个思路——服务端下发token时,直接把过期时间也带上。前端省去解码这一步,问题迎刃而解。下面引出最终版本。
1.3.1.1 获取token过期时间
// 获取当前token过期时间,不判断是否过期(因为进来时已经判断过了)
public static Date getExpirationDate(String token) {
if(StringUtil.isBlank(token))
return null;
Claims claims = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token).getBody();
return claims.getExpiration();
}
1.3.1.2 发放token时携带过期时间
// 存放token到请求头中
String[] tokenArray = JWTHelper.createToken(sysUser.getId(), sysUser.getEmail(), permsList);
map.put("token",tokenArray[0]);
// 新增设置过期时间(毫秒数)
map.put("tokenExpire",JWTHelper.getExpirationDate(tokenArray[0]).getTime());
map.put("refreshToken",tokenArray[1]);
同样,在refreshToken接口返回时也带上新的过期时间。
最终版本的类包含三个属性:timerId(定时器唯一ID)、delay(执行间隔)、minCheck(过期时间阈值)。采用单例模式,全局导出一份实例便于管理。token的过期时间直接从服务端获取,和当前时间比较即可。
import { refresh } from "@/api/system/auth/index"
class MyTimer {
private timerId: any | null = null;
private delay: number;
private minCheck: number;
private static instance: MyTimer;
public static getInstance(): MyTimer {
if (!MyTimer.instance) {
MyTimer.instance = new MyTimer();
}
return MyTimer.instance;
}
private constructor() {
this.delay = 30000;
this.minCheck = 60000;
}
start(): void {
this.timerId = setInterval(async () => {
const currentToken = window.sessionStorage.getItem('token');
console.log("timer++++",currentToken)
if (currentToken) {
const tokenExpireStr = window.sessionStorage.getItem('tokenExpire') as string
const expirationTime = parseInt(tokenExpireStr, 10);
const timeRemaining = expirationTime - Date.now();
console.log("ttime sub++++",timeRemaining)
if (timeRemaining <= this.minCheck) {
try{
await refresh();
} catch (error) {
console.error('刷新失败:', error);
window.sessionStorage.removeItem('isAuthenticated')
window.sessionStorage.removeItem('token')
window.sessionStorage.removeItem('refreshToken')
Message.error("token refresh got some problem, please login")
window.location.href = '/auth/login';
}
}
} else {
Message.error("token invalidate, please login")
window.location.href = '/auth/login';
}
}, this.delay);
console.log(this.timerId)
}
stop(): void {
if (this.timerId !== null) {
clearInterval(this.timerId);
this.timerId = null;
}
}
setDelay(delay: number): void {
this.delay = delay;
}
setMinCheck(minCheck: number): void {
this.minCheck = minCheck;
}
}
export const myFilterInstance = MyTimer.getInstance();
export function onPageRender(){
myFilterInstance.stop();
myFilterInstance.start();
}
注意最后这个方法。测试中发现,通过导航栏切换页面时,定时器会被kill掉,导致token无法刷新,新请求会报错。因此最好在每个页面挂载时调用onPageRender,重启定时器,确保token一直有“人”照看。
import { onPageRender } from '@/utils/tokenMonitor'
window.addEventListener('load', () => {
onPageRender();
});
最终的测试结果如下(可以结合代码中的输出语句来看):
一旦小于阈值(这里设置1分钟 = 60000ms),就进入refresh逻辑,和上面讲的一样。这样就能保证每次请求携带的token大概率都是最新的。客户端实现的思路到这里就完整讲完了。

这种思路是:在gateway拦截token时,如果发现过期,就通过WebClient携带refreshToken异步请求认证服务器获取新token。下面的代码实现了发起请求到获取数据的过程,但没有实现原请求的再发送(这个坑后续再填)。
MononewTokenMono = WebClient.create().get() .uri(buildUri(SecurityAccessConstant.WEB_REQUEST_TO_AUTH_URL+SecurityAccessConstant.REQUEST_REFRESH , new String[]{"refreshToken", token})) .retrieve() .bodyToMono(ResultData.class); AtomicBoolean isPass = new AtomicBoolean(false); newTokenMono.subscribe(resultData -> { if(resultData.getCode() == "200"){ exchange.getRequest().getHeaders().set(SecurityAccessConstant.HEADER_NAME_TOKEN, SecurityAccessConstant.TOKEN_PREFIX + resultData.getData()); isPass.set(true); } }).dispose(); if(isPass.get()){ return chain.filter(exchange.mutate().request().build()); }
不同场景下,选择也不同。在高精度、高安全要求的系统中,服务端刷新更稳妥;而在移动端或简单Web应用中,客户端方案能有效分担服务器压力。具体选哪个,还得看实际需求。
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
正版软件
正版软件
正版软件
正版软件
正版软件
1
2
3
7
8