Axios 拦截器重试原始请求并访问原始承诺。

97

我已经设置了一个拦截器来捕获401错误,以便在访问令牌过期时使用刷新令牌获取新的访问令牌。如果在此期间进行任何其他调用,则它们将排队等待访问令牌验证。

这一切都非常顺利。但是,在使用Axios(originalRequest)处理队列时,最初附加的promises没有被调用。请参见下面的示例。

工作中的拦截器代码:

Axios.interceptors.response.use(
  response => response,
  (error) => {
    const status = error.response ? error.response.status : null
    const originalRequest = error.config

    if (status === 401) {
      if (!store.state.auth.isRefreshing) {
        store.dispatch('auth/refresh')
      }

      const retryOrigReq = store.dispatch('auth/subscribe', token => {
        originalRequest.headers['Authorization'] = 'Bearer ' + token
        Axios(originalRequest)
      })

      return retryOrigReq
    } else {
      return Promise.reject(error)
    }
  }
)

刷新方法 (使用刷新令牌获取新的访问令牌)

refresh ({ commit }) {
  commit(types.REFRESHING, true)
  Vue.$http.post('/login/refresh', {
    refresh_token: store.getters['auth/refreshToken']
  }).then(response => {
    if (response.status === 401) {
      store.dispatch('auth/reset')
      store.dispatch('app/error', 'You have been logged out.')
    } else {
      commit(types.AUTH, {
        access_token: response.data.access_token,
        refresh_token: response.data.refresh_token
      })
      store.dispatch('auth/refreshed', response.data.access_token)
    }
  }).catch(() => {
    store.dispatch('auth/reset')
    store.dispatch('app/error', 'You have been logged out.')
  })
},

auth/actions模块中的订阅方法:

subscribe ({ commit }, request) {
  commit(types.SUBSCRIBEREFRESH, request)
  return request
},

除了突变之外:

[SUBSCRIBEREFRESH] (state, request) {
  state.refreshSubscribers.push(request)
},

这里是一个示例操作:

Vue.$http.get('/users/' + rootState.auth.user.id + '/tasks').then(response => {
  if (response && response.data) {
    commit(types.NOTIFICATIONS, response.data || [])
  }
})

