项目中无感刷新token的思考和实现

前言

最近在搭建一个新的项目中,对于 用户身份验证 这块, 后端说用常规的方案:AccessToken + RefreshToken 的无感刷新,引发了一顿思考。

老场景:

经常在项目中遇到,用户正在操作,突然跳转到登录页面,提示需要重新登录,这是因为AccessToken过期了。

对应的解决方案:

试想:如果为了安全设置 AccessToken 每一小时刷新一次,那么也就意味着用户每小时就需要登录一次,体验感很差;如果将 AccessToken 过期时间设置为一个月,则安全性又大大降低。

所以,除了有 AccessToken,还需要加入 RefreshToken。RefreshToken 的存在就是为了降低 AccessToken 的有效期,避免 AccessToken 有效期太长导致泄漏造成更大的危害。它只是一种相对的安全的设置,并不保证完全的安全。同时也是为了让用户能够保持登录的状态,不需要去重新登录的一种用户体验解决方案。

原理

  • AccessToken 设置有效期为一天,RefreshToken 设置有效期为一个月。
  • 用户首次登录的时候,服务端返回 AccessToken + RefreshToken,前端把这两个token保存在本地。
  • 客户端每次发起请求时,HTTP请求头携带 Authorization=Bearer+ AccessToken
  • 如果请求401了,说明 AccessToken 过期了。
  • 客户端把401的这些请求暂存起来,放在待请求队列里。
  • 客户端需要重新发起新的请求去获取最新的 AccessToken,这个新的请求携带 Authorization=Bearer+ RefreshToken
    • 请求成功: 获取到新的 AccessToken 后,再将待请求队列里的任务依次发起请求,携带最新的 AccessToken。
    • 请求401:说明 RefreshToken 也过期了,这时候才需要用户重新登录。

本文可供借鉴的点

  • 方法的封装:各司其职,单一原则。
  • 流程完整性:包含待请求队列的管理。
  • 无感刷新的思路。

实现

基于 axios@1.4.0 进行封装,篇幅有限,只能摘取部分代码讲解一下,完整的封装放在 GitHub 上。

封装 request 请发方法

  • 初步创建一个 OriginAxios 类,并封装一个 request 请发方法:
ts 复制代码
export default class OriginAxios {
  private axiosInstance: AxiosInstance // axios实例
  private readonly options: CreateAxiosOptions // 请求选项
  private isRefreshing = false // 是否正在刷新token,开启请求队列
  private requestQueue: RequestQueueItem[] // 待请求队列

  constructor(options: CreateAxiosOptions) {
    this.options = options
    this.axiosInstance = axios.create(options)
    this.isRefreshing = false
    this.requestQueue = []
  }
  
    request<T = any>(config: AxiosRequestConfig, options?: RequestOptions): Promise<T> {
        let conf: CreateAxiosOptions = config
        const { requestOptions } = this.options
        // 将默认配置和用户配置组合
        const opt: RequestOptions = Object.assign({}, requestOptions, options)
        return new Promise((resolve, reject) => {
          this.requestPipeline(this.axiosInstance, conf, opt, resolve, reject)
        })
  }
}

封装请求拦截器

  • 封装请求拦截器interceptors:主要处理请求头Authorization
ts 复制代码
  interceptors(instance: AxiosInstance): void {
   const { requestInterceptors } = this.getTransform() || {}
   // 请求拦截
   instance.interceptors.request.use(
     (config) => {
     // 请求头设置Authorization
       if (requestInterceptors && isFunction(requestInterceptors)) {
         config = requestInterceptors(config, this.options)
       }
       return config
     },
     (error) => {
       return Promise.reject(error)
     }
   )
   // 响应拦截
   instance.interceptors.response.use(
     (res: AxiosResponse<any>) => {
       return res
     },
     (error: AxiosError) => {
       throw error
     }
   )
 }
 
 /**
  * @description: 添加请求头Authorization
  */
 requestInterceptors: (config, options) => { 
   const token = getToken()
   if (token && (config as Recordable)?.requestOptions?.withToken !== false) {
     // jwt token
     ;(config as Recordable).headers.Authorization = options.authenticationScheme
       ? `${options.authenticationScheme} ${token}`
       : token
   }
   return config
 },

