双axios双token无感刷新技术方案与实现(前端部分)

1,484 阅读4分钟

前置知识:
JWT是什么:JSON Web Token 入门教程 - 阮一峰的网络日志 (ruanyifeng.com)
token是什么:3分钟彻底搞懂什么是 token - 掘金 (juejin.cn)

双token技术讲解

本文将阐述一种使用两个axios实例来完成基于jwt的双token无感刷新的登录方案。该技术方案中双token指的是访问令牌(Access Token)和刷新令牌(Refresh Token)。

这种机制旨在解决:

  1. 单个长期有效令牌导致登录操作复杂度不够高产生的安全问题,(提高安全性)
  2. 并提供一种方式让用户在填写表单等场景中不返回登录页并保持登录状态(优化用户体验)
  3. 该方法能拓展为权限管理方案,但本文不细究

作为前端,需要根据后端的两个接口来完成前端部分的双token无感刷新登录

  1. 登录接口,获取双token

传参:

// url:/user/login
// body:
{
  "username": "",
  "password": ""
}

响应:

// data:
{	
  // 短token
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoieGhmIiwiaWF0IjoxNzI4ODA5Mjc5LCJleHAiOjE3Mjg4MDkyODl9.oDWnXnTs9WBk-Jkkl4RYol9fsWTRzjAcLbG62DXgjpY",
  // 刷新token
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsImlhdCI6MTcyODgwOTI3OSwiZXhwIjoxNzI4OTE3Mjc5fQ.Ox87koH_bhoWWw1UOBJxKGu0ucH1xAntSoMvVB-icm4"
}
  1. 刷新短token接口,携带长token去更换短token

传参:

// url:/auth/refresh
// Header:
Authorization: Bearer {{refresh_token}}

响应

// data:
{	
  // 短token
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoieGhmIiwiaWF0IjoxNzI4ODExODkzLCJleHAiOjE3Mjg4MTE5MDN9.SzaMDLRjI0lt2ma6ZzofzTvAlTUsxnxbMAUNaxoKj24",
  // 刷新token
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsImlhdCI6MTcyODgxMTg5MywiZXhwIjoxNzI4ODEyNDkzfQ.1XHI-1f-vFGglSoV1LmpCgdblzXqZhsn9lpYfxtS16c"
}

这两个接口是前端必须依赖的接口,而其他接口都将携带名为access_token的短token与后端交换资源

交互逻辑如下图:

技术选型:使用双axios实例完成双token登录方案详解

Q: 为什么要使用axios库

A: 上述方案可以单纯的使用fetch等原生网络请求实现,但是利用axios库旨在利用它的响应请求拦截器,这能更方便的实现上述方案。

Q: 为什么要使用双axios实例

A: 在单axios实例开发中,出现了一个问题。该问题具体为:当使用刷新短token的接口时,该接口也将步入axios的响应请求拦截器中,而在axios响应请求中,响应拦截器有对错误请求的处理,请求拦截器有对添加请求头的业务逻辑。若在响应拦截器中添加当401未授权的时调用刷新短token接口时出现长token过期时,状态码也是401,会重新步入该响应拦截器的逻辑,导致递归调用该刷新短token的逻辑中。

Q: 为什么不添加if来排除这个分支逻辑

A: 添加if了就不优雅了,会导致业务逻辑糅杂。双axios即可解决该问题,且符合分离思想。


答疑结束,开始实现环节~

我们只需要编写两个类即可,一个类是常规的axios的二次封装,一个类是刷新短token的业务逻辑类

axios二次封装类(AxiosRequest)

import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'
import RefresherAxiosRequest from './RefresherAxiosRequest';

type BaseResponse<T> = {
  data: T,
  status: string
}

class AxiosRequest {
  private axiosInstance: AxiosInstance

  constructor() {
    this.axiosInstance = axios.create({
      baseURL: '/api',
      timeout: 50000
    })
    this.interceptorsRequest()
    this.interceptorsResponse()
  }

  // 请求拦截器
  private interceptorsRequest() {
    this.axiosInstance.interceptors.request.use((config) => {
      const accessToken = localStorage.getItem("access_token")
      config.headers.authorization = 'Bearer ' + accessToken;
      return config;
    }, function (error) {
      return Promise.reject(error);
    });
  }

  // 响应拦截器
  private interceptorsResponse() {
    this.axiosInstance.interceptors.response.use((response) => { // 2xx 范围内的状态码都会触发该函数
      return response;
    }, async (error) => { // 超出 2xx 范围的状态码都会触发该函数
      // 当状态码为401时,调用刷新token的方法
      if(error.response?.status === 401 && await RefresherAxiosRequest.refresh('/auth/refresh')){
        return Promise.reject(error); // 返回错误,可重发请求
      }
    });
  }

