关于Token过期问题
本地运行项目,一切看似正常,进入主页,可是打开控制台,却会发现控制台有错误,如下图:
刚看到这个问题,的确让我傻眼,是不是后端的接口出问题了?答案当然不是啦! 那这是为什么呢? 原来啊,这是token 过期了,那什么是token过期呢? 要怎么解决这个问题呢?
接口返回的参数
上面报错的原因是调用后台的接口/front/user/getInfo,没有权限了
我们在登录成功后后台接口会返回给我们下方的信息:
access_token: 用来获取需要授权的接口数据 expries_in: access_token 过期时间 refresh_token: 刷新获取新的token
为了安全,给access_token 后端服务设置了过期时间,而且有时会设置的比较短
解决方案
-
在请求发起前进行拦截,判断token的有效时间是否过期,若已过期,请求挂起,先刷新token,再发起请求 优点:在请求前进行了拦截,减少一次请求的流量 缺点:需要服务端返回过期时间,和本地的时间对比,若是本地的时间被篡改或本地时间比服务器慢,这种方式就拦截失败
-
拦截返回后的数据,先发起请求,接口返回401 过期后,先刷新token,再重新进行一次请求 优点: 不需要额外的token过期时间字段,不需要判断时间 缺点: 会多消耗一次请求,会耗流量
我们接下来会使用方式2 进行处理这个问题
创建响应拦截器
我们的请求封装在request.ts 文件中,因此我们需要在这里面创建响应拦截器
import axios from 'axios'
import store from '@/store'
const request = axios.create({
// 配置选项
// baseURL,
// timeout
})
// 请求拦截器
// Add a request interceptor
request.interceptors.request.use(function (config) {
// 全局配置token
config.headers.Authorization = store.state.user.access_token
// Do something before request is sent
return config
}, function (error) {
// Do something with request error
return Promise.reject(error)
})
// 响应拦截器
// Add a response interceptor
request.interceptors.response.use(function (response) {
// 状态码为 2xx 触发这个回调函数
// 如果是项目自定义的状态码,错误的处理方法写到这里
return response
}, function (error) {
// 状态码超出 2xx 会触发此处的回调
// 如果使用的是http 的错误状态码,错误的处理方法写到这里
return Promise.reject(error)
})
export default request
axios错误处理
token 过期返回的错误编码是401 ,因此,要处理这个问题,我们就需要在处理状态码超出2xx 的回调函数中来 进行处理token过期的逻辑 下面是axios官方给出的参考写法:
function (error) {
// 状态码超出 2xx 会触发此处的回调
// 如果使用的是http 的错误状态码,错误的处理方法写到这里
if (error.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
// 请求发出去了,服务器返回了超出2xx 的状态码
const { status } = error.response
switch (status) {
case 400:
Message.error('请求参数出错')
break
case 401:
// token 无效 (无token, token无效, token 过期)
Message.error('token 无效')
break
case 403:
Message.error('没有权限,请联系管理员')
break
case 404:
Message.error('请求资源不存在')
break
default:
Message.error('服务端出现异常,请联系管理员')
break
}
} else if (error.request) {
// 请求发出去但是没有收到服务器的返回信息
console.log(error.request);
} else {
// 设置的请求参数发生了错误
console.log('Error', error.message);
}
console.log(error.config);
// 把请求失败的对象继续抛出给上一个调用者
return Promise.reject(error)
}
处理token 失效的基本流程
- store 容器中没有用户,跳转到登录
- 若有refresh_token ,则尝试使用refresh_token 获取新的token
- 刷新token 成功了-》 重新发起本次失败的请求
- 刷新token 失败了 -》 跳转登录页面,重新登录获取token
- 若没有refresh_token, 则直接跳转到登录页
按照以上的思路,我们一步步来实现
store 容器中没有用户,跳转到登录
if (!store.state.user) {
router.push({
name: 'login',
query: {
redirect: router.currentRoute.fullPath
}
})
return Promise.reject(error)
}
若有refresh_token ,则尝试使用refresh_token 获取新的token
const refreshToken = store.state.user.refresh_token
if (refreshToken) {
request({
method: 'POST',
url: '/front/user/refresh_token',
data: qs.stringify(refreshToken)
})
}
这样的写法需要考虑一个问题,若是当前的refreshToken也失效了,请求接口返回的也是401,那么我们此时就会陷入 到这个获取新token 和 返回 401错误的怪圈中的。因此,为了解决这个问题我们需要优化这部分代码:
// 若有refresh_token ,则尝试使用refresh_token 获取新的token
const refreshToken = store.state.user.refresh_token
if (refreshToken) {
try {
const { data } = await axios.create()({
method: 'POST',
url: '/front/user/refresh_token',
data: qs.stringify({
refreshtoken: refreshToken
})
})
console.dir(data)
// 刷新token 成功了-》 重新发起本次失败的请求
} catch (error) {
// 刷新token 失败了 -》 跳转登录页面,重新登录获取token
}
}
优化之后,我们使用axios 创建了新的请求实例,与主逻辑中的request请求实例没有关系,就解决了这个问题了
刷新token 失败了 -》 跳转登录页面,重新登录获取token
// 跳转到登录
const redirectLogin = () => {
router.push({
name: 'login',
query: {
redirect: router.currentRoute.fullPath
}
})
}
async function (error) {
// 状态码超出 2xx 会触发此处的回调
// 如果使用的是http 的错误状态码,错误的处理方法写到这里
// console.dir(error)
if (error.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
// 请求发出去了,服务器返回了超出2xx 的状态码
const { status } = error.response
switch (status) {
case 400:
Message.error('请求参数出错')
break
case 401: {
// token 无效 (无token, token无效, token 过期)
Message.error('token 无效')
// 处理token 过期的基本流程
// store 容器中没有用户,跳转到登录
if (!store.state.user) {
redirectLogin()
return Promise.reject(error)
}
// 若有refresh_token ,则尝试使用refresh_token 获取新的token
const refreshToken = store.state.user.refresh_token
if (refreshToken) {
try {
const { data } = await axios.create()({
method: 'POST',
url: '/front/user/refresh_token',
data: qs.stringify({
refreshtoken: refreshToken
})
})
console.dir(data)
// 刷新token 成功了-》 重新发起本次失败的请求
// 更新store 中存储的access_token
store.commit('setUser', data.content)
// 重新发起请求,并将结果返回给调用者
return request(error.config)
} catch (error) {
// 刷新token 失败了 -》 跳转登录页面,重新登录获取token
redirectLogin()
return Promise.reject(error)
}
}
// 若没有refresh_token, 则直接跳转到登录页
}
break
case 403:
Message.error('没有权限,请联系管理员')
break
case 404:
Message.error('请求资源不存在')
break
default:
Message.error('服务端出现异常,请联系管理员')
break
}
} else if (error.request) {
// 请求发出去但是没有收到服务器的返回信息
console.log(error.request)
} else {
// 设置的请求参数发生了错误
console.log('Error', error.message)
}
// console.log(error.config)
// 把请求失败的对象继续抛出给上一个调用者
return Promise.reject(error)
})
经过上面的处理,我们基本把token过期的处理流程实现了一遍,完整代码:
import axios from 'axios'
import store from '@/store'
import { Message } from 'element-ui'
import router from '@/router'
import qs from 'qs'
const request = axios.create({
// 配置选项
// baseURL,
// timeout
})
// 跳转到登录
const redirectLogin = () => {
router.push({
name: 'login',
query: {
redirect: router.currentRoute.fullPath
}
})
}
// 请求拦截器
// Add a request interceptor
request.interceptors.request.use(function (config) {
// 全局配置token
config.headers.Authorization = store.state.user?.access_token
// Do something before request is sent
return config
}, function (error) {
// Do something with request error
return Promise.reject(error)
})
// 响应拦截器
// Add a response interceptor
request.interceptors.response.use(function (response) {
// 状态码为 2xx 触发这个回调函数
// 如果是项目自定义的状态码,错误的处理方法写到这里
return response
}, async function (error) {
// 状态码超出 2xx 会触发此处的回调
// 如果使用的是http 的错误状态码,错误的处理方法写到这里
// console.dir(error)
if (error.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
// 请求发出去了,服务器返回了超出2xx 的状态码
const { status } = error.response
switch (status) {
case 400:
Message.error('请求参数出错')
break
case 401: {
// token 无效 (无token, token无效, token 过期)
Message.error('token 无效')
// 处理token 过期的基本流程
// store 容器中没有用户,跳转到登录
if (!store.state.user) {
redirectLogin()
return Promise.reject(error)
}
// 若有refresh_token ,则尝试使用refresh_token 获取新的token
const refreshToken = store.state.user.refresh_token
if (refreshToken) {
try {
const { data } = await axios.create()({
method: 'POST',
url: '/front/user/refresh_token',
data: qs.stringify({
refreshtoken: refreshToken
})
})
// 刷新token 成功了-》 重新发起本次失败的请求
// 更新store 中存储的access_token
store.commit('setUser', data.content)
// 重新发起请求,并将结果返回给调用者
return request(error.config)
} catch (error) {
// 刷新token 失败了 -》 跳转登录页面,重新登录获取token
// 把当前登录用户清空
store.commit('setUser', null)
redirectLogin()
return Promise.reject(error)
}
}
// 若没有refresh_token, 则直接跳转到登录页
}
break
case 403:
Message.error('没有权限,请联系管理员')
break
case 404:
Message.error('请求资源不存在')
break
default:
Message.error('服务端出现异常,请联系管理员')
break
}
} else if (error.request) {
// 请求发出去但是没有收到服务器的返回信息
console.log(error.request)
} else {
// 设置的请求参数发生了错误
console.log('Error', error.message)
}
// console.log(error.config)
// 把请求失败的对象继续抛出给上一个调用者
return Promise.reject(error)
})
export default request
测试一下,由于后台设置的token过期时间24H,所以我们这里手动人为的破坏一下存储在本地的access_token 的值
在图中的位置,随便更改access_token 的值,然后刷新一下页面,结果:
从结果中分析: 由于token过期,获取用户信息的接口getInfo, 401请求失败,此时被axios的返回拦截器中的失败函数拦截了,接下来刷新token,重新发起失败的请求接口,达到了这种无痛刷新的效果。
多个请求同时刷新,token过期处理问题
虽然在上文中,咱们已经能够实现正常的处理token过期,无痛属性的问题了。但是,我们实际中同时有多个请求会需要用到token,这时候请求就会出现问题。 我们模拟有两个 获取用户信息,同时发起请求
created () {
this.loadUserInfo()
this.loadUserInfo()
}
async loadUserInfo () {
const { data } = await getUserInfo()
this.userInfo = data.content
}
手动破坏access_token,刷新页面
我们先看看网络请求的记录:
从上往下,当token过期后,两个请求都失败,返回401了,接着两个都进行了token刷新,其中第一个刷新请求正常:
再看第二个属性token的请求:
结果失败了,原因是refresh_token 只能被使用一次,因此,最终的store 中存储的user 为null , 跳转到登录界面
为了解决这个问题,我们需要引入一个变量isRefreshing来控制刷新状态
// 刷新token
const refreshToken = () => {
return axios.create()({
method: 'POST',
url: '/front/user/refresh_token',
data: qs.stringify({
refreshtoken: store.state.user.refresh_token
})
})
}
let isRefreshing = false
async function (error) {
// 状态码超出 2xx 会触发此处的回调
// 如果使用的是http 的错误状态码,错误的处理方法写到这里
// console.dir(error)
if (error.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
// 请求发出去了,服务器返回了超出2xx 的状态码
const { status } = error.response
switch (status) {
case 400:
Message.error('请求参数出错')
break
case 401:
// token 无效 (无token, token无效, token 过期)
// 处理token 过期的基本流程
// store 容器中没有用户,跳转到登录
if (!store.state.user) {
redirectLogin()
return Promise.reject(error)
}
// 若有refresh_token ,则尝试使用refresh_token 获取新的token
if (!isRefreshing) {
isRefreshing = true // 开启刷新状态
return refreshToken().then((res) => {
if (!res.data.success) {
throw new Error('刷新Token失效')
}
// 刷新token 成功了-》 重新发起本次失败的请求
// 更新store 中存储的access_token
store.commit('setUser', res.data.content)
// 重新发起请求,并将结果返回给调用者
return request(error.config)
}).catch((err) => {
// 刷新token 失败了 -》 跳转登录页面,重新登录获取token
// 把当前登录用户清空
store.commit('setUser', null)
redirectLogin()
return Promise.reject(err)
}).finally(() => {
// 重置刷新状态
isRefreshing = false
})
}
// 若没有refresh_token, 则直接跳转到登录页
break
case 403:
Message.error('没有权限,请联系管理员')
break
case 404:
Message.error('请求资源不存在')
break
default:
Message.error('服务端出现异常,请联系管理员')
break
}
} else if (error.request) {
// 请求发出去但是没有收到服务器的返回信息
console.log(error.request)
} else {
// 设置的请求参数发生了错误
console.log('Error', error.message)
}
// console.log(error.config)
// 把请求失败的对象继续抛出给上一个调用者
return Promise.reject(error)
})
结果如下图:
目前刷新完token后,只重新发起一次的请求,还是需要进一步处理的
- 定义一个数组用于存储返回状态401 的请求
- 等待token刷新时,将请求挂起存储起来
- 刷新token完成后,遍历数组,重发请求
- 将数组清空
具体实现完整代码:
import axios from 'axios'
import store from '@/store'
import { Message } from 'element-ui'
import router from '@/router'
import qs from 'qs'
const request = axios.create({
// 配置选项
// baseURL,
// timeout
})
// 跳转到登录
const redirectLogin = () => {
router.push({
name: 'login',
query: {
redirect: router.currentRoute.fullPath
}
})
}
// 刷新token
const refreshToken = () => {
return axios.create()({
method: 'POST',
url: '/front/user/refresh_token',
data: qs.stringify({
refreshtoken: store.state.user.refresh_token
})
})
}
// 请求拦截器
// Add a request interceptor
request.interceptors.request.use(function (config) {
// 全局配置token
config.headers.Authorization = store.state.user?.access_token
// Do something before request is sent
return config
}, function (error) {
// Do something with request error
return Promise.reject(error)
})
// 控制刷新状态
let isRefreshing = false
// 存储返回401 的失败请求
let requests: (() => void)[] = []
// 响应拦截器
// Add a response interceptor
request.interceptors.response.use(function (response) {
// 状态码为 2xx 触发这个回调函数
// 如果是项目自定义的状态码,错误的处理方法写到这里
return response
}, async function (error) {
// 状态码超出 2xx 会触发此处的回调
// 如果使用的是http 的错误状态码,错误的处理方法写到这里
// console.dir(error)
if (error.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
// 请求发出去了,服务器返回了超出2xx 的状态码
const { status } = error.response
switch (status) {
case 400:
Message.error('请求参数出错')
break
case 401:
// token 无效 (无token, token无效, token 过期)
// 处理token 过期的基本流程
// store 容器中没有用户,跳转到登录
if (!store.state.user) {
redirectLogin()
return Promise.reject(error)
}
// 若有refresh_token ,则尝试使用refresh_token 获取新的token
if (!isRefreshing) {
isRefreshing = true // 开启刷新状态
return refreshToken().then((res) => {
if (!res.data.success) {
throw new Error('刷新Token失效')
}
// 刷新token 成功了-》 重新发起本次失败的请求
// 更新store 中存储的access_token
store.commit('setUser', res.data.content)
// 将挂起的请求,重新发起
requests.forEach(cb => cb())
// 清空数组
requests = []
// 重新发起请求,并将结果返回给调用者
return request(error.config)
}).catch((err) => {
// 刷新token 失败了 -》 跳转登录页面,重新登录获取token
// 把当前登录用户清空
store.commit('setUser', null)
redirectLogin()
return Promise.reject(err)
}).finally(() => {
// 重置刷新状态
isRefreshing = false
})
}
// 等待token刷新时,将请求挂起存储起来
return new Promise(resolve => {
requests.push(() => {
resolve(request(error.config))
})
})
// 若没有refresh_token, 则直接跳转到登录页
break
case 403:
Message.error('没有权限,请联系管理员')
break
case 404:
Message.error('请求资源不存在')
break
default:
Message.error('服务端出现异常,请联系管理员')
break
}
} else if (error.request) {
// 请求发出去但是没有收到服务器的返回信息
console.log(error.request)
} else {
// 设置的请求参数发生了错误
console.log('Error', error.message)
}
// console.log(error.config)
// 把请求失败的对象继续抛出给上一个调用者
return Promise.reject(error)
})
export default request
结果:token只刷新了一次,而请求全部都重新发起了。