背景
2024年01月01日,接了个私活,赚点外快。2周的时间,理需求,设计页面,开发,联调,产品 - UI - 前端一把梭,忙里忙外,终于尘埃落定了,准备提交给甲方体验。2000块到手,美滋滋。
第二天,开了个视频会议,给甲方做了一波功能展示,然后大吹特吹了一波。甲方负责人连连说“OK,OK!”,会议过程中充满了欢声笑语。会议结束前,约定本周给甲方开几个演示账号,Happy!
第三天,将测试的脏数据清空,模拟线上环境,新增了一些真实的数据,并更新了配置文件 - 将token的有效期更新为10分钟。万事俱备,准备做最后一轮测试,然后不出意外的情况下,果然出意外了。系统突然弹到登陆页了,查了一下日志,是token过期了,但是刷新token的接口也调用了。而且完整复现这个bug,十分钟过期,严重影响客户体验,属于一级BUG
开发测试的时候,token都是不过期的
当天,甲方负责人在群里问:”小李,测试账号新增好了吗?“。问的我冒虚汗,头皮发麻。跟后端商量了一下,先放开token时间限制,
。时间紧迫,解决这个问题迫在眉睫~~
问题排查 & 分析
登陆后,获取token,保存在cookie中,正常。
然后8分钟获取刷新token接口,并替换token信息,正常。
问题点:刷新token接口与其他接口同步触发,刷新token的接口请求 & 返回的慢,其他接口就过期了,然后触发了进入过期的逻辑,弹到登陆页了
。
定位了问题了,就好办了,开搞!
// 最初的代码
/*
提前2分钟去置换token
*/
service.interceptors.request.use(async (config) => {
if (getToken()) {
const expirationTime = getExpiration()
const currentTime = Date.now().getTime();
if (expirationTime - currentTime < 2 * 60 * 1000) {
try {
const res = await axios.post('/refresh', { token: getRefreshToken() });
let d = res.data.body || {}
setToken(d.tokenType + " " + d.accessToken);
setExpiration(d.expiresIn + currentTime);
setRefreshToken(d.refreshToken);
} catch (error) {
console.error('Refresh Token Error', error);
}
}
config.headers['Authorization'] = getToken()
}
return config;
}
进化版
于是打算在401的时候
,重新发起请求,获取新的token
axios.interceptors.response.use(response => {
// ...
}, error => {
if (error.response.status === 401) {
// isRefreshing 去控制是否在刷新token的状态,防止多次刷新
if (!isRefreshing) {
isRefreshing = true
return axios.post('/refresh', { refreshToken })
.then(res => {
let d = res.data.body ||
let token = d.tokenType + " " + d.accessToken
setToken(token);
setExpiration(d.expiresIn + Date.now().getTime());
setRefreshToken(d.refreshToken);
error.config.headers.Authorization = d.token
return axios(config);
})
.catch(err => {
removeToken()
router.push('/login')
return Promise.reject(err)
})
.finally(() => {
isRefreshing = false
})
}
return Promise.reject(error);
});
经测试,多个接口如A接口,B接口,C接口发起请求时,
A接口401,置换token,B接口401,置换token
但是B置换的token,会刷新A置换的token。如果只是多请求几次刷新token,也是可以接受的,关键问题还是同步请求顺序的不确定性
,如:A刷新token接口请求,B刷新刷新token接口请求,A接口401,B接口401。刷新失败,GG
最终版
确定了同步请求顺序的不确定性的问题后。如何采用顺序请求就成了关键点。查找资料后,将这个请求先存到一个数组队列中,然后将这个请求状态置于等待中,一直等到刷新token接口置换到数据后再逐个重试清空请求队列。
为了解决这个问题,我们需要借助Promise。将请求存进队列中后,同时返回一个Promise,让这个Promise一直处于Pending状态(即不调用resolve),此时这个请求就会一直处于等待中。当刷新请求的接口返回来后,我们再调用resolve,然后逐一清空请求队列。
let isRefreshing = false
let requests = []
/**
* 获取刷新token接口后,再逐一请求队列中的接口,直到清空请求
*/
service.interceptors.response.use(
async response => {
let expiration = getExpiration()
let timestamp = parseInt(new Date().getTime() / 1000)
if (expiration && expiration - 8 * 60 <= timestamp) {
if (!isRefreshing) {
isRefreshing = true
return refreshToken()
.then(res => {
const { token, expire } = res.data
setToken(token)
setExpiration(expire)
response.headers.Authorization = `${token}`
requests.forEach((cb) => cb(token))
requests = [] // 重新请求完清空队列
return service(response.config)
})
.catch(err => {
removeToken()
router.push('/login')
return Promise.reject(err)
})
.finally(() => {
isRefreshing = false
})
}
}
},
error => {
// ...
}
)
经测试,完美解决无感刷新token的问题,提交测试,over
总结
刷新token,一般来说有4种方案,更推荐第四种
- 后端自动续期,自动置换token
我司用的就是这种,什么都不用管,真爽
- 定时器定时刷新
- 浪费资源
- 无法处理多接口同时请求的情况
- 临过期前,前端调用刷新token接口
- 依赖本地时间判断,如果被篡改,拦截会失败
- 无法处理多接口同时请求的情况
- 请求拦截方案 - 完美版