前言

主要步骤:

  1. 在nuxt中基于$fetch封装请求工具http类;
  2. 总的思路是请求方法为返回一个Promise;正常200响应的时在onResponse回到中执行resolve(data)返回数据;401响应时直接执行refreshToken方法请求刷新token,成功则重新执行因为401报错的请求;然后执行resolve(data) 把当前Promise状态变为fulfilled;
  3. 主要难点是当token过期时,已经发出了多个失败的请求,重新请求时如一一响应;正在刷新中时会把多个请求的configurl放进一个全局请求队列中,把当前的请求方法的Promise中的resolve方法也一并放入队里中并且返回(请求的Promise状态还是处于pending);
  4. refreshToken接口成功响应之后,循环全局队列一一重新发请求fn(apiFetch(url, config))并且都执行fn变更Promise状态后清空全局队列;失败时则执行重新登录逻辑。

一、前端实现

前端项目请求工具方法封装;
具体项目请参考 request.js

import { baseUrl } from '~~/config'
import { messageDanger } from '~~/utils/toast'
import { TokenKey, RefreshTokenKey } from '@/utils/cookie'
interface PendingTask {
  config: any
  url: string
  fn: Function
}

// 无感刷新token let refreshing = false // 是否正在刷新token let queue: PendingTask[] = []

// async/await函数错误统一处理 const [err, data] = await checkFile({ hash, }) export const awaitWrap = <T, U = any>(promise: Promise<T>): Promise<[U | null, T | null]> => { return promise.then<[null, T]>((data: T) => [null, data]).catch<[U, null]>(err => [err, null]) } // 创建一个实例 const apiFetch = $fetch.create({ baseURL: baseUrl, }) const $http = async (url: string, options: any): Promise<ApiResponse> => { const { method = 'GET', params = {}, body = {}, headers, } = options const config: any = { headers: { ...headers, }, credentials: 'include', // session需要携带cookie method, /* fetch中 params和body不能同时存在 */ params: ['GET', 'DELETE'].includes(method.toUpperCase()) ? params : undefined, body: ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase()) ? body : undefined, onRequest (ctx: any) { ctx.options.headers.Authorization = getToken() }, } return await new Promise((resolve, reject) => { apiFetch<ApiResponse>(url, { ...config, onResponse (ctx) { const status: number = ctx.response.status if (status === 200 || status === 201) { resolve(ctx.response._data) } }, async onResponseError (ctx: any) { console.log('onResponseError', ctx) // console.log('status', ctx.response) const status: number = ctx.response.status const { url, } = ctx.response if (refreshing) { queue.push({ config, url, // 作用是把当前状态为pending的promise放进全局数组中 // 刷新完token之后再把对应的promise状态改为fulfilled, // 这样之前报401响应的请求没有变更状态,刷新token再变为fulfilled响应后执行等待的相关操作 fn: resolve, }) // return为关键,不执行下面代码,不然下面resolve变更promise状态, // 造成每个promise都会执行foreach请求多个 return } try { if (status === 401 && !url.includes('/user/refresh')) { refreshing = true const res = await refreshToken() refreshing = false if (res) { queue.forEach(({ config, url, fn, }) => { fn(apiFetch(url, config)) }) console.log('queue', queue) queue = [] resolve(apiFetch(url, config)) } else { // 清除token const token = useToken() token.value = '' localStorage.setItem(TokenKey, '') console.error(ctx.response._data.message) } } else { // 其他状态码直接变为reject messageDanger(ctx.response._data.message || '') reject(ctx.response._data) } } catch (error) { reject(error) } }, }) }) } // 获取 token const getToken = () => { const tk = useToken() let token = '' if (tk.value) { token = 'Bearer ' + tk.value } // console.log({ token }); return token } const get = async (url: string, params = {}): Promise<any> => { return await $http(url, { method: 'GET', params, }).then(res => res.data) }

const del = async (url: string, params = {}): Promise<any> => { return await $http(url, { method: 'DELETE', params, }).then(res => res.data) }

