• 如何实现无感知刷新token
  • 发布于 2个月前
  • 439 热度
    0 评论
什么是无感知刷新token?无感知刷新token,就是accessToken过期时,去请求refreshToken接口,请求成功后继续之前因为accessToken过期导致无法请求的接口。无感知刷新token的好处?无感知刷新token,当accessToken过期时自动请求refreshToken接口获取新的accessToken从而实现续签,且此过程用户毫无感知。

传统实现方式为弹个弹框提示认证过期,点击确定才会去请求refreshToken接口。对于accessToken有效期较短的项目,用户隔三差五就看到弹框叫重新获取认证,非常不友好。

实现逻辑
统一约定好规则,错误代码
200:请求成功   401:认证失败     403:禁止访问,直接弹框提示  
1、状态200返回响应数据
2、状态403直接弹框,点击确定 清除缓存刷新页面
3、状态 401认证过期
      1)设置 isRefresh 变量为true
      2)请求刷新token接口
      3)  此时有其他请求时,将请求放到队列中,待刷新token响应时再请求
      4)刷新token响应成功时,将isRefresh设置为false

第一种实现方式,具体上代码 作者自研
import axios from 'axios'
import type {
  AxiosInstance,
  AxiosRequestConfig,
  AxiosResponse,
  InternalAxiosRequestConfig
} from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAppStoreHook } from '@/store/modules/app'
const useAppStore = useAppStoreHook()
import { useUserStoreHook } from '@/store/modules/user'

interface RequestObj {
  [key: string]: {
    cancelSource: any // 堆代码 duidaima.com
    noShowLoading: boolean // 是否展示加载遮罩
    changeRouteRemove: boolean // 是否路由跳转时 取消未响应的接口请求
    isCancel: boolean // 是否是取消请求
  }
}

interface NewAddConfig {
  preventDuplicateRequests?: boolean // 是否配置防止重复请求
  noShowLoading?: boolean // 是否展示加载遮罩
  changeRouteRemove?: boolean // 是否路由跳转时 取消未响应的接口请求
}

export interface UserAxiosRequestConfig extends NewAddConfig, AxiosRequestConfig {}

interface NewAxiosRequestConfig extends NewAddConfig, InternalAxiosRequestConfig {}

interface NewAxiosResponse extends AxiosResponse {
  config: NewAxiosRequestConfig
}

const CancelToken = axios.CancelToken
class Axios {
  // axios 实例
  instance: AxiosInstance

  // 基础配置,url和超时时间
  baseConfig: AxiosRequestConfig = { baseURL: '/proxy', timeout: 10000 }

  // 请求对象
  requestObj: RequestObj = {}

  // 正在请求的接口数量
  requestingNum = 0

  // 记录正在刷新token时 请求的接口地址
  authErrorArr = []

  // accessToken过期后 请求的接口
  queue = []

  // 是否正在执行刷新token请求
  isRefresh = false

  // 是否展示了重新登录弹框
  isShowReLoginDialog = false

  // 取消请求方法,
  remove(url: string, isRouteChange: boolean): void {
    // 没有这个请求,直接返回
    if (!this.requestObj[url]) return

    // 路由处调用的remove方法, 且此接口切换路由时不进行 取消请求操作 直接返回
    if (isRouteChange && !this.requestObj[url].changeRouteRemove) return

    this.requestObj[url].cancelSource.cancel({
      ...this.requestObj[url]
    })

    if (isRouteChange) {
      delete this.requestObj[url]
    }
  }

  async clearAuth(message: string, useUserStore: any): Promise<void> {
    if (this.isShowReLoginDialog) {
      return
    }
    this.isShowReLoginDialog = true
    await useUserStore.clearAuth()
    ElMessageBox.alert(`${message}`, '提示', {
      confirmButtonText: 'OK',
      showClose: false,
      callback: async (action: string) => {
        if (action === 'confirm') {
          // 刷新浏览器是为了 跳转登录页时query的redirect 会带上 当前页面地址
          window.location.reload()
        }
      }
    })
    Promise.reject(new Error(message || 'Error'))
  }

