简介
jwt有两个token,分别是access_token、refresh_token;通常refresh_token设置较长的有效时间,access_token设置较短的有效时间。access_token用于后端验证,refresh_token用于获取新的access_token。也就是说,只要refresh_token不过期就可以一直保持登陆状态。 使用localStorage存储refresh_token在页面关闭后重新访问也能保持称登录状态。
代码实现
代码实现:
使用axios的拦截器,对于http响应状态401的进行拦截,加入待重发列表,刷新token,刷新成功,重发失败的请求,否则登录失败,跳转到登录页面。 另外如果请求较为密集,请求失败的比较集中,也可以在请求拦截器中作拦截。
js
import axios from 'axios'
import router from '@/router'
const baseURL = 'http://127.0.0.1:8100/'
export const getServerUrl = () => {
return baseURL
}
function refreshToken() {
let instance = axios.create()
return instance({
baseURL: baseURL,
url: '/user/refresh/',
method: 'post',
data: {
refresh: window.localStorage.getItem('refresh_token'),
},
})
}
const instance = axios.create({
baseURL: baseURL,
})
let isRefreshing = false // 是否正在刷新
let requests = [] // 存储当前token失效时正在请求的请求
function processQueue(error, token = null) {
requests.forEach((promise) => {
if (error) {
promise.reject(error)
} else {
promise.resolve(token)
}
})
requests = []
}
instance.interceptors.request.use(
function (config) {
console.log('当前在发送请求')
let access_token = window.localStorage.getItem('access_token')
config.headers.Authorization = 'Bearer ' + access_token
return config
},
function (error) {
return Promise.reject(error)
},
)
// 添加响应拦截器
instance.interceptors.response.use(
function (response) {
if (response.status || String(response.status).indexOf('2') === 0) {
return response.data
}
},
function (error) {
// 处理token失效,实现无感刷新
const { config, response } = error
if (response && response.status === 401) {
console.log('token失效,开始刷新token')
if (!isRefreshing) {
isRefreshing = true
// 发送刷新token的请求并返回
return refreshToken()
.then((res) => {
window.localStorage.setItem('access_token', res.data.access)
// 对requests列表中存储的请求重新执行
processQueue(null, res.data.access)
requests = []
//对当前失败的附上新的token,重新发起
config.headers.Authorization = 'Bearer ' + res.data.access
return instance(config)
})
.catch((error) => {
console.log('refreshToken failed,', error)
window.localStorage.removeItem('access_token')
window.localStorage.removeItem('refresh_token')
processQueue(error) // 刷新token失败,执行所有请求的回调函数
router.replace({ path: '/login' })
return Promise.reject(error)
})
.finally(() => {
isRefreshing = false
})
} else {
// 已经有其他的请求失败后去刷新了
return new Promise((resolve) => {
requests.push((token) => {
config.headers.Authorization = 'Bearer ' + token
resolve(instance(config))
})
})
}
}
if (response && response.status === 404) {
console.log('请求404')
return Promise.reject('错误!!')
}
// 超出 2xx 范围的状态码都会触发该函数。
return Promise.reject(error)
},
)
export default instance
参考文章
需求
最近遇到个需求:前端登录后,后端返回token
和token有效时间
,当token过期时要求用旧token去获取新的token,前端需要做到无痛刷新token,即请求刷新token时要做到用户无感知。
需求解析
当用户发起一个请求时,判断token
是否已过期,若已过期则先调refreshToken
接口,拿到新的token
后再继续执行之前的请求。
这个问题的难点在于:当同时发起多个请求,而刷新token
的接口还没返回,此时其他请求该如何处理?接下来会循序渐进地分享一下整个过程
实现思路
由于后端返回了token
的有效时间,可以有两种方法:
方法一:
在请求发起前拦截每个请求,判断token
的有效时间是否已经过期,若已过期,则将请求挂起,先刷新token
后再继续请求。
方法二:
不在请求前拦截,而是拦截返回后的数据。先发起请求,接口返回过期后,先刷新token
,再进行一次重试。
两种方法对比
方法一
- 优点: 在请求前拦截,能节省请求,省流量。
- 缺点: 需要后端额外提供一个token过期时间的字段;使用了本地时间判断,若本地时间被篡改,特别是本地时间比服务器时间慢时,拦截会失败。
PS:token有效时间建议是时间段,类似缓存的MaxAge,而不要是绝对时间。当服务器和本地时间不一致时,绝对时间会有问题。
方法二
- 优点:不需额外的token过期字段,不需判断时间。
- 缺点: 会消耗多一次请求,耗流量。
综上,方法一和二优缺点是互补的,方法一有校验失败的风险(本地时间被篡改时,当然一般没有用户闲的蛋疼去改本地时间的啦),方法二更简单粗暴,等知道服务器已经过期了再重试一次,只是会耗多一个请求。
在这里博主选择了 方法二。
实现
这里会使用axios
来实现,方法一是请求前拦截,所以会使用axios.interceptors.request.use()
这个方法;
而方法二是请求后拦截,所以会使用axios.interceptors.response.use()
方法。
封装axios
基本骨架
首先说明一下,项目中的token
是存在localStorage
中的。request.js
基本骨架:
javascript
import axios from 'axios'
// 从localStorage中获取token
function getLocalToken () {
const token = window.localStorage.getItem('token')
return token
}
// 给实例添加一个setToken方法,用于登录后将最新token动态添加到header,同时将token保存在localStorage中
instance.setToken = (token) => {
instance.defaults.headers['X-Token'] = token
window.localStorage.setItem('token', token)
}
// 创建一个axios实例
const instance = axios.create({
baseURL: '/api',
timeout: 300000,
headers: {
'Content-Type': 'application/json',
'X-Token': getLocalToken() // headers塞token
}
})
// 拦截返回的数据
instance.interceptors.response.use(response => {
// 接下来会在这里进行token过期的逻辑处理
return response
}, error => {
return Promise.reject(error)
})
export default instance
这个是项目中一般的axios
实例的封装,创建实例时,将本地已有的token
放进header
,然后export
出去供调用。接下来就是如何拦截返回的数据啦。
instance.interceptors.response.use拦截实现
后端接口一般会有一个约定好的数据结构,如:
yaml
{code: 1234, message: 'token过期', data: {}}
如我这里,后端约定当code === 1234
时表示token
过期了,此时就要求刷新token
。
javascript
instance.interceptors.response.use(response => {
const { code } = response.data
if (code === 1234) {
// 说明token过期了,刷新token
return refreshToken().then(res => {
// 刷新token成功,将最新的token更新到header中,同时保存在localStorage中
const { token } = res.data
instance.setToken(token)
// 获取当前失败的请求
const config = response.config
// 重置一下配置
config.headers['X-Token'] = token
config.baseURL = '' // url已经带上了/api,避免出现/api/api的情况
// 重试当前请求并返回promise
return instance(config)
}).catch(res => {
console.error('refreshtoken error =>', res)
//刷新token失败,神仙也救不了了,跳转到首页重新登录吧
window.location.href = '/'
})
}
return response
}, error => {
return Promise.reject(error)
})
function refreshToken () {
// instance是当前request.js中已创建的axios实例
return instance.post('/refreshtoken').then(res => res.data)
}
这里需要额外注意的是,response.config
就是原请求的配置,但这个是已经处理过了的,config.url
已经带上了baseUrl
,因此重试时需要去掉,同时token
也是旧的,需要刷新下。
以上就基本做到了无痛刷新token
,当token
正常时,正常返回,当token
已过期,则axios内部进行一次刷新token
和重试。对调用者来说,axios
内部的刷新token
是一个黑盒,是无感知的,因此需求已经做到了。
问题和优化
上面的代码还是存在一些问题的,没有考虑到多次请求的问题,因此需要进一步优化。
如何防止多次刷新token
如果refreshToken
接口还没返回,此时再有一个过期的请求进来,上面的代码就会再一次执行refreshToken
,这就会导致多次执行刷新token
的接口,因此需要防止这个问题。我们可以在request.js
中用一个flag来标记当前是否正在刷新token
的状态,如果正在刷新则不再调用刷新token
的接口
javascript
// 是否正在刷新的标记
let isRefreshing = false
instance.interceptors.response.use(response => {
const { code } = response.data
if (code === 1234) {
if (!isRefreshing) {
isRefreshing = true
return refreshToken().then(res => {
const { token } = res.data
instance.setToken(token)
const config = response.config
config.headers['X-Token'] = token
config.baseURL = ''
return instance(config)
}).catch(res => {
console.error('refreshtoken error =>', res)
window.location.href = '/'
}).finally(() => {
isRefreshing = false
})
}
}
return response
}, error => {
return Promise.reject(error)
})
这样子就可以避免在刷新token
时再进入方法了。但是这种做法是相当于把其他失败的接口给舍弃了,假如同时发起两个请求,且几乎同时返回,第一个请求肯定是进入了refreshToken
后再重试,而第二个请求则被丢弃了,仍是返回失败,所以接下来还得解决其他接口的重试问题。
同时发起两个或以上的请求时,其他接口如何重试
两个接口几乎同时发起和返回,第一个接口会进入刷新token
后重试的流程,而第二个接口需要先存起来,然后等刷新token
后再重试。同样,如果同时发起三个请求,此时需要缓存后两个接口,等刷新token
后再重试。由于接口都是异步的,处理起来会有点麻烦。
当第二个过期的请求进来,token
正在刷新,我们先将这个请求存到一个数组队列中,想办法让这个请求处于等待中,一直等到刷新token
后再逐个重试清空请求队列。
那么如何做到让这个请求处于等待中呢?为了解决这个问题,我们得借助Promise
。将请求存进队列中后,同时返回一个Promise
,让这个Promise
一直处于Pending
状态(即不调用resolve),此时这个请求就会一直等啊等,只要我们不执行resolve
,这个请求就会一直在等待。当刷新请求的接口返回来后,我们再调用resolve
,逐个重试。最终代码:
javascript
// 是否正在刷新的标记
let isRefreshing = false
// 重试队列,每一项将是一个待执行的函数形式
let requests = []
instance.interceptors.response.use(response => {
const { code } = response.data
if (code === 1234) {
const config = response.config
if (!isRefreshing) {
isRefreshing = true
return refreshToken().then(res => {
const { token } = res.data
instance.setToken(token)
config.headers['X-Token'] = token
config.baseURL = ''
// 已经刷新了token,将所有队列中的请求进行重试
requests.forEach(cb => cb(token))
// 重试完了别忘了清空这个队列(掘金评论区同学指点)
requests = []
return instance(config)
}).catch(res => {
console.error('refreshtoken error =>', res)
window.location.href = '/'
}).finally(() => {
isRefreshing = false
})
} else {
// 正在刷新token,返回一个未执行resolve的promise
return new Promise((resolve) => {
// 将resolve放进队列,用一个函数形式来保存,等token刷新后直接执行
requests.push((token) => {
config.baseURL = ''
config.headers['X-Token'] = token
resolve(instance(config))
})
})
}
}
return response
}, error => {
return Promise.reject(error)
})