  async request<T>(axiosRequestConfig: AxiosRequestConfig): Promise<BaseResponse<T>> {
    // 当正在刷新token,将当前请求放入暂存队列中
    if (RefresherAxiosRequest.isRefreshing) {
      console.log('当前请求正在刷新token,暂存请求')
      return new Promise((resolve, reject) => {
        RefresherAxiosRequest.temporaryQueue.push(this.axiosInstance(axiosRequestConfig).then(res => {
          resolve({
            status: res.status.toString(),
            data: res.data,
          });
        }).catch(reject))// 最后会在promise.allSettled处执行
      })
    }
    return new Promise((resolve) => {
      this.axiosInstance(axiosRequestConfig).catch(() => {
        // 出现非200状态的错误,就重新发请求
        return this.request(axiosRequestConfig)
      }).then(res => {
        resolve({
          status: res.status.toString(),
          data: res.data,
        });
      })
    })
  }

  // 二次封装的各种请求方法(很常规不用看了)
  async get<T>(url: string, config: AxiosRequestConfig = {}): Promise<BaseResponse<T>> {
    const { data, status } = await this.request<T>({
      ...config,
      url,
      method: 'get',
    });
    return { data, status };
  }

  async post<T>(url: string, body?: object, config?: AxiosRequestConfig): Promise<BaseResponse<T>> {
    const { data, status } = await this.request<T>({
      ...config,
      url,
      method: 'post',
      data: body,
    });
    return { data, status };
  }

  async put<T>(url: string, body: object | FormData, config?: AxiosRequestConfig): Promise<BaseResponse<T>> {
    const { data, status } = await this.request<T>({
      ...config,
      url,
      method: 'put',
    });
    return { data, status };
  }

  async del<T>(url: string, config?: AxiosRequestConfig): Promise<BaseResponse<T>> {
    const { data, status } = await this.request<T>({
      ...config,
      url,
      method: 'delete',
    });
    return { data, status };
  }
}

export default new AxiosRequest

刷新短token业务逻辑类(RefresherAxiosRequest)

import axios, { AxiosRequestConfig, AxiosInstance } from 'axios'

class RefresherAxiosRequest {
  private refresherAxiosInstance: AxiosInstance // 专门刷新token的axios实例
  public isRefreshing: boolean = false // 是否正在刷新token
  public temporaryQueue: Array<Promise<any>> = []; // 暂存队列,当处于刷新token的时间中时,后续请求全部push进该队列中

  constructor() {
    this.refresherAxiosInstance = axios.create({
      baseURL: '/api',
      timeout: 50000
    })
    this.interceptorsRequest()
    this.interceptorsResponse()
  }

  // 请求拦截器
  private interceptorsRequest() {
    this.refresherAxiosInstance.interceptors.request.use((config) => {
      return config;
    }, function (error) {
      return Promise.reject(error);
    });
  }

  // 响应拦截器
  private interceptorsResponse() {
    this.refresherAxiosInstance.interceptors.response.use((response) => {
      return response;
    }, async (error) => {
      if(error.response?.status){
        this.temporaryQueue = [];
        console.log('长token失效,跳转登录页面')
      }
    });
  }

  private request(axiosRequestConfig: AxiosRequestConfig): Promise<any> {
    return this.refresherAxiosInstance(axiosRequestConfig);
  }

  // 刷新短token的业务逻辑
  public async refresh(url: string = '/auth/refresh', config: AxiosRequestConfig = {}): Promise<boolean> {
    try {
      const refreshToken = localStorage.getItem('refresh_token')
      this.isRefreshing = true; // 标记正在刷新token
      const { data, status } = await this.request({
        ...config,
        url,
        method: 'get',
        headers: {
          authorization: 'Bearer ' + refreshToken, // 把refreshToken放到请求头里面
        },
      });
      Promise.allSettled(this.temporaryQueue)
      localStorage.setItem('access_token', data?.access_token);
      localStorage.setItem('refresh_token', data?.refresh_token);
      return Promise.resolve(true);
    }
    catch (error) {
      console.warn('刷新token失败', error)
      return Promise.resolve(false);
    }
    finally {
      this.isRefreshing = false; // 标记刷新token完成
    }
  }
}

export default new RefresherAxiosRequest

以上就是本文的全部内容了~

若想CR我的代码,请点击如下链接:

FengBuPi/Dual-token-silent-refresh: 双axios双token无感刷新技术方案demo (github.com)

特别感谢元兮大大的大力支持

双axios双token无感刷新技术方案与实现(nestjs后端部分) - 掘金 (juejin.cn)