  constructor(config?: AxiosRequestConfig) {
    // 使用axios.create创建axios实例
    this.instance = axios.create(Object.assign(this.baseConfig, config))

    this.instance.interceptors.request.use(
      (config: NewAxiosRequestConfig) => {
        // 如果没配置不展示加载动画 则请求开始 请求数量+1
        if (!config.noShowLoading) {
          this.requestingNum++
          useAppStore.setLoadingStatus(true)
        }

        const url = config.url
        const index = this.authErrorArr.indexOf(url)
        // 不存在的才要push 加进去,当正要去请求刷新token接口 且此接口不是刷新token接口
        if (index === -1 && this.isRefresh && url !== '/xiaobu-admin/refresh-token') {
          this.authErrorArr.push(url)
        }

        // 配置 preventDuplicateRequests 为false 则不校验防止重复请求
        if (url && config.preventDuplicateRequests !== false) {
          this.remove(url, false)
          this.requestObj[url] = {
            cancelSource: CancelToken.source(),
            isCancel: true,
            noShowLoading: !!config.noShowLoading,
            changeRouteRemove: !(config.changeRouteRemove === false) // 配置为false时才不清除
          }
          config.cancelToken = this.requestObj[config.url].cancelSource.token
        }

        // 一般会请求拦截里面加token
        const useUserStore = useUserStoreHook()
        const accessToken = useUserStore.gettersAccessToken
        if (accessToken) {
          config.headers['authorization'] = 'Bearer ' + accessToken
        }

        return config
      },
      (err: any) => {
        return Promise.reject(err)
      }
    )

    this.instance.interceptors.response.use(
      async (res: NewAxiosResponse) => {
        const useUserStore = useUserStoreHook()
        const { config } = res
        // 如果没配置不展示加载动画 则响应结束 请求数量-1
        if (!config.noShowLoading) {
          this.requestingNum--
          if (this.requestingNum <= 0) {
            useAppStore.setLoadingStatus(false)
          }
        }

        // 响应结束 去除 请求对象 的key值
        if (config.url && this.requestObj[config.url]) {
          delete this.requestObj[config.url]
        }

        const data = res.data
        // 返回的是文件流 直接返回
        if (config.responseType === 'blob') {
          return data
        }

        // code为200 直接返回有用数据
        if (data.code === 200) {
          return data.data
        }

        const index = this.authErrorArr.indexOf(config.url)
        if (index !== -1) {
          this.authErrorArr.splice(index, 1)
          // 当前正在尝试刷新token,先返回一个promise阻塞请求并推进请求列表中
          return new Promise((resolve) => {
            this.queue.push((accessToken: string) => {
              Reflect.set(config.headers!, 'authorization', 'Bearer ' + accessToken)
              // @ts-ignore
              resolve(this.instance.request<NewAxiosResponse<any>>(config))
            })
          })
        }

        // token过期 请求refreshToken
        if (data.code === 401) {
          const refreshToken = useUserStore.gettersRefreshToken
          // 不存在refreshToken时 直接弹框提示
          if (!refreshToken) {
            return this.clearAuth(data.message, useUserStore)
          }
          // 判断当前是否为刷新状态中(防止多个请求导致多次调refresh接口)
          if (!this.isRefresh) {
            // 设置当前状态为刷新中
            this.isRefresh = true
            try {
              const { accessToken } = await useUserStore.refreshTokenAction({ refreshToken })

              // 遍历队列,重新发起请求
              this.queue.forEach((cb) => cb(accessToken))

              // 请求第一个报 401的接口 并return 否则 无法返回数据
              return this.instance.request(config)
            } catch {
              return this.clearAuth(data.message, useUserStore)
            } finally {
              this.queue = []
              // 重置状态
              this.isRefresh = false
            }
          } else {
            //还是会存在部分接口和 第一个报 401接口 一起进来的 所以此处要对非第一个报 401 接口 的接口做处理
            // 当前正在尝试刷新token,先返回一个promise阻塞请求并推进请求列表中
            return new Promise((resolve) => {
              this.queue.push((accessToken: string) => {
                Reflect.set(config.headers!, 'authorization', 'Bearer ' + accessToken)
                // @ts-ignore
                resolve(this.instance.request<NewAxiosResponse<any>>(config))
              })
            })
          }
        }

        // accessToken过期或者无效 清除token 然后刷新页面
        if (data.code === 403) {
          return this.clearAuth(data.message, useUserStore)
        }

        ElMessage({
          showClose: true,
          message: `${data.message}`,
          type: 'error'
        })

        return Promise.reject(new Error(data.message || 'Error'))
      },
      (err: any) => {
        // 这里用来处理http常见错误,进行全局提示
        let message = ''

        if (err?.config) {
          const url = err.config.url
          // 报错时 删除这个正在请求的对象
          if (this.requestObj[url]) delete this.requestObj[url]

          // 接口报错时 删除
          const index = this.authErrorArr.indexOf(url)
          if (index !== -1) this.authErrorArr.splice(index, 1)
        }

        // 取消请求中message.noShowLoading不为true 或者 非取消请求中 config.noShowLoading 不为true
        if (
          (err?.message?.isCancel && !err?.message?.noShowLoading) ||
          (err.config && !err.config.noShowLoading)
        ) {
          this.requestingNum--
          if (this.requestingNum <= 0) {
            useAppStore.setLoadingStatus(false)
          }
        }

        message = err?.message?.isCancel ? '' : err.message

        if (err.response && err.response.status) {
          switch (err.response.status) {
            case 400:
              message = '请求错误(400)'
              break
            case 401:
              message = '未授权,请重新登录(401)'
              // 这里可以做清空storage并跳转到登录页的操作
              break
            case 403:
              message = '拒绝访问(403)'
              break
            case 404:
              message = '请求出错(404)'
              break
            case 408:
              message = '请求超时(408)'
              break
            case 500:
              message = '服务器错误(500)'
              break
            case 501:
              message = '服务未实现(501)'
              break
            case 502:
              message = '网络错误(502)'
              break
            case 503:
              message = '服务不可用(503)'
              break
            case 504:
              message = '网络超时(504)'
              break
            case 505:
              message = 'HTTP版本不受支持(505)'
              break
            default:
              message = `连接出错(${err.response.status})!`
          }
        }
        // 这里错误消息可以使用全局弹框展示出来
        if (message) {
          // 比如element plus 可以使用 ElMessage
          ElMessage({
            showClose: true,
            message: `${message},请检查网络或联系管理员!`,
            type: 'error'
          })
        }

        // 这里是AxiosError类型,所以一般我们只reject我们需要的响应即可
        return Promise.reject(err)
      }
    )
  }

