返回

GraphQL请求失败?Token过期问题深度解析与解决方案

javascript

Token 过期导致 GraphQL 请求失败:深入分析与解决方案

在使用 GraphQL 和 Apollo Client 进行开发时,我们经常会遇到 Token 过期的问题。如果 Token 过期后,在发起 GraphQL 请求前没有正确刷新 Token,就会导致请求失败。 问题来了,明明写了 setContext 在每次请求前获取 token,为啥还是和 GraphQL API 同时发出了刷新 token 的请求?

问题原因剖析

先看看这段代码, 问题可能出在 setContextcommonAuthHeaders 函数的实现上,尤其与异步操作的处理方式有关。

const authLink = setContext((_, { headers }) => {
  return new Promise( async (resolve, reject) => {
    // get the authentication headers
    const updatedHeaders = await commonAuthHeaders(headers)
    resolve ({
      headers: updatedHeaders
    }
    )
  });
});


export const client = new ApolloClient({
  link: from([authLink, errorLink , httpLink]),
  cache,
  connectToDevTools: process.env.NODE_ENV !== "production",
  defaultOptions: {
    watchQuery: {
      fetchPolicy: "network-only",
      nextFetchPolicy: "network-only",
    },
  },
});



export async function commonAuthHeaders(headers) {
    const supportedLoginModes = ["autodesk", "google"]

    const logInMode = getCookie("__login_mode");
    let access_token = getCookie("__bauhub_token");
    const refresh_token = getCookie("__bauhub_refresh_token")
    const lang = getCookie("__bauhub_lang");
    const currentOrgId = getCookie("__current_organization");
    const pluginType = getCookie("pluginType")

    const newHeaders = {
        ...headers,
        "Apollo-Require-Preflight": true,
    };

    if (lang) newHeaders["Accept-Language"] = lang;

    if (supportedLoginModes.includes(logInMode)) {

        if (logInMode === "autodesk" && !access_token && refresh_token) {
            access_token = await refreshAutodeskAccessToken()
        }

        if (logInMode === "google" && !access_token) {
            access_token = await getFirebaseToken()
        }

        if (access_token && currentOrgId) newHeaders.organizationId = currentOrgId;

        if (logInMode && access_token) newHeaders.logInMode = logInMode;

        if (pluginType) newHeaders.pluginType = pluginType

        newHeaders.authorization = access_token ? `Bearer ${access_token}` : "";
        return newHeaders
    } else {
        return headers
    }
}

代码中,setContext 确实返回了一个 Promise,保证了异步获取 header 的操作。 看起来,应该先等 commonAuthHeaders 执行完毕拿到 updatedHeaders 之后, 才把包含新 token 的 updatedHeaders 放进请求。

问题偏偏出在并发控制。 虽然 authLink 用了 async/await,commonAuthHeaders 内部也用了 async/await (例如 refreshAutodeskAccessToken()getFirebaseToken())。 但, 如果有多个 GraphQL 请求同时发出,就有可能出现多个请求同时触发 authLink, 进而同时进入 commonAuthHeaders,此时大家一起去检查 access_token ,发现都过期了,然后一起调用刷新 Token 的 API 。

结果就变成了:Token 刷新请求和 GraphQL 请求一起发出。

解决方案

要解决这个问题, 需要确保在同一时刻,只有一个请求能去刷新 Token。其他请求咋办? 等! 等到拿到新的 Token 再继续。 下面给出几种可行的方案。

1. 使用 Semaphore (信号量)

信号量是一种经典的同步机制,可以限制同时访问某个资源的线程数量。 用在这里, 可以保证同一时刻只有一个请求在执行刷新 Token 的操作。

原理:

  • 创建一个初始值为 1 的信号量。
  • commonAuthHeaders 函数中,刷新 Token 前,尝试获取信号量。
    • 如果获取成功(当前没有其他请求在刷新 Token),则执行刷新操作。
    • 如果获取失败(已有其他请求在刷新),则等待,直到信号量被释放。
  • 刷新 Token 完成后,释放信号量。

代码示例 (使用 async-mutex 库):

import { Mutex } from 'async-mutex';

const mutex = new Mutex();

export async function commonAuthHeaders(headers) {
  const supportedLoginModes = ["autodesk", "google"];

  const logInMode = getCookie("__login_mode");
  let access_token = getCookie("__bauhub_token");
  const refresh_token = getCookie("__bauhub_refresh_token");
  const lang = getCookie("__bauhub_lang");
  const currentOrgId = getCookie("__current_organization");
  const pluginType = getCookie("pluginType");

  const newHeaders = {
    ...headers,
    "Apollo-Require-Preflight": true,
  };

  if (lang) newHeaders["Accept-Language"] = lang;

  if (supportedLoginModes.includes(logInMode)) {
      let release; //  用来存放 mutex 的 release 方法
    if ((logInMode === "autodesk" && !access_token && refresh_token) || (logInMode === "google" && !access_token)) {
        release = await mutex.acquire(); // 获取锁
        try{
          //重新从cookie里取一下, 避免多个请求同时刷新
          access_token = getCookie("__bauhub_token");
          if (logInMode === "autodesk" && !access_token && refresh_token) {
            access_token = await refreshAutodeskAccessToken();
            }
            if (logInMode === "google" && !access_token) {
                 access_token = await getFirebaseToken();
             }
          }finally{
               release();  // 刷新 Token 后,释放锁
          }
    }

    if (access_token && currentOrgId) newHeaders.organizationId = currentOrgId;

    if (logInMode && access_token) newHeaders.logInMode = logInMode;

    if (pluginType) newHeaders.pluginType = pluginType;

    newHeaders.authorization = access_token ? `Bearer ${access_token}` : "";
    return newHeaders;
  } else {
    return headers;
  }
}

