关于Token过期问题

3,171 阅读12分钟

关于Token过期问题

本地运行项目,一切看似正常,进入主页,可是打开控制台,却会发现控制台有错误,如下图:

刚看到这个问题,的确让我傻眼,是不是后端的接口出问题了?答案当然不是啦! 那这是为什么呢? 原来啊,这是token 过期了,那什么是token过期呢? 要怎么解决这个问题呢?

接口返回的参数

上面报错的原因是调用后台的接口/front/user/getInfo,没有权限了 我们在登录成功后后台接口会返回给我们下方的信息:

access_token: 用来获取需要授权的接口数据 expries_in: access_token 过期时间 refresh_token: 刷新获取新的token

为了安全,给access_token 后端服务设置了过期时间,而且有时会设置的比较短

解决方案

  1. 在请求发起前进行拦截,判断token的有效时间是否过期,若已过期,请求挂起,先刷新token,再发起请求 优点:在请求前进行了拦截,减少一次请求的流量 缺点:需要服务端返回过期时间,和本地的时间对比,若是本地的时间被篡改或本地时间比服务器慢,这种方式就拦截失败

  2. 拦截返回后的数据,先发起请求,接口返回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只刷新了一次,而请求全部都重新发起了。