Axios插件-双 token 无感刷新登录

2,208 阅读7分钟

单 token:普通的单 token 方案,有效期设置过长不安全,过短需要频繁重新登录,用户体验差。

双 token:服务端返回两个 token,一个有效期短(例如:30分钟过期)的 access-token 用来认证用户身份,一个有效期长(例如:7天)的 refresh-token 用来刷新 token。
优点:1.用户只需要7天内登录过一次,即可刷新登录状态,不用频繁重新登录;2.access-token 有效期短,被盗损失小,而 refresh-token 只在第一次获取和刷新 token 时才在网络中传输,被盗风险小,万一被盗,刷新 token 也需要同时提供两个 token。

1. 项目背景

最近开发项目时,碰到这个需求,刚开始初步实现了一版。后面 review 时,发现与 axios 拦截器中其他业务逻辑耦合度过高,就想着能不能把相关逻辑单独抽离出来,使用时主动注册到拦截器即可,也方便复用。

2. 功能展望

  • 身份认证失败时,自动刷新身份凭证,刷新成功后,再重新发起请求并返回结果;
  • 刷新身份凭证期间发起的请求先暂时中断,待刷新成功后,再重新请求;
  • 抽离成一个插件,可以无缝注册到 axios 中,并支持自定义刷新身份凭证方法与刷新时机。

3. 功能开发

3.1 实现自动刷新身份凭证

首先,需要知道什么情况下,才需要刷新 token。一般来说接口返回的 http 状态码为401时,就代表需要身份认证。也可以先和后端约定好相关状态码。代码如下:

import axios, { AxiosError } from 'axios'

export const instance = axios.create({
  baseURL: '/api',
  timeout: 5000,
  withCredentials: true
})

instance.interceptors.request.use(
  (config) => {
    // 获取 token 并设置请求头
    const token = localStorage.getItem('token')
    config.headers.Authorization = `Bearer ${token}`

    return config
  },
  (error: AxiosError) => Promise.reject(error)
)
instance.interceptors.response.use(
  (res) => {
    const data = res.data
    if (data instanceof Blob || data.statusCode === 200) {
      return res
    }

    // 1. 与后端约定的身份验证失败状态码
    if (data.statusCode === 401) {
      // 401 代表 token 过期,自动刷新 token
    }

    return Promise.reject(new AxiosError(data.message))
  },
  (error: AxiosError) => {
    // 2. http 状态码为 401 时,自动刷新 token
    if (error.response?.status === 401) {
      // 401 代表 token 过期,自动刷新 token
    }

    return Promise.reject(error)
  }
)

接下来,我们需要实现一个刷新方法,该方法在刷新成功时,可以接收一个回调函数并返回该回调函数执行后的 Promise 结果。刷新失败时,返回一个 Promise.reject 错误。代码如下:

import axios, { AxiosError, type AxiosResponse } from 'axios'

export const instance = axios.create({
  baseURL: '/api',
  timeout: 5000,
  withCredentials: true
})

// 通过接口获取新 token,伪代码,实际项目中需要根据实际情况修改
const getNewToken = async () => {
  const res = await instance.post('/refreshToken')

  if (res.data.statusCode === 200) {
    // 缓存新 token
    localStorage.setItem('token', res.data.data.token)
    localStorage.setItem('refreshToken', res.data.data.refreshToken)
    return true
  }

  return false
}

// 刷新方法,成功时执行回调,失败时抛出 reject(AxiosError)
const refreshToken = (
  callback: (
    resolve: (value: AxiosResponse | PromiseLike<AxiosResponse>) => void,
    reject: (reason?: any) => void
  ) => void
): Promise<AxiosResponse> => {
  return new Promise((resolve, reject) => {
    getNewToken().then((res) => {
      // 刷新 token 成功,执行回调
      if (res) {
        callback(resolve, reject)
        return
      }

      // 刷新 token 失败,抛出错误
      reject(new AxiosError('Refresh token failed'))
    })
  })
}

instance.interceptors.request.use(
  (config) => {
    // 获取 token 并设置请求头
    const token = localStorage.getItem('token')
    config.headers.Authorization = `Bearer ${token}`

    return config
  },
  (error: AxiosError) => Promise.reject(error)
)
instance.interceptors.response.use(
  (res) => {
    const data = res.data
    if (data instanceof Blob || data.statusCode === 200) {
      return res
    }

    // 与后端约定的身份验证失败状态码
    if (data.statusCode === 401) {
      // 刷新 token
      return refreshToken((resolve, reject) => {
        // 刷新成功后,重新请求
        instance.request(res.config).then(resolve).catch(reject)
      })
    }

    return Promise.reject(new AxiosError(data.message))
  },
  (error: AxiosError) => {
    // http 状态码为 401 时,自动刷新 token
    if (error.response?.status === 401) {
      // 刷新 token
      return refreshToken((resolve, reject) => {
        // 刷新成功后,重新请求
        instance.request(error.config!).then(resolve).catch(reject)
      })
    }

    return Promise.reject(error)
  }
)