const post = async (url: string, params = {}): Promise<any> => { return await $http(url, { method: 'POST', body: params, }).then(res => res.data) }

const put = async (url: string, params = {}): Promise<any> => { return await $http(url, { method: 'PUT', body: params, }).then(res => res.data) } // 刷新token async function refreshToken () { const token = useToken() const userInfo = useUserInfo() const accessToken = localStorage.getItem(RefreshTokenKey) || '' const res = await get('/user/refresh', { token: accessToken, }) localStorage.setItem(TokenKey, res.accessToken) localStorage.setItem(RefreshTokenKey, res.refreshToken) token.value = res.accessToken const { nickname, homepage, intro, avatar, id: uid, role, } = res.user userInfo.value = { nickname, homepage, intro, avatar, uid, role, } return res }

export default { http: $http, get, post, put, del, awaitWrap, }

二、后端实现

  1. 接口一:验证用户登录之后生成一个过期时间为0.5h的accessToken和一个过期时间为7d的refreshToken;
  2. 接口二:当accessToken过期之后,用refreshToken请求该接口刷新两个token的过期时间;具体请求参考后端user模块代码 UserService
 // 生成 token
  async certificate(user: User) {
    // 设置在token中的信息
    const payload = {
      id: user.id,
      nickname: user.nickname,
      mobile: user.mobile,
      role: user.role,
    };
    // console.log(payload);
    // 兼容老登录token
    const token = this.jwtService.sign(payload);
    const accessToken = this.jwtService.sign(payload, { expiresIn: '2s' });
    const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' });
    return {
      token,
      accessToken,
      refreshToken,
    };
  }