  get<T = any>(url: string, config: UserAxiosRequestConfig): Promise<T> {
    return this.instance.request({ url, ...config, method: 'GET' })
  }

  post<T = any>(url: string, data: any, config: UserAxiosRequestConfig): Promise<T> {
    return this.instance.request({ url, data, ...config, method: 'POST' })
  }

  delete<T = any>(url: string, config: UserAxiosRequestConfig): Promise<T> {
    return this.instance.request({ url, ...config, method: 'DELETE' })
  }

  patch<T = any>(url: string, data: any, config: UserAxiosRequestConfig): Promise<T> {
    return this.instance.request({ url, data, ...config, method: 'PATCH' })
  }

  put<T = any>(url: string, data: any, config: UserAxiosRequestConfig): Promise<T> {
    return this.instance.request({ url, data, ...config, method: 'PUT' })
  }
}

export default new Axios()
此种方式,要求后端响应状态码为token认证过期响应401,refreshToken过期响应403,异地登录响应403,正常响应200。

另一种方式,上代码
import axios from 'axios'
import type {
  AxiosInstance,
  AxiosRequestConfig,
  AxiosResponse,
  InternalAxiosRequestConfig
} from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAppStoreHook } from '@/store/modules/app'
const useAppStore = useAppStoreHook()
import { useUserStoreHook } from '@/store/modules/user'

interface RequestObj {
  [key: string]: {
    cancelSource: any
    noShowLoading: boolean // 是否展示加载遮罩
    changeRouteRemove: boolean // 是否路由跳转时 取消未响应的接口请求
    isCancel: boolean // 是否是取消请求
  }
}

interface NewAddConfig {
  preventDuplicateRequests?: boolean // 是否配置防止重复请求
  noShowLoading?: boolean // 是否展示加载遮罩
  changeRouteRemove?: boolean // 是否路由跳转时 取消未响应的接口请求
}

export interface UserAxiosRequestConfig extends NewAddConfig, AxiosRequestConfig {}

interface NewAxiosRequestConfig extends NewAddConfig, InternalAxiosRequestConfig {}

interface NewAxiosResponse extends AxiosResponse {
  config: NewAxiosRequestConfig
}

const CancelToken = axios.CancelToken
class Axios {
  // axios 实例
  instance: AxiosInstance

  // 基础配置,url和超时时间
  baseConfig: AxiosRequestConfig = { baseURL: '/proxy', timeout: 10000 }

  // 请求对象
  requestObj: RequestObj = {}

  // 正在请求的接口数量
  requestingNum = 0

