一文彻底搞懂无感刷新——双Token+并发锁机制

1,378 阅读5分钟

双 Token 刷新机制 是一种用于身份验证和授权的安全机制,通常用于现代 Web 应用和 API 中。它的核心思想是使用两种 Token:Access Token 和 Refresh Token,分别用于短期和长期的权限管理。通过这种机制,可以在保证安全性的同时,提升用户体验。

1. 双 Token 的作用

(1)Access Token(访问令牌)

  • 作用:用于访问受保护的资源(如 API 接口)。

  • 特点

    • 有效期较短(如 15 分钟到 1 小时)。
    • 存储在客户端(如 localStorage、内存或 Cookie 中)。
    • 每次请求时,客户端需要将 Access Token 放在请求头中发送给服务器。

(2)Refresh Token(刷新令牌)

  • 作用:用于在 Access Token 过期后,获取新的 Access Token。

  • 特点

    • 有效期较长(如 7 天到 30 天)。
    • 存储在安全的 HTTP-only Cookie 中,避免被 JavaScript 访问。
    • 仅在 Access Token 过期时使用,不会随普通请求发送。

2. 双 Token 刷新机制的工作流程

  1. 用户登录

    • 用户输入用户名和密码,发送登录请求。
    • 服务器验证成功后,返回 Access Token 和 Refresh Token
  2. 存储 Token

    • Access Token:存储在客户端(如 localStorage 或内存)。
    • Refresh Token:存储在 HTTP-only Cookie 中。
  3. 访问受保护资源

    • 客户端在每次请求时,将 Access Token 放在请求头中发送给服务器。
    • 服务器验证 Access Token 的有效性,如果有效,则返回请求的数据。
  4. Access Token 过期

    • 如果 Access Token 过期,服务器会返回 401 状态码(未授权)。
    • 客户端检测到 401 错误后,使用 Refresh Token 向服务器请求新的 Access Token。
  5. 刷新 Access Token

    • 客户端发送 Refresh Token 到服务器的刷新接口。
    • 服务器验证 Refresh Token 的有效性,如果有效,则返回新的 Access Token。
    • 客户端更新本地的 Access Token,并重试之前的请求。
  6. Refresh Token 过期

    • 如果 Refresh Token 也过期,客户端需要重新登录。

3. 双 Token 刷新机制的优势

  1. 安全性

    • Access Token 有效期短,即使泄露,攻击者也只能在短时间内使用。
    • Refresh Token 存储在 HTTP-only Cookie 中,避免被 XSS 攻击窃取。
  2. 用户体验

    • 用户无需频繁登录,Refresh Token 可以自动刷新 Access Token,保持用户会话。
  3. 灵活性

    • 可以根据业务需求调整 Access Token 和 Refresh Token 的有效期。
  4. 无状态性

    • 服务器无需维护会话状态,所有信息都包含在 Token 中。

4.实现过程

const axiosInstance = axios.create({
  baseURL: isHttpProxy ? '/proxy-url' : import.meta.env.VITE_BASE_API,
  timeout: DEFAULT_OPTIONS.timeout,
})

// 定义一个tokenKey,用于storage的key
const TOKEN_KEY = 'access_token'

// token刷新接口地址
const REFRESH_TOKEN_API = '/api/Auth/RefreshToken'

// 是否正在刷新token
let isRefreshing = false

// 定义一个队列,用于存储失败的请求
let failedQueue: any[] = []

// 刷新token
async function refreshToken() {
  try {
    // 发送 HTTP-only Cookie 中的 Refresh Token
    const res = await api.auth.refreshToken({}, { withCredentials: true, })
    const accessToken = `Bearer ${res.data.accessToken}`

    localStorage.setItem(TOKEN_KEY, accessToken)

    return accessToken
  } catch (error: any) {
    return error
  }
}

