如何在客户端并行处理多个请求/API 调用的 JWT 刷新令牌?

dar*_*mnx 5 javascript security authentication authorization jwt

我遇到了一个问题,我在任何地方都找不到安全第一和可维护的答案。

想象一个仪表板同时执行多个查询,您如何以干净和标准的方式处理refresh_tokens?

堆栈是(即使堆栈在这里无​​关紧要):

后端 - 带有 JWT 令牌认证的 Laravel 前端 - 带有 axios 的 Vue JS 用于 API 调用

端点:

  • /auth/登录(公共)
  • /auth/refresh-token(需要认证)
  • /统计(需要身份验证)
  • /other-statistics(需要身份验证)
  • /event-more-statistics(需要身份验证)
  • /final-statistics(需要身份验证)...

JWT 刷新工作流

  • 用户导航到客户端上的mywebsite.com/login
  • 登录页面对服务器进行 API 调用 axios.get('/auth/login').then(res => setTokenAndUser(res))
  • 服务器响应access_token(生命周期 1 分钟)和refresh_token(生命周期 1 个月左右)
  • 用户导航到mywebsite.com/dashboard
  • 用户点击某物,仪表板页面执行 4 个 API 调用,与上面最后 4 个端点并行
// ... just some pseudo code
userDidAction() {
  axios.get('/statistics').then(res => handleThis(res.data));
  axios.get('/other-statistics').then(res => handleThat(res.data));
  axios.get('/event-more-statistics').then(res => handleThisAgain(res.data));
  axios.get('/final-statistics').then(res => handleThatAgain(res.data));
}
// ...
Run Code Online (Sandbox Code Playgroud)
  • 第一次调用完成,服务器使旧令牌无效 + 以新的access_token和refresh_token响应
  • 第二次调用被服务器阻止,因为它正在传输过时的令牌
  • 第三次调用被服务器阻止,因为它正在传输一个过时的令牌
  • 第 4 次调用被服务器阻止,因为它正在传输过时的令牌
  • 客户端 / UI 未正确更新

这是 SPA 和 SaaS 应用程序中非常常见的场景。具有多个异步 API 调用不是边缘情况。

我在这里有什么选择?

  • 不使令牌无效:
    • 但随后存在安全漏洞,使用 JWT 令牌变得无用
  • 跟踪每个失败的 API 调用,并在刷新令牌更改时重放它们
    • 这很难维护并在用户界面上为用户创建不可预测的行为
    • 如果用户在呼叫重放期间进行交互,它会搞乱呼叫处理程序
    • 每个 axios 调用都有一个承诺,为了获得良好的处理,我们也需要存储和延迟每个承诺,以便正确处理 UI
    • 每次新的重播也会每次都重新创建新的令牌

我目前的想法是使用以下工作流程使access_token持续 3 天,使refresh_token持续一个月:

  • 前端启动时,我们在客户端检查access_token的有效性
    • 如果refresh_token已过期,则从客户端清除令牌
    • 别的什么都不做
    • 如果access_token超过 12 小时过期,则发送所有未来的请求
    • 否则使用刷新令牌获取新令牌

这使得refresh_token在网络上的传播更少,并使并行失败成为不可能,因为我们仅在前端加载时才更改令牌,因此令牌在失败前至少会存活 12 小时。

尽管这个解决方案有效,我正在寻找一种更安全/标准的方式,有什么线索吗?

ham*_*kan 6

这是我在应用程序中遇到的情况以及解决方法:

应用程序设置

  • Nuxt应用程序
  • 使用axios进行API调用
  • 使用Vuex进行状态管理
  • 使用每 15 分钟过期一次的 JWT 令牌,因此每当发生这种情况时,都应该调用 API 来刷新令牌并重复失败的请求

代币

我将令牌数据保存在会话存储中,并每次使用刷新令牌 API 响应进行更新

问题

我在一个页面中有三个 get 请求,我希望这种行为是,当令牌过期时,只有其中一个可以调用刷新令牌 API,而其他请求则必须等待响应,当刷新令牌承诺得到解决时,这三个请求都得到解决应使用更新的令牌数据重复失败的请求

使用 axios 拦截器和 vuex 的解决方案

这是 vuex 设置:

// here is the state to check if there is a refresh token request proccessing or not  
export const state = () => ({
  isRefreshing: false,
});

// mutation to update the state
export const mutations = {
  SET_IS_REFRESHING(state, isRefreshing) {
    state.isRefreshing = isRefreshing;
  },
};

// action to call the mutation with a false or true payload
export const actions = {
  setIsRefreshing({ commit }, isRefreshing) {
    commit('SET_IS_REFRESHING', isRefreshing);
  },
};
Run Code Online (Sandbox Code Playgroud)

这是 axios 设置:

import { url } from '@/utils/generals';

// adding axios instance as a plugin to nuxt app (nothing to concern about!)
export default function ({ $axios, store, redirect }, inject) {

  // creating axios instance
  const api = $axios.create({
    baseURL: url,
  });

  // setting the authorization header from the data that is saved in session storage with axios request interceptor
  api.onRequest((req) => {
    if (sessionStorage.getItem('user'))
      req.headers.authorization = `bearer ${
        JSON.parse(sessionStorage.getItem('user')).accessToken
      }`;
  });

  // using axios response interceptor to handle the 401 error
  api.onResponseError((err) => {
    // function that redirects the user to the login page if the refresh token request fails
    const redirectToLogin = function () {
      // some code here
    };

    if (err.response.status === 401) {
      // failed API call config
      const config = err.config;
      
      // checks the store state, if there isn't any refresh token proccessing attempts to get new token and retry the failed request
      if (!store.state.refreshToken.isRefreshing) {
        return new Promise((resolve, reject) => {
          // updates the state in store so other failed API with 401 error doesnt get to call the refresh token request
          store.dispatch('refreshToken/setIsRefreshing', true);
          let refreshToken = JSON.parse(sessionStorage.getItem('user'))
            .refreshToken;

          // refresh token request
          api
            .post('token/refreshToken', {
              refreshToken,
            })
            .then((res) => {
              if (res.data.success) {
                // update the session storage with new token data
                sessionStorage.setItem(
                  'user',
                  JSON.stringify(res.data.customResult)
                );
                // retry the failed request 
                resolve(api(config));
              } else {
                // rediredt the user to login if refresh token fails
                redirectToLogin();
              }
            })
            .catch(() => {
                // rediredt the user to login if refresh token fails
              redirectToLogin();
            })
            .finally(() => {
              // updates the store state to indicate the there is no current refresh token request and/or the refresh token request is done and there is updated data in session storage
              store.dispatch('refreshToken/setIsRefreshing', false);
            });
        });
      } else {
        // if there is a current refresh token request, it waits for that to finish and use the updated token data to retry the API call so there will be no Additional refresh token request
        return new Promise((resolve, reject) => {
          // in a 100ms time interval checks the store state
          const intervalId = setInterval(() => {
            // if the state indicates that there is no refresh token request anymore, it clears the time interval and retries the failed API call with updated token data
            if (!store.state.refreshToken.isRefreshing) {
              clearInterval(intervalId);
              resolve(api(config));
            }
          }, 100);
        });
      }
    }
  });

  // injects the axios instance to nuxt context object (nothing to concern about!)
  inject('api', api);
}
Run Code Online (Sandbox Code Playgroud)

这是网络选项卡中所示的情况:

在此输入图像描述

正如您在这里看到的,有 3 个失败的请求,并出现 401 错误,然后有一个刷新令牌请求,之后所有失败的请求都会使用更新的令牌数据再次调用