至此,我们就初步实现了一个无感刷新登录状态的功能了。

3.2 请求等待队列

上面的代码存在一个问题,那就是在刷新期间还会发送新请求到服务器。那能不能先中断在刷新期间发起的请求,等待刷新完成后再重新发起呢?

这是可以实现的。

可以添加一个等待队列,将刷新 token 期间发送的请求中断后,放入该队列中,等到刷新成功后,再重新请求。

那要如何判断哪些请求是在刷新 token 期间发送的呢?

可以通过增加一个变量来标记,修改刷新方法的代码如下:

// 新增变量标记刷新状态
let isRefreshing = false

const refreshToken = (
  callback: (
    resolve: (value: AxiosResponse | PromiseLike<AxiosResponse>) => void,
    reject: (reason?: any) => void
  ) => void
): Promise<AxiosResponse> => {
  // 开始刷新
  isRefreshing = true
    
  return new Promise((resolve, reject) => {
    getNewToken().then((res) => {
      // 结束刷新
      isRefreshing = false

      // 刷新 token 成功,执行回调
      if (res) {
        callback(resolve, reject)
        return
      }

      // 刷新 token 失败,抛出错误
      reject(new AxiosError('Refresh token failed'))
    })
  })
}

那么要如何中断请求呢?

在请求拦截器中抛出错误,然后在响应拦截器的错误回调中处理即可。

import axios, { AxiosError, type AxiosRequestConfig, type AxiosResponse } from 'axios'

export const instance = axios.create({
  baseURL: '/api',
  timeout: 5000,
  withCredentials: true
})

const getNewToken = async () => {
  // 伪代码,实际项目中需要根据实际情况修改
  const res = await instance.post('/refreshToken')

  if (res.data.statusCode === 200) {
    localStorage.setItem('token', res.data.data.token)
    localStorage.setItem('refreshToken', res.data.data.refreshToken)
    return true
  }

  return false
}

// 等待队列
let queue: Function[] = []

// 添加到等待队列中
const addPending = (config: AxiosRequestConfig): Promise<AxiosResponse> => {
  return new Promise((resolve, reject) => {
    const delay = config.timeout
    let timer: NodeJS.Timeout | null = null
    if (delay) {
      // 超时处理
      timer = setTimeout(() => {
        reject(new AxiosError('Request timeout', 'ERR_CANCELED', config as any))
      }, delay)
    }

    const callback = () => {
      timer && clearTimeout(timer)
      // 重新请求
      instance.request(config).then(resolve).catch(reject)
    }
    queue.push(callback)
  })
}

let isRefreshing = false
const refreshToken = (
  callback: (
    resolve: (value: AxiosResponse | PromiseLike<AxiosResponse>) => void,
    reject: (reason?: any) => void
  ) => void
): Promise<AxiosResponse> => {
  isRefreshing = true

  return new Promise((resolve, reject) => {
    getNewToken().then((res) => {
      isRefreshing = false

      if (res) {
        callback(resolve, reject)
        // 执行等待队列中的请求
        queue.forEach((fn) => fn())
        queue = []
        return
      }

      // 刷新 token 失败,抛出错误
      queue = []
      reject(new AxiosError('Refresh token failed'))
    })
  })
}

instance.interceptors.request.use(
  (config) => {
    // 获取 token 并设置请求头
    const token = localStorage.getItem('token')
    config.headers.Authorization = `Bearer ${token}`

    // 正在刷新 token 时,拦截请求
    if (isRefreshing) {
      return Promise.reject(new AxiosError('Refreshing token', 'ERR_REFRESHING', config))
    }

    return config
  },
  (error: AxiosError) => Promise.reject(error)
)

instance.interceptors.response.use(
  (res) => {
    const data = res.data
    if (data instanceof Blob || data.statusCode === 200) {
      return res
    }

    // 与后端约定的身份验证失败状态码
    if (data.statusCode === 401) {
      return refreshToken((resolve, reject) => {
        // 重新请求
        instance.request(res.config).then(resolve).catch(reject)
      })
    }

    return Promise.reject(new AxiosError(data.message))
  },
  (error: AxiosError) => {
    // 处理刷新 token 时拦截的请求
    if (error.code === 'ERR_REFRESHING') {
      return addPending(error.config!)
    }

    // http 状态码为 401 时,自动刷新 token
    if (error.response?.status === 401) {
      return refreshToken((resolve, reject) => {
        // 重新请求
        instance.request(error.config!).then(resolve).catch(reject)
      })
    }

    return Promise.reject(error)
  }
)