  // accessToken过期后 请求的接口
  queue = []

  // 是否正在执行刷新token请求
  isRefresh = false

  // 是否展示了重新登录弹框
  isShowReLoginDialog = false

  currentCount = 0

  MAX_ERROR_COUNT = 3

  // 取消请求方法,
  remove(url: string, isRouteChange: boolean): void {
    // 没有这个请求,直接返回
    if (!this.requestObj[url]) return

    // 路由处调用的remove方法, 且此接口切换路由时不进行 取消请求操作 直接返回
    if (isRouteChange && !this.requestObj[url].changeRouteRemove) return

    this.requestObj[url].cancelSource.cancel({
      ...this.requestObj[url]
    })

    if (isRouteChange) {
      delete this.requestObj[url]
    }
  }

  async clearAuth(message: string, useUserStore: any): Promise<void> {
    if (this.isShowReLoginDialog) {
      return
    }
    this.isShowReLoginDialog = true
    await useUserStore.clearAuth()
    ElMessageBox.alert(`${message}`, '提示', {
      confirmButtonText: 'OK',
      showClose: false,
      callback: async (action: string) => {
        if (action === 'confirm') {
          // 刷新浏览器是为了 跳转登录页时query的redirect 会带上 当前页面地址
          window.location.reload()
        }
      }
    })
    Promise.reject(new Error(message || 'Error'))
  }