async login(loginDTO: LoginDTO): Promise<any> { // 用户信息 const user = await this.checkLoginForm(loginDTO); // 密码和加盐不返回 delete user.password; delete user.salt; const data = await this.certificate(user); return { info: { ...data, user, }, }; } /**

  • 根据refreshToken刷新accessToken
  • @param accessToken */ async refresh(token: string) { try { let user = this.jwtService.verify(token); const data = await this.certificate(user); user = await this.findById(user.id); return { ...data, user, message: '刷新token成功', }; } catch (e) { throw new UnauthorizedException('token 失效,请重新登录'); } }

链接

Demo页面

三、前端axios实现

import { showFailToast } from 'vant'
import axios from 'axios'
import { getToken } from './auth'
// import * as crypto from './crypto'
import 'vant/es/toast/style'
import { TOKEN_KEY, TOKEN_KEY2 } from '@/utils/auth'

// 默认 axios 实例请求配置 const configDefault = { headers: {}, timeout: 0, baseURL: import.meta.env.VITE_BASE_API, data: {}, withCredentials: true } const SUCCESSCODE = 200 // 无感刷新token let refreshing = false // 是否正在刷新token let queue = []

// // 请求内容加密 // const requestEncrypt = (data) =>{ // console.log('请求报文: ',data) // data = { // key: crypto.sm2Encrypt(crypto.SM4KEY), // content: crypto.sm4Encrypt(data), // } // return data // } // // 响应内容解密 // const responseDecrypt = (response) =>{ // // console.log('response==================>',response) // const { key, content } = response.data // let resData = crypto.sm4Decrypt(content,key) // console.log('响应报文: ',resData) // return resData // } class Http { // 当前实例 static axiosInstance // 请求配置 static axiosConfigDefault

// 请求拦截 httpInterceptorsRequest() { this.axiosInstance.interceptors.request.use( (config) => { // 发送请求前,可在此携带 token const token = getToken() if (token) { // config.headers['Authorization'] = 'Bearer ' + token config.headers['Jwt-Token'] = token } return config }, (error) => { showFailToast(error.message) return Promise.reject(error) } ) }

// 响应拦截 httpInterceptorsResponse() { this.axiosInstance.interceptors.response.use( (response) => { // 开启加密 // let resData = responseDecrypt(response) let resData = response.data

    <span class="hljs-comment">// 与后端协定的返回字段</span>
    <span class="hljs-keyword">const</span> { code, message } = resData
    <span class="hljs-comment">// 判断请求是否成功</span>
    <span class="hljs-keyword">const</span> isSuccess = response &amp;&amp; <span class="hljs-title class_">Reflect</span>.<span class="hljs-title function_">has</span>(resData, <span class="hljs-string">&#x27;code&#x27;</span>) &amp;&amp; code === <span class="hljs-variable constant_">SUCCESSCODE</span>
    <span class="hljs-comment">// console.log(code, message, resData ,isSuccess,&#x27;-------------------&#x27;)</span>
    <span class="hljs-keyword">if</span> (isSuccess) {
      <span class="hljs-keyword">return</span> resData.<span class="hljs-property">data</span>
    } <span class="hljs-keyword">else</span> {
      <span class="hljs-comment">// 处理请求错误</span>
      <span class="hljs-title function_">showFailToast</span>(message)
      <span class="hljs-keyword">return</span> <span class="hljs-title class_">Promise</span>.<span class="hljs-title function_">reject</span>(resData.<span class="hljs-property">data</span>)
    }
  },
  <span class="hljs-keyword">async</span> (error) =&gt; {
    <span class="hljs-keyword">try</span> {
      <span class="hljs-comment">// console.log(error)</span>
      <span class="hljs-keyword">let</span> { status, data, config } = error.<span class="hljs-property">response</span> || {}
      <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(<span class="hljs-string">&#x27;status========================&gt;&#x27;</span>, status)
      <span class="hljs-keyword">if</span> (refreshing) {
        <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Promise</span>(<span class="hljs-function">(<span class="hljs-params">resolve</span>) =&gt;</span> {
          queue.<span class="hljs-title function_">push</span>({
            config,
            resolve
          })
        })
      }

      <span class="hljs-keyword">let</span> message = <span class="hljs-string">&#x27;&#x27;</span>
      <span class="hljs-comment">// HTTP 状态码</span>
      <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(<span class="hljs-string">&#x27;==================&gt;response.data&#x27;</span>, data)
      <span class="hljs-keyword">switch</span> (status) {
        <span class="hljs-keyword">case</span> <span class="hljs-number">400</span>:
          message = <span class="hljs-string">&#x27;请求错误&#x27;</span>
          <span class="hljs-keyword">break</span>
        <span class="hljs-keyword">case</span> <span class="hljs-number">401</span>:
          message = <span class="hljs-string">&#x27;未授权,请登录&#x27;</span>
          <span class="hljs-keyword">if</span> (status === <span class="hljs-number">401</span> &amp;&amp; !config.<span class="hljs-property">url</span>.<span class="hljs-title function_">includes</span>(<span class="hljs-string">&#x27;/user/refresh&#x27;</span>)) {
            refreshing = <span class="hljs-literal">true</span>

            <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> <span class="hljs-title function_">refreshToken</span>()

            refreshing = <span class="hljs-literal">false</span>

            <span class="hljs-keyword">if</span> (res) {
              queue.<span class="hljs-title function_">forEach</span>(<span class="hljs-function">(<span class="hljs-params">{ config, resolve }</span>) =&gt;</span> {
                <span class="hljs-title function_">resolve</span>(<span class="hljs-variable language_">this</span>.<span class="hljs-title function_">axiosInstance</span>(config))
              })
              queue = []
              <span class="hljs-keyword">return</span> <span class="hljs-variable language_">this</span>.<span class="hljs-title function_">axiosInstance</span>(config)
            } <span class="hljs-keyword">else</span> {
              message = data.<span class="hljs-property">message</span> || <span class="hljs-string">&#x27;登录过期,请重新登录&#x27;</span>
            }
          } <span class="hljs-keyword">else</span> {
            <span class="hljs-keyword">return</span> error.<span class="hljs-property">response</span>
          }
          <span class="hljs-keyword">break</span>
        <span class="hljs-keyword">case</span> <span class="hljs-number">403</span>:
          message = data.<span class="hljs-property">message</span> || <span class="hljs-string">&#x27;拒绝访问&#x27;</span>
          <span class="hljs-keyword">break</span>
        <span class="hljs-keyword">case</span> <span class="hljs-number">404</span>:
          message = data.<span class="hljs-property">message</span> || <span class="hljs-string">`请求地址出错: <span class="hljs-subst">${error.response?.config?.url}</span>`</span>
          <span class="hljs-keyword">break</span>
        <span class="hljs-keyword">case</span> <span class="hljs-number">408</span>:
          message = data.<span class="hljs-property">message</span> || <span class="hljs-string">&#x27;请求超时&#x27;</span>
          <span class="hljs-keyword">break</span>
        <span class="hljs-keyword">case</span> <span class="hljs-number">500</span>:
          message = data.<span class="hljs-property">message</span> || <span class="hljs-string">&#x27;服务器内部错误&#x27;</span>
          <span class="hljs-keyword">break</span>
        <span class="hljs-keyword">case</span> <span class="hljs-number">501</span>:
          message = data.<span class="hljs-property">message</span> || <span class="hljs-string">&#x27;服务未实现&#x27;</span>
          <span class="hljs-keyword">break</span>
        <span class="hljs-keyword">case</span> <span class="hljs-number">502</span>:
          message = data.<span class="hljs-property">message</span> || <span class="hljs-string">&#x27;网关错误&#x27;</span>
          <span class="hljs-keyword">break</span>
        <span class="hljs-keyword">case</span> <span class="hljs-number">503</span>:
          message = data.<span class="hljs-property">message</span> || <span class="hljs-string">&#x27;服务不可用&#x27;</span>
          <span class="hljs-keyword">break</span>
        <span class="hljs-keyword">case</span> <span class="hljs-number">504</span>:
          message = data.<span class="hljs-property">message</span> || <span class="hljs-string">&#x27;网关超时&#x27;</span>
          <span class="hljs-keyword">break</span>
        <span class="hljs-keyword">case</span> <span class="hljs-number">505</span>:
          message = data.<span class="hljs-property">message</span> || <span class="hljs-string">&#x27;HTTP版本不受支持&#x27;</span>
          <span class="hljs-keyword">break</span>
        <span class="hljs-attr">default</span>:
          message = data.<span class="hljs-property">message</span> || <span class="hljs-string">&#x27;网络连接故障&#x27;</span>
      }

      <span class="hljs-title function_">showFailToast</span>(message)
      <span class="hljs-keyword">return</span> <span class="hljs-title class_">Promise</span>.<span class="hljs-title function_">reject</span>(error)
    } <span class="hljs-keyword">catch</span> (error) {
      <span class="hljs-keyword">return</span> <span class="hljs-title class_">Promise</span>.<span class="hljs-title function_">reject</span>(<span class="hljs-string">&#x27;网络连接故障&#x27;</span>)
    }
  }
)

}

constructor(config) { this.axiosConfigDefault = config this.axiosInstance = axios.create(config) this.httpInterceptorsRequest() this.httpInterceptorsResponse() }

// 通用请求函数 request(paramConfig) { const config = { ...this.axiosConfigDefault, ...paramConfig } return new Promise((resolve, reject) => { this.axiosInstance .request(config) .then((response) => { resolve(response) }) .catch((error) => { reject(error) }) }) } post(url, data) { // data = requestEncrypt(data) return this.request({ url, method: 'post', data }) } patch(url, data) { return this.request({ url, method: 'patch', data }) } get(url, params) { return this.request({ url, method: 'get', params }) } del(url, params) { return this.request({ url, method: 'delete', params }) } } const refreshToken = async () => { const refreshToken = localStorage.getItem(TOKEN_KEY2) || '' const res = await http.get('/mobile/refreshToken', { token: refreshToken }) // console.log(res,'refreshToken>>>>>>>') localStorage.setItem(TOKEN_KEY, res.accessToken) localStorage.setItem(TOKEN_KEY2, res.refreshToken) return res } export const http = new Http(configDefault)