但是这样,使用同一个 axios 实例发起的刷新 token 的请求也会被拦截。因为我们是在发起前修改变量的值,当代码走到请求拦截器时,isRefreshing 的值已经为 true 了。要解决这个问题,我们可以再定义一个变量,或判断一下接口地址。

首先修改刷新方法的代码为:

let isRefreshing = false
// 新增标记变量
let isStartedRefresh = false

const refreshToken = (
  callback: (
    resolve: (value: AxiosResponse | PromiseLike<AxiosResponse>) => void,
    reject: (reason?: any) => void
  ) => void
): Promise<AxiosResponse> => {
  isRefreshing = true
  
  return new Promise((resolve, reject) => {
    getNewToken().then((res) => {
      isRefreshing = false
      // 新增标记变量
      isStartedRefresh = false

      // 刷新 token 成功,执行回调
      if (res) {
        callback(resolve, reject)
        // 执行等待队列中的请求
        queue.forEach((fn) => fn())
        queue = []
        return
      }

      // 刷新 token 失败,抛出错误
      queue = []
      reject(new AxiosError('Refresh token failed'))
    })
  })
}

然后修改请求拦截器的代码为:

instance.interceptors.request.use(
  (config) => {
    // 获取 token 并设置请求头
    const token = localStorage.getItem('token')
    config.headers.Authorization = `Bearer ${token}`

    // 拦截请求
    if (isRefreshing && isStartedRefresh) {
      return Promise.reject(new AxiosError('Refreshing token', 'ERR_REFRESHING', config))
    }
    // 发送刷新 token 请求时,标记已经开始刷新 token
    if (isRefreshing) {
      isStartedRefresh = true
    }

    return config
  },
  (error: AxiosError) => Promise.reject(error)
)

至此,我们就完成无感刷新 token 功能的开发啦~

4. 无感刷新插件

直接在 axios 的请求/响应拦截器中写,不是不行,只是其他业务代码多了后,耦合在一起,不好维护,也不好扩展。

最好还是将相关代码抽离出来,封装成一个工具类,提供相应的请求/响应拦截器方法,使用时直接注册到 axios 的请求/响应拦截器中即可。

完整工具类代码如下:

import type {
  AxiosError,
  AxiosRequestConfig,
  AxiosResponse,
  InternalAxiosRequestConfig
} from 'axios'

type IRequest = (config: AxiosRequestConfig) => Promise<AxiosResponse>
type IRefreshTokenFn = () => Promise<boolean>
type IsRefresh = (error?: AxiosError, res?: AxiosResponse) => boolean
interface IError extends AxiosError {
  config: InternalAxiosRequestConfig
}

export class AxiosRefreshTokenPlugin {
  static CODE = 'ERR_REFRESH'
  static CODE_FAILED = 'ERR_REFRESH_FAILED'
  private isRefreshing = false
  private isStartedRefresh = false
  pendingQueue: Function[]
  request: IRequest
  refreshTokenFn: IRefreshTokenFn

  constructor(refreshTokenFn: IRefreshTokenFn, request: IRequest, isRefresh?: IsRefresh) {
    this.pendingQueue = []
    this.request = request
    this.refreshTokenFn = refreshTokenFn

    // 如果传入了自定义判断是否刷新方法,则使用传入方法
    if (isRefresh) {
      this.isRefresh = isRefresh
    }
  }

  // 创建错误对象,为了不依赖 axios 的错误对象
  static createError(message: string, code: string, config: InternalAxiosRequestConfig) {
    const error = new Error(message) as IError
    error.code = code
    error.config = config
    error.isAxiosError = false
    error.toJSON = () => ({})
    error.name = 'RefreshTokenPluginError'
    return error
  }

  // 默认判断是否需要刷新 token 的方法
  isRefresh(error: AxiosError): boolean
  isRefresh(error: undefined, res: AxiosResponse): boolean
  isRefresh(error?: AxiosError, res?: AxiosResponse) {
    if (error) {
      return error.response?.status === 401
    }
    if (res) {
      return res.data?.code === 401
    }

    return false
  }