如果此请求被添加到队列中,那是因为要使用刷新令牌获取新令牌,我想附加原始的 then()。
  const retryOrigReq = store.dispatch('auth/subscribe', token => {
    originalRequest.headers['Authorization'] = 'Bearer ' + token
    // I would like to attache the original .then() as it contained critical functions to be called after the request was completed. Usually mutating a store etc...
    Axios(originalRequest).then(//if then present attache here)
  })

一旦访问令牌已经被刷新,请求队列将被处理:

refreshed ({ commit }, token) {
  commit(types.REFRESHING, false)
  store.state.auth.refreshSubscribers.map(cb => cb(token))
  commit(types.CLEARSUBSCRIBERS)
},

3
你无法获取“原始.then()回调函数”并将它们附加到你的新请求上。相反,你需要从拦截器返回一个新结果的 promise,以便它使用新结果来“resolve(解决)”原始 promise。 - Bergi
2
我不太了解axios或vue的细节,但我认为像const retryOrigReq = store.dispatch('auth/subscribe').then(token => { originalRequest.headers['Authorization'] = 'Bearer ' + token; return Axios(originalRequest) });这样的代码应该可以实现。 - Bergi
1
我更新了问题以添加额外的上下文。我需要找到一种方法来运行原始请求中的then语句。在示例中,它更新了通知存储,作为一个例子。 - Tim Wickstrom
很想知道你的“subscribe”操作是什么样子的,这可能会有所帮助。 - Dawid Zbiński
@TimWickstrom 是的,而运行那些“then”回调的唯一方法是解决“get(...)”调用返回的承诺。据我所知,拦截器回调的返回值提供了这种能力。 - Bergi
@DawidZbiński,好的建议,看一下更新后的订阅函数问题。 - Tim Wickstrom
3个回答

160

2019年2月13日更新

由于许多人对此话题表现出了兴趣,我创建了axios-auth-refresh包,这应该可以帮助您实现此处指定的行为。


关键在于返回正确的Promise对象,这样就可以使用.then()进行链接。我们可以使用Vuex的状态来实现。如果发生刷新调用,我们不仅可以将refreshing状态设置为true,还可以将正在刷新的调用设置为挂起状态。这样,使用.then()将始终绑定到正确的Promise对象上,并在Promise完成时执行。这样做将确保您无需额外的队列来保持等待令牌刷新的调用。

function refreshToken(store) {
    if (store.state.auth.isRefreshing) {
        return store.state.auth.refreshingCall;
    }
    store.commit('auth/setRefreshingState', true);
    const refreshingCall = Axios.get('get token').then(({ data: { token } }) => {
        store.commit('auth/setToken', token)
        store.commit('auth/setRefreshingState', false);
        store.commit('auth/setRefreshingCall', undefined);
        return Promise.resolve(true);
    });
    store.commit('auth/setRefreshingCall', refreshingCall);
    return refreshingCall;
}

这将始终返回已创建的请求作为Promise,或创建新请求并保存它以供其他调用。现在,您的拦截器将类似于以下内容。

Axios.interceptors.response.use(response => response, error => {
    const status = error.response ? error.response.status : null

    if (status === 401) {

        return refreshToken(store).then(_ => {
            error.config.headers['Authorization'] = 'Bearer ' + store.state.auth.token;
            error.config.baseURL = undefined;
            return Axios.request(error.config);
        });
    }

    return Promise.reject(error);
});

这将允许您再次执行所有待处理的请求。但是一次性全部执行,无需查询。


如果您希望按照它们实际被调用的顺序执行待处理的请求,您需要将回调作为第二个参数传递给refreshToken()函数,像这样。

function refreshToken(store, cb) {
    if (store.state.auth.isRefreshing) {
        const chained = store.state.auth.refreshingCall.then(cb);
        store.commit('auth/setRefreshingCall', chained);
        return chained;
    }
    store.commit('auth/setRefreshingState', true);
    const refreshingCall = Axios.get('get token').then(({ data: { token } }) => {
        store.commit('auth/setToken', token)
        store.commit('auth/setRefreshingState', false);
        store.commit('auth/setRefreshingCall', undefined);
        return Promise.resolve(token);
    }).then(cb);
    store.commit('auth/setRefreshingCall', refreshingCall);
    return refreshingCall;
}

拦截器:

Axios.interceptors.response.use(response => response, error => {
    const status = error.response ? error.response.status : null

    if (status === 401) {

        return refreshToken(store, _ => {
            error.config.headers['Authorization'] = 'Bearer ' + store.state.auth.token;
            error.config.baseURL = undefined;
            return Axios.request(error.config);
        });
    }

    return Promise.reject(error);
});

我没有测试第二个例子,但它应该能够工作或者至少给你一个想法。

第一个例子的演示 - 由于使用了模拟请求和服务的演示版本,它在一段时间后将无法工作,但是代码仍然存在。

来源:拦截器 - 如何防止被拦截的消息解析为错误


它返回发送给它的请求。 - Tim Wickstrom
我添加了一个可工作的示例。你可以看一下。 - Dawid Zbiński
1
我在我的Axios拦截器中必须添加的一件事是检查是否从oath/refresh方法返回了401。如果它返回了401,它将被卡在循环中。我对此的临时实现只是通过if语句检查路径:if(location.substring(location.length - 13, location.length) === 'login/refresh') { .. do something } - Tim Wickstrom
1
我将区分调用的逻辑委托给了这个问题。希望有人能提供解决方案的想法。否则,如果问题得到解决,我很乐意将此拦截器作为一个包提供。 - Dawid Zbiński
1
@DawidZbiński 这真的很方便。感谢你的整理。 - Daniel Mlodecki
显示剩余17条评论

10
这可以通过一个拦截器来实现:
let _refreshToken = '';
let _authorizing: Promise<void> | null = null;
const HEADER_NAME = 'Authorization';

axios.interceptors.response.use(undefined, async (error: AxiosError) => {
    if(error.response?.status !== 401) {
        return Promise.reject(error);
    }

    // create pending authorization
    _authorizing ??= (_refreshToken ? refresh : authorize)()
        .finally(() => _authorizing = null)
        .catch(error => Promise.reject(error));

    const originalRequestConfig = error.config;
    delete originalRequestConfig.headers[HEADER_NAME]; // use from defaults

    // delay original requests until authorization has been completed
    return _authorizing.then(() => axios.request(originalRequestConfig));
});

剩下的是应用程序特定的代码:
  • 登录到api
  • 保存/从存储加载授权数据
  • 刷新令牌
请查看完整示例

7
为什么不试试这种方法?在这里我同时使用了 AXIOS 拦截器。对于传出方向,我设置了“Authorization”头。对于传入方向 - 如果有错误,则返回一个 Promise(AXIOS 将尝试解决它)。Promise 检查错误是什么 - 如果是 401 并且我们首次看到它(即我们不在重试中),则尝试刷新令牌。否则抛出原始错误。 在我的情况下,refreshToken() 使用 AWS Cognito,但您可以使用适合您的任何内容。这里我为 refreshToken() 设置了2个回调函数:
  1. 当成功刷新令牌时,使用更新后的配置(包括新的令牌和设置 retry 标志)重试 AXIOS 请求,以便如果 API 反复响应 401 错误,则不会进入无限循环。我们需要将 resolve 和 reject 参数传递给 AXIOS,否则我们的全新 Promise 将永远不会得到解决/拒绝。
  2. 如果由于任何原因未能刷新令牌,则拒绝 Promise。我们不能简单地抛出错误,因为 AWS Cognito 内部可能有 try/catch 块。
Vue.prototype.$axios = axios.create(
  {
    headers:
      {
        'Content-Type': 'application/json',
      },
    baseURL: process.env.API_URL
  }
);

Vue.prototype.$axios.interceptors.request.use(
  config =>
  {
    events.$emit('show_spin');
    let token = getTokenID();
    if(token && token.length) config.headers['Authorization'] = token;
    return config;
  },
  error =>
  {
    events.$emit('hide_spin');
    if (error.status === 401) VueRouter.push('/login'); // probably not needed
    else throw error;
  }
);

Vue.prototype.$axios.interceptors.response.use(
  response =>
  {
    events.$emit('hide_spin');
    return response;
  },
  error =>
  {
    events.$emit('hide_spin');
    return new Promise(function(resolve,reject)
    {
      if (error.config && error.response && error.response.status === 401 && !error.config.__isRetry)
      {
        myVue.refreshToken(function()
        {
          error.config.__isRetry = true;
          error.config.headers['Authorization'] = getTokenID();
          myVue.$axios(error.config).then(resolve,reject);
        },function(flag) // true = invalid session, false = something else
        {
          if(process.env.NODE_ENV === 'development') console.log('Could not refresh token');
          if(getUserID()) myVue.showFailed('Could not refresh the Authorization Token');
          reject(flag);
        });
      }
      else throw error;
    });
  }
); 

我错过了 return new Promise( (resolve,reject) => {//refresh code},甚至没有想到将 resolvereject 解析到 .then() 函数中。这个答案可能需要更多的解释来说明你的代码。但是这是一个很好的代码片段。 - user5283119
__isRetry 属性的作用是什么?我在 AxiosError.config 上没有看到它存在。 - moze
@moze __isRetry 防止我们进入无限循环 - 它是我们自己的标志,用于向我们的拦截器发出信号,表明给定的 AJAX 请求是自动生成的,我们不应该尝试重试它。 - IVO GELOV
“given AJAX request” - 指需要在刷新令牌后重新尝试的请求?我仍然看不出使用标志的意义。 _retry 仅适用于当前错误响应。如何可能创建无限循环? - moze
只需删除__isRetry的设置和检查,您就会在令牌过期后立即进入无限循环。 "给定AJAX请求"是指提供给interceptors.response.use()的第二个回调接收到的参数(此处命名为error)。如果我们第一次得到401 - 我们要求一个新的令牌并重试请求,但如果我们第二次得到401 - 那么我们放弃并抛出错误。 - IVO GELOV
这已经完全足够了。感谢提示将刷新信息存储在axios请求本身中 ;) - nonNumericalFloat

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接