额外建议:

mutex 对象放在一个单独的模块中,并导出,以便在整个应用程序中共享同一个锁。

2. Token 刷新队列

另一个方案, 是搞个队列。 想法是: 如果发现 Token 过期,不直接刷新,而是把当前的请求放到一个队列里。然后,检查下有没有正在刷新的请求,没有的话就发起一个刷新请求。 刷新成功后, 把队列里的请求都挨个执行一遍, 大家都用上新 Token。

原理:

  • 维护一个请求队列。
  • 如果发现 Token 过期:
    • 将当前请求的回调函数(用于设置 headers)放入队列。
    • 检查是否有正在进行的 Token 刷新请求。
      • 如果没有,发起 Token 刷新请求。
      • 如果有,则等待刷新完成。
  • Token 刷新完成后:
    • 从队列中取出所有回调函数,并使用新的 Token 执行它们。

代码示例(简略版,仅展示思路):

let refreshPromise = null;
let requestQueue = [];

async function refreshTokenAndProcessQueue() {
    try{
       const newAccessToken = await refreshAutodeskAccessToken(); // 或者 getFirebaseToken()
        // 更新 cookie 或者其他存储
         requestQueue.forEach(callback => callback(newAccessToken));
    }catch (error){
      //处理刷新token的错误
       requestQueue.forEach(callback => callback(null, error)); //  可以传递 error
    }finally{
       requestQueue = []; // 清空队列
       refreshPromise = null;
    }

}

export async function commonAuthHeaders(headers) {
  // ... (省略部分代码)

  if (logInMode === "autodesk" && !access_token && refresh_token) {
    return new Promise((resolve,reject) => {
         requestQueue.push((newAccessToken, error)=>{
              if(error){
                  reject(error)
                  return;
               }
               const updatedHeaders = {...headers, authorization: `Bearer ${newAccessToken}`};
              //更新其他的 header
               resolve(updatedHeaders)
         });
        if(!refreshPromise){
             refreshPromise =  refreshTokenAndProcessQueue();
         }
     })
  }

    if (logInMode === "google" && !access_token) {
     //和上面类似处理
    }
// ...
}

额外建议:

这个方法比较复杂,容易出错, 但好处是可以更细粒度地控制请求。比如实现请求重试, 或者区分不同的错误类型。

3. 使用第三方库

也有一些库, 封装好了 Token 刷新的逻辑, 你直接拿来用就行了。 比如 apollo-link-token-refresh

原理:

这类库通常会拦截需要认证的请求,检查 Token 是否过期。如果过期,则自动刷新 Token,并使用新的 Token 重新发起请求。

代码示例 (使用 apollo-link-token-refresh) :

首先,安装:

npm install apollo-link-token-refresh jwt-decode

然后,在 Apollo Client 配置中使用:

import { ApolloClient, InMemoryCache, from } from '@apollo/client';
import { RefreshLink } from 'apollo-link-token-refresh';
import decode from 'jwt-decode'; //  用于解析 token 获取过期时间, 可以根据你的 token 类型修改

const refreshLink = new RefreshLink({
  isTokenValidOrUndefined: () => {
       const token = getCookie("__bauhub_token"); // 从 cookie 或者其他地方拿token
       if(!token) return true; //  没token 认为是有效

      const decodedToken = decode(token); // 你的token解析逻辑
      return  decodedToken.exp * 1000 > Date.now() + 30000;  // 预留30秒 buffer, 免得临界情况出问题
  },
    fetchAccessToken:  async () =>{
          const logInMode = getCookie("__login_mode");
          if(logInMode==="autodesk"){
               return refreshAutodeskAccessToken()
          }
           if(logInMode==="google"){
              return getFirebaseToken()
           }
     },
     handleFetch: (accessToken) => {
          // 更新cookie
           setCookie("__bauhub_token",accessToken)  //你的cookie 设置函数
       },
       handleResponse: (operation, accessTokenField) => response => {
        //  处理 token 刷新后, 服务器返回的各种情况
            return response;
        },
          handleError: err => {
        //  处理刷新token失败,  比如 refresh token 也过期了
         }
});

export const client = new ApolloClient({
    link: from([refreshLink, authLink, errorLink, httpLink]), // 注意  refreshLink 要放在 authLink 前面
     // ...其他配置
    cache: new InMemoryCache()
})

进阶技巧:

可以根据不同的错误码 (比如 401, 403) , 在 handleError 里实现更精细的错误处理, 例如, 可以根据服务器返回的提示信息来判断是否需要用户重新登录。

总结

选择哪种方案,主要取决于你的项目具体情况和需求。如果不想自己处理复杂的并发逻辑,用现成的库是最省事的。 如果想对请求有更多的控制, 队列的方法更合适。 信号量的方法, 实现起来比较简单, 而且也够用了。 不管用哪个, 核心思想都是: 同一时间, 只允许一个请求去刷新 token, 保证请求的顺序和 token 的正确性。