  addPending(config: InternalAxiosRequestConfig): Promise<AxiosResponse> {
    return new Promise((resolve, reject) => {
      // 超时逻辑
      const delay = config.timeout
      let timer: NodeJS.Timeout | null = null
      if (delay) {
        timer = setTimeout(() => {
          const error = AxiosRefreshTokenPlugin.createError(
            'Request timeout',
            'ERR_CANCELED',
            config
          )
          reject(error)
        }, delay)
      }

      // 将需要重新请求结果的请求放入等待队列中
      const callback = () => {
        timer && clearTimeout(timer)
        this.request(config).then(resolve).catch(reject)
      }
      this.pendingQueue.push(callback)
    })
  }

  refresh(error: AxiosError): Promise<AxiosResponse> {
    // 标记刷新 token 中
    this.isRefreshing = true

    return new Promise((resolve, reject) => {
      this.refreshTokenFn().then((res) => {
        // 标记刷新 token 完成
        this.isRefreshing = false
        this.isStartedRefresh = false

        // 刷新 token 失败,返回错误并清空等待队列
        if (!res) {
          this.pendingQueue = []
          error.message = 'Refresh token failed'
          error.code = AxiosRefreshTokenPlugin.CODE_FAILED
          reject(error)
          return
        }

        // 将当前触发刷新 token 的请求也添加到等待队列中
        this.addPending(error.config!).then(resolve).catch(reject)
        // 执行等待队列中的请求方法
        this.pendingQueue.forEach((fn) => {
          fn()
        })
        this.pendingQueue = []
      })
    })
  }

  requestInterceptor(config: InternalAxiosRequestConfig) {
    // 拦截在刷新期间发送的请求
    if (this.isRefreshing && this.isStartedRefresh) {
      const error = AxiosRefreshTokenPlugin.createError(
        'Refreshing token',
        AxiosRefreshTokenPlugin.CODE,
        config
      )
      return Promise.reject(error)
    }
    // 标记开始了刷新 token 请求,后续在刷新期间发送的请求就会走上面那个 if 的逻辑
    if (this.isRefreshing) {
      this.isStartedRefresh = true
    }

    return config
  }

  responseInterceptorFulfilled(response: AxiosResponse) {
    if (this.isRefresh(undefined, response) && !this.isRefreshing) {
      const error = AxiosRefreshTokenPlugin.createError(
        'Unauthorized',
        'ERR_UNAUTHORIZED',
        response.config
      )
      return this.refresh(error)
    }

    return response
  }

  responseInterceptorRejected(error: AxiosError) {
    // 将拦截的请求放入等待队列中,等待成功刷新 token 后,再重新发起请求并返回结果
    if (error.code === AxiosRefreshTokenPlugin.CODE) {
      return this.addPending(error.config!)
    }

    if (this.isRefresh(error) && !this.isRefreshing) {
      return this.refresh(error)
    }

    return Promise.reject(error)
  }
}

export default function createRefreshTokenPlugin(
  refreshToken: IRefreshTokenFn,
  request: IRequest,
  isRefresh?: IsRefresh
) {
  const instance = new AxiosRefreshTokenPlugin(refreshToken, request, isRefresh)

  return {
    requestInterceptor: instance.requestInterceptor.bind(instance),
    responseInterceptorFulfilled: instance.responseInterceptorFulfilled.bind(instance),
    responseInterceptorRejected: instance.responseInterceptorRejected.bind(instance)
  }
}

为什么使用 bind 绑定 this,因为注册请求/响应拦截器时,axios 会修改 this 指向。

使用示例:

import axios from 'axios'
import createRefreshTokenPlugin from '..'

const instance = axios.create()
const refreshTokenPlugin = createRefreshTokenPlugin(async () => {
    // 调用刷新token接口
    const res = await refreshToken()
    if (失败) {
        return false
    }
    
    // todo: 保存新token的逻辑...
    
    return true
}, instance.request)

instance.interceptors.request.use(refreshTokenPlugin.requestInterceptor)
// 响应拦截器,先进先出(axios 的规则)
instance.interceptors.response.use(refreshTokenPlugin.responseInterceptorFulfilled, refreshTokenPlugin.responseInterceptorRejected)