封装请求管道处理方法

  • 封装请求管道处理方法requestPipeline

    封装起来的原因是: requestQueue待请求队列里的任务,执行的时候也直接调用这个方法即可;并且这样也可以把回调的resolve和reject,流转到请求拦截器中,便于处理响应。

    当状态码401时,判断当前是否正在刷新 token 中 (isRefreshing)

    • 是:将请求放到待请求队列里,等待刷新token之后,重新执行。
    • 否:发起请求,刷新token。
ts 复制代码
  // 请求管道处理具体细节
 requestPipeline<T = unknown>(
   instance: AxiosInstance,
   conf: CreateAxiosOptions,
   options: RequestOptions,
   resolve: (data: Promise<T>) => void,
   reject: (data: unknown) => void
 ): void {

   this.interceptors(instance)

   instance<any, AxiosResponse<Result>>(conf)
     .then((res: AxiosResponse<Result>) => {
       resolve(res.data as unknown as Promise<T>)
     })
     .catch((error) => {
       const code = error?.response?.status
       if (code === 401) {
         if (!this.isRefreshing) {
           this.isRefreshing = true
           this.refreshToken()
         } else {
           this.addRequestQueueForRefreshToken<T>(instance, conf, options, resolve, reject)
         }
       } else {
         //...异常状态码响应处理
       }
     })
 }

封装refreshToken方法

  • 发起请求,刷新 AccessToken,并携带 Authorization=Bearer+ RefreshToken
  • 请求结果:
    • 成功:执行待请求队列的任务
    • 失败:提示重新登录
ts 复制代码
  // 刷新token
 refreshToken() {
   const loginStore = useLoginStoreWithOut()
   this.postRefreshTokenFunc()
     .then((res) => {
       if (res.data) {
         const data = res.data
         loginStore.updateToken(data.data)
         this.requestQueueStartAfterRefreshToken()
       }
     })
     .catch(() => {
       loginStore.logout()
       ElMessage.error('登录已失效,需要重新登录')
     })
     .finally(() => {
       this.isRefreshing = false
     })
 }
 postRefreshTokenFunc() {
   const params = {
     // 设备Canvas指纹
     clientId: getCanvasFingerprint(),
   }
   const token = getRefreshToken()
   return axios.post(import.meta.env.VITE__APP_BASE_URL + '/auth/refresh', params, {
     headers: {
       Authorization: `Bearer ${token}`,
     },
   })
 }

封装添加任务到待请求队列方法

  • 添加请求到等待队列
ts 复制代码
 addRequestQueueForRefreshToken<T = unknown>(
   instance: AxiosInstance,
   conf: CreateAxiosOptions,
   options: RequestOptions,
   resolve: (data: Promise<T>) => void,
   reject: (data: unknown) => void
 ): void {
   this.requestQueue.push({
     instance,
     conf,
     options,
     // eslint-disable-next-line @typescript-eslint/ban-ts-comment
     // @ts-ignore
     resolve,
     reject,
   })
 }

封装 执行待请求队列 方法

  • 刷新token成功,等待队列开始请求
ts 复制代码
 requestQueueStartAfterRefreshToken(): void {
   let requestQueueItem = this.requestQueue.pop()

   while (requestQueueItem) {
     const { options, instance, conf, resolve, reject } = requestQueueItem

     this.requestPipeline(instance, conf, options, resolve, reject)

     requestQueueItem = this.requestQueue.pop()
   }
 }

至此,整个封装工作也已经结束了。

结语

完整的封装放在 GitHub

只是提供一种思路,技术上没什么难点,有思考就写下来,共勉。

相关推荐
cooldream20093 天前
使用Axios函数库进行网络请求的使用指南
前端·vue·axios
京东菜鸟全球通快递小哥3 天前
Axios取消重复请求,但能让最新请求作为最终返回,且能共享状态 ,不知小伙您有没有尝到真香~
前端·javascript·axios
cooldream20095 天前
使用 Axios 拦截器优化 HTTP 请求与响应的实践
vue·axios·拦截器
曾经的三心草6 天前
JavaWeb之AJAX
java·ajax·json·axios·web
JerryXZR6 天前
Javascript 高级事件编程 - Axios & fetch
javascript·ecmascript·axios·fetch
前端李易安15 天前
手写一个axios方法
前端·vue.js·axios
前端李易安17 天前
如何封装一个axios,封装axios有哪些好处
前端·vue.js·axios
坡道口1 个月前
react-query用户哭了:token认证还能这么玩?
前端·javascript·axios
Cder1 个月前
如何解决前端请求并发和请求覆盖?
前端·axios
坡道口1 个月前
前端大佬都在用的usePagination究竟有多强?
前端·javascript·axios