// 请求拦截
axiosInstance.interceptors.request.use(
  async (config: InternalAxiosRequestConfig) => {
    const accessToken = localStorage.getItem(TOKEN_KEY)

    if (accessToken) {
      config.headers['Authorization'] = accessToken
    }

    return config
  },
  (error: any) => {
    return Promise.reject(error)
  },
)
// 响应拦截
axiosInstance.interceptors.response.use(
  (response: AxiosResponse) => {
    // 单独处理下载文件流情况
    if (response.config.responseType === 'blob') {
      return response
    }
    // 业务请求成功
    if (response.data.isSuccess)
      return { ...response, data: handleServiceResult(response.data) }

    // 业务请求失败
    const errorResult = handleBusinessError(response.data, DEFAULT_BACKEND_OPTIONS)
    return { ...response, data: handleServiceResult(errorResult, false) }
  },
  async (error: AxiosError) => {
    // http状态码非200
    const errorResult = handleResponseError(error.response)
    const originalRequest = error.config as AxiosError['config'] & { _retry: boolean }

    // originalRequest._retry是一个自定义属性,用于标记请求是否已经重试过
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true

      // 刷新token
      if (!isRefreshing) {
        isRefreshing = true

        try {
          const newToken = await refreshToken()

          axiosInstance.defaults.headers.common['Authorization'] = newToken

          // 使用新的token重试所有等待的请求
          failedQueue.forEach(promise => promise.resolve(newToken))

          // 重试原始请求
          return axiosInstance(originalRequest)

        } catch (refreshError) {
          // 刷新Token失败,跳转登录页
          failedQueue.forEach(item => item.reject(refreshError))

          localStorage.removeItem(TOKEN_KEY)

          window.location.href = window.location.origin + window.location.pathname + '#/login'

          return Promise.reject(refreshError)
        } finally {
          // 重置刷新状态并清空队列
          isRefreshing = false
          failedQueue = []
        }
      }
      
      if (originalRequest.url === REFRESH_TOKEN_API) {
        return Promise.reject(handleServiceResult(errorResult, false))
      }

      // 如果正在刷新token,则将请求加入队列
      return new Promise((resolve, reject) => failedQueue.push({ resolve, reject }))
        .then(newToken => {
          originalRequest.headers.Authorization = newToken as string
          return axiosInstance(originalRequest)
        })
        .catch(err => {
          return Promise.reject(err)
        })
    }

    return Promise.reject(handleServiceResult(errorResult, false))
  },
)

/**
核心逻辑在响应拦截器的error部分:

1、判断http状态码是否为401,并且没有重试标识:_retry,满足条件则认为是Token过期,并且未被重试过。
换句话说,重试过的请求依旧401,则直接报错,防止无限重试。

2、针对并发请求,第一个会触发refreshToken方法更新,第二个到第N个由于被isRefreshing锁住了,
都会进入failedQueue队列当中,等待refreshToken方法执行完毕后,用新的token重试failedQueue队列
当中所有的请求,并且重试原始请求(也就是第一个请求)。至此,便完成了所有401请求的重试。

    
  并发请求时,refreshToken是个异步过程,所以,同一时间会将第2个出现401的请求到后面所有401失败的请求
  全部放入队列当中。
  
return new Promise((resolve, reject) => failedQueue.push({ resolve, reject }))
    .then(newToken => {
      originalRequest.headers.Authorization = newToken as string
      return axiosInstance(originalRequest)
    })
    .catch(err => {
      return Promise.reject(err)
    })

流程图

image.png

双 Token 刷新机制 是一种安全且高效的身份验证方案,通过 Access Token 和 Refresh Token 的配合,既能保证用户会话的安全性,又能提升用户体验。它的核心思想是:

  • Access Token:短期有效,用于访问资源。
  • Refresh Token:长期有效,用于刷新 Access Token。

双 Token 刷新机制本质上是延长Token时效的一种策略,如果Refresh Token过期,依然需要重新登录。

如果想进一步提升用户体验,需要进行Token续期。就是在每次请求时,判断Refresh Token是否即将过期,如果即将过期,则需要对其进行续期,避免Refresh Token过期导致跳转到登录页。

这里有两种做法。

一种是客户端登录完成后,服务端返回给客户端一个有效期,由客户端在请求拦截器里面做判断。

第二种是服务端在给客户端下发令牌时,将有效期写入JWT当中,这样返回给客户端的Access Token字符串中就包含了时效信息,然后在客户端每次发起请求的时候,服务端判断Refresh Token是否即将过期,如果是,则更新Refresh Token,从而达到给Refresh Token续期的目的。但第二种做法只支持Http-Only Cookie这种形式,由服务端主动SetCookie。