// 请求拦截器,后进先出(axios 的规则)
instance.interceptors.request.use(
(config) => {
    // 自行添加,token携带相关逻辑
    const token = getToken()
    if (token) {
        config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
)

js 版本

推荐使用 TS 版本,有更好的类型提示。

export class AxiosRefreshTokenPlugin {
  static CODE = 'ERR_REFRESH'
  static CODE_FAILED = 'ERR_REFRESH_FAILED'
  isRefreshing = false
  isStartedRefresh = false

  /**
   *
   * @param {() => Promise<boolean>} refreshTokenFn
   * @param {(config: AxiosRequestConfig) => Promise<AxiosResponse>} request
   * @param {(error?: AxiosError, res?: AxiosResponse) => boolean} [isRefresh]
   */
  constructor(refreshTokenFn, request, isRefresh) {
    this.pendingQueue = []
    this.request = request
    this.refreshTokenFn = refreshTokenFn

    if (isRefresh) {
      this.isRefresh = isRefresh
    }
  }

  /**
   *
   * @param {string} message
   * @param {string} code
   * @param {InternalAxiosRequestConfig} config
   * @returns
   */
  static createError(message, code, config) {
    const error = new Error(message)
    error.code = code
    error.config = config
    error.isAxiosError = false
    error.toJSON = () => ({})
    error.name = 'RefreshTokenPluginError'
    return error
  }

  /**
   *
   * @param {AxiosError} [error]
   * @param {AxiosResponse} [res]
   * @returns {boolean}
   */
  isRefresh(error, res) {
    if (error) {
      return error.response?.status === 401
    }
    if (res) {
      return res.data?.code === 401
    }

    return false
  }

  /**
   *
   * @param {InternalAxiosRequestConfig} config
   * @returns {Promise<AxiosResponse>}
   */
  addPending(config) {
    return new Promise((resolve, reject) => {
      const delay = config.timeout
      let timer = null
      if (delay) {
        timer = setTimeout(() => {
          const error = AxiosRefreshTokenPlugin.createError(
            'Request timeout',
            'ERR_CANCELED',
            config
          )
          reject(error)
        }, delay)
      }

      const callback = () => {
        timer && clearTimeout(timer)
        this.request(config).then(resolve).catch(reject)
      }

      this.pendingQueue.push(callback)
    })
  }

  /**
   *
   * @param {AxiosError} error
   * @returns {Promise<AxiosResponse>}
   */
  refresh(error) {
    this.isRefreshing = true

    return new Promise((resolve, reject) => {
      this.refreshTokenFn().then((res) => {
        this.isRefreshing = false
        this.isStartedRefresh = false

        if (!res) {
          this.pendingQueue = []
          error.message = 'Refresh token failed'
          error.code = AxiosRefreshTokenPlugin.CODE_FAILED
          reject(error)
          return
        }

        this.addPending(error.config).then(resolve).catch(reject)

        this.pendingQueue.forEach((fn) => {
          fn()
        })
        this.pendingQueue = []
      })
    })
  }

  /**
   *
   * @param {InternalAxiosRequestConfig} config
   * @returns
   */
  requestInterceptor(config) {
    if (this.isRefreshing && this.isStartedRefresh) {
      const error = AxiosRefreshTokenPlugin.createError(
        'Refreshing token',
        AxiosRefreshTokenPlugin.CODE,
        config
      )
      return Promise.reject(error)
    }
    if (this.isRefreshing) {
      this.isStartedRefresh = true
    }

    return config
  }

  /**
   *
   * @param {AxiosResponse} response
   * @returns
   */
  responseInterceptorFulfilled(response) {
    if (this.isRefresh(undefined, response) && !this.isRefreshing) {
      const error = AxiosRefreshTokenPlugin.createError(
        'Unauthorized',
        'ERR_UNAUTHORIZED',
        response.config
      )
      return this.refresh(error)
    }

    return response
  }

  /**
   *
   * @param {AxiosError} error
   * @returns
   */
  responseInterceptorRejected(error) {
    if (error.code === AxiosRefreshTokenPlugin.CODE) {
      return this.addPending(error.config)
    }

    if (this.isRefresh(error) && !this.isRefreshing) {
      return this.refresh(error)
    }

    return Promise.reject(error)
  }
}

/**
 *
 * @param {() => Promise<boolean>} refreshTokenFn
 * @param {(config: AxiosRequestConfig) => Promise<AxiosResponse>} request
 * @param {(error?: AxiosError, res?: AxiosResponse) => boolean} [isRefresh]
 */
export default function createRefreshTokenPlugin(refreshToken, request, isRefresh) {
  const instance = new AxiosRefreshTokenPlugin(refreshToken, request, isRefresh)

  return {
    requestInterceptor: instance.requestInterceptor.bind(instance),
    responseInterceptorFulfilled: instance.responseInterceptorFulfilled.bind(instance),
    responseInterceptorRejected: instance.responseInterceptorRejected.bind(instance)
  }
}

赶紧去试试吧~