  constructor(config?: AxiosRequestConfig) {
    // 使用axios.create创建axios实例
    this.instance = axios.create(Object.assign(this.baseConfig, config))

    this.instance.interceptors.request.use(
      (config: NewAxiosRequestConfig) => {
        // 如果没配置不展示加载动画 则请求开始 请求数量+1
        if (!config.noShowLoading) {
          this.requestingNum++
          useAppStore.setLoadingStatus(true)
        }

        const url = config.url
        // 配置 preventDuplicateRequests 为false 则不校验防止重复请求
        if (url && config.preventDuplicateRequests !== false) {
          this.remove(url, false)
          this.requestObj[url] = {
            cancelSource: CancelToken.source(),
            isCancel: true,
            noShowLoading: !!config.noShowLoading,
            changeRouteRemove: !(config.changeRouteRemove === false) // 配置为false时才不清除
          }
          config.cancelToken = this.requestObj[config.url].cancelSource.token
        }

        // 一般会请求拦截里面加token
        const useUserStore = useUserStoreHook()
        const accessToken = useUserStore.gettersAccessToken
        if (accessToken) {
          /**
           *   当 NewAxiosRequestConfig 继承自 AxiosRequestConfig时 会报 headers不一定存在,于是加了下面的判断,
           * 后面查看axios/index.d.ts代码,发现还有一层 InternalAxiosRequestConfig 包含headers的引用类型,然后可去除下面的判断
           * */
          // if (!config.headers) {
          //   config.headers = {}
          // }
          config.headers['authorization'] = 'Bearer ' + accessToken
        }

        return config
      },
      (err: any) => {
        return Promise.reject(err)
      }
    )

    this.instance.interceptors.response.use(
      async (res: NewAxiosResponse) => {
        const useUserStore = useUserStoreHook()
        const { config } = res
        // 如果没配置不展示加载动画 则响应结束 请求数量-1
        if (!config.noShowLoading) {
          this.requestingNum--
          if (this.requestingNum <= 0) {
            useAppStore.setLoadingStatus(false)
          }
        }

        // 响应结束 去除 请求对象 的key值
        if (config.url && this.requestObj[config.url]) {
          delete this.requestObj[config.url]
        }

        const data = res.data
        // 返回的是文件流 直接返回
        if (config.responseType === 'blob') {
          return data
        }

        // code为200 直接返回有用数据
        if (data.code === 200) {
          return data.data
        }

        // token过期 请求refreshToken
        if (data.code === 401) {
          const refreshToken = useUserStore.gettersRefreshToken
          // 不存在refreshToken时 直接弹框提示
          if (!refreshToken) {
            return this.clearAuth(data.message, useUserStore)
          }

          // 判断是否refresh失败且状态码401,再次进入错误拦截器
          if (config.url === '/xiaobu-admin/refresh-token') {
            return this.clearAuth(data.message, useUserStore)
          }

          // 判断当前是否为刷新状态中(防止多个请求导致多次调refresh接口)
          if (!this.isRefresh) {
            // 设置当前状态为刷新中
            this.isRefresh = true

            // 如果重发次数超过,直接退出登录
            if (this.currentCount > this.MAX_ERROR_COUNT) {
              return this.clearAuth(data.message, useUserStore)
            }
            // 增加重试次数
            this.currentCount += 1

            try {
              const { accessToken } = await useUserStore.refreshTokenAction({ refreshToken })

              this.currentCount = 0

              // 遍历队列,重新发起请求
              this.queue.forEach((cb) => cb(accessToken))

              // 请求第一个报 401的接口 并return 否则 无法返回数据
              return this.instance.request(config)
            } catch {
              return this.clearAuth(data.message, useUserStore)
            } finally {
              this.queue = []
              // 重置状态
              this.isRefresh = false
            }
          } else {
            //还是会存在部分接口和 第一个报 401接口 一起进来的 所以此处要对非第一个报 401 接口 的接口做处理
            // 当前正在尝试刷新token,先返回一个promise阻塞请求并推进请求列表中
            return new Promise((resolve) => {
              this.queue.push((accessToken: string) => {
                Reflect.set(config.headers!, 'authorization', 'Bearer ' + accessToken)
                // @ts-ignore
                resolve(this.instance.request<NewAxiosResponse<any>>(config))
              })
            })
          }
        }

        // accessToken过期或者无效 清除token 然后刷新页面
        if (data.code === 403) {
          return this.clearAuth(data.message, useUserStore)
        }

        ElMessage({
          showClose: true,
          message: `${data.message}`,
          type: 'error'
        })

        return Promise.reject(new Error(data.message || 'Error'))
      },
      (err: any) => {
        // 这里用来处理http常见错误,进行全局提示
        let message = ''

        // 取消请求中message.noShowLoading不为true 或者 非取消请求中 config.noShowLoading 不为true
        if (
          (err?.message?.isCancel && !err?.message?.noShowLoading) ||
          (err.config && !err.config.noShowLoading)
        ) {
          this.requestingNum--
          if (this.requestingNum <= 0) {
            useAppStore.setLoadingStatus(false)
          }
        }

        message = err?.message?.isCancel ? '' : err.message

        if (err.response && err.response.status) {
          switch (err.response.status) {
            case 400:
              message = '请求错误(400)'
              break
            case 401:
              message = '未授权,请重新登录(401)'
              break
            case 403:
              message = '拒绝访问(403)'
              break
            case 404:
              message = '请求出错(404)'
              break
            case 408:
              message = '请求超时(408)'
              break
            case 500:
              message = '服务器错误(500)'
              break
            case 501:
              message = '服务未实现(501)'
              break
            case 502:
              message = '网络错误(502)'
              break
            case 503:
              message = '服务不可用(503)'
              break
            case 504:
              message = '网络超时(504)'
              break
            case 505:
              message = 'HTTP版本不受支持(505)'
              break
            default:
              message = `连接出错(${err.response.status})!`
          }
        }
        // 这里错误消息可以使用全局弹框展示出来
        if (message) {
          // 比如element plus 可以使用 ElMessage
          ElMessage({
            showClose: true,
            message: `${message},请检查网络或联系管理员!`,
            type: 'error'
          })
        }

        // 这里是AxiosError类型,所以一般我们只reject我们需要的响应即可
        return Promise.reject(err)
      }
    )
  }

  get<T = any>(url: string, config: UserAxiosRequestConfig): Promise<T> {
    return this.instance.request({ url, ...config, method: 'GET' })
  }

  post<T = any>(url: string, data: any, config: UserAxiosRequestConfig): Promise<T> {
    return this.instance.request({ url, data, ...config, method: 'POST' })
  }

  delete<T = any>(url: string, config: UserAxiosRequestConfig): Promise<T> {
    return this.instance.request({ url, ...config, method: 'DELETE' })
  }

  patch<T = any>(url: string, data: any, config: UserAxiosRequestConfig): Promise<T> {
    return this.instance.request({ url, data, ...config, method: 'PATCH' })
  }

  put<T = any>(url: string, data: any, config: UserAxiosRequestConfig): Promise<T> {
    return this.instance.request({ url, data, ...config, method: 'PUT' })
  }
}

export default new Axios()

此种方式要求后端,refreshToken过期及token过期都返回401,只有异地登录时可以返回403。因为403意味着弹框提示,点击确定清除缓存,刷新页面。此方式会刷新token请求响应401时,还有机会再请求,具体请求次数为配置的MAX_ERROR_COUNT。
用户评论