通过 axios 中的拦截器自动刷新访问令牌

Automating access token refreshing via interceptors in axios

我们最近在 中讨论了用于 OAuth 身份验证令牌刷新的 axios 拦截器。

拦截器应该做的是拦截任何带有 401 状态代码的响应并尝试刷新令牌。 考虑到这一点,接下来要做的是 return 来自拦截器的 Promise,这样任何通常会失败的请求都会 运行 因为在令牌刷新后没有任何反应。

主要问题是,拦截器只检查 401 状态码,这还不够,因为 refreshToken 也会 return 401 状态失败时的代码 - 我们有一个循环。

我想到了两种可能的情况:

  1. 检查调用的 URL,如果是 /auth/refresh,则不应尝试刷新令牌;
  2. 调用refreshToken逻辑时省略拦截器

第一个选项在我看来有点“不动态”。第二种选择看起来很有希望,但我不确定是否可行。

那么主要的问题是,我们如何 differentiate/identify 调用拦截器并 运行 为它们调用不同的逻辑而不专门对其进行“硬编码”,或者是否有任何方法可以省略拦截器指定电话?提前谢谢你。

拦截器的代码可能有助于理解问题:

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

    if (status === 401) {
        // will loop if refreshToken returns 401
        return refreshToken(store).then(_ => {
            error.config.headers['Authorization'] = 'Bearer ' + store.state.auth.token;
            error.config.baseURL = undefined;
            return Axios.request(error.config);
        })
        // Would be nice to catch an error here, which would work if the interceptor is omitted
        .catch(err => err);
    }

    return Promise.reject(error);
});

和令牌刷新部分:

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;
}

不确定这是否符合您的要求,但另一种解决方法也可以是单独的 Axios 实例(使用 axios.create 方法)用于 refreshToken 和其余 API 调用。通过这种方式,您可以轻松绕过默认拦截器以在 refreshToken 的情况下检查 401 状态。

所以,现在您的普通拦截器将是相同的。

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

  if (status === 401) {
    // will loop if refreshToken returns 401
    return refreshToken(store).then(_ => {
      error.config.headers['Authorization'] = 'Bearer ' + store.state.auth.token;
      error.config.baseURL = undefined;
      return Axios.request(error.config);
    })
    // Would be nice to catch an error here, which would work, if the interceptor is omitted
    .catch(err => err);
  }

  return Promise.reject(error);
});

而且,您的 refreshToken 会像:

const refreshInstance = Axios.create();

function refreshToken(store) {
  if (store.state.auth.isRefreshing) {
    return store.state.auth.refreshingCall;
  }

  store.commit('auth/setRefreshingState', true);
  const refreshingCall = refreshInstance.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;
}

这里有一些不错的链接[1] [2],你可以参考Axios实例

我可能找到了一种更简单的方法来处理这个问题:当我调用 /api/refresh_token 端点时使用 axios.interceptors.response.eject() 禁用拦截器,然后重新启用它。

代码:

createAxiosResponseInterceptor() {
    const interceptor = axios.interceptors.response.use(
        response => response,
        error => {
            // Reject promise if usual error
            if (error.response.status !== 401) {
                return Promise.reject(error);
            }
            
            /* 
             * When response code is 401, try to refresh the token.
             * Eject the interceptor so it doesn't loop in case
             * token refresh causes the 401 response
             */
            axios.interceptors.response.eject(interceptor);

            return axios.post('/api/refresh_token', {
                'refresh_token': this._getToken('refresh_token')
            }).then(response => {
                saveToken();
                error.response.config.headers['Authorization'] = 'Bearer ' + response.data.access_token;
                return axios(error.response.config);
            }).catch(error => {
                destroyToken();
                this.router.push('/login');
                return Promise.reject(error);
            }).finally(createAxiosResponseInterceptor);
        }
    );
}

在选择的解决方案中似乎遗漏了一些东西:如果在刷新期间触发请求会发生什么?为什么要等待令牌过期和 401 响应才能获得新令牌?

1) 触发刷新请求

2) 触发了另一个对普通资源的请求

3) 收到刷新响应,令牌已更改(意味着旧令牌无效)

4) 后端处理来自步骤 2 的请求,但它收到旧令牌 => 401

基本上,对于刷新请求期间触发的所有请求,您都会得到 401(至少这是我一直面临的问题)。

从这个问题 和@waleed-ali 对这个问题的回答看来,请求拦截器可以 return 一个 Promise。

我的解决方案所做的是保留请求并在刷新请求得到解决后立即触发它们。

在我的 vuex store 用户模块中(vuex + vuex-module-decorators):

  @Action({ rawError: true })
  public async Login(userInfo: { email: string, password: string }) {
    let { email, password } = userInfo
    email = email.trim()
    const { data } = await login({ email, password })
    setToken(data.access_token)
    setTokenExpireTime(Date.now() + data.expires_in * 1000)
    this.SET_TOKEN(data.access_token)
    // after getting a new token, set up the next refresh in 'expires_in' - 10 seconds
    console.log("You've just been logged-in, token will be refreshed in ", data.expires_in * 1000 - 10000, "ms")
    setTimeout(this.RefreshToken, data.expires_in * 1000 - 10000)
  }

  @Action
  public async RefreshToken() {
    setRefreshing(refresh().then(({ data }) => {
      setToken(data.access_token) // this calls a util function to set a cookie
      setTokenExpireTime(Date.now() + data.expires_in * 1000) // same here
      this.SET_TOKEN(data.access_token)
      // after getting a new token, set up the next refresh in 'expires_in' - 10 seconds
      console.log('Token refreshed, next refresh in ', data.expires_in * 1000 - 10000)
      setTimeout(this.RefreshToken, data.expires_in * 1000 - 10000)
      setRefreshing(Promise.resolve())
    }))
  }

在登录操作中,我设置了一个超时以在令牌即将过期之前调用 RefreshToken。

在 RefreshToken 操作中相同,因此创建一个刷新循环,它将在任何 401 发生之前自动刷新令牌。

User模块重要的两行是:

setRefreshing(Promise.resolve())

刷新请求完成后,刷新变量会立即解析。

并且:

setRefreshing(refresh().then(({ data }) => {

这里调用了api/user.ts文件的refresh方法(又调用了axios):

export const refresh = () =>
  request({
    url: '/users/login/refresh',
    method: 'post'
  })

并将 returned Promise 发送到 utils.ts 中的 setRefreshing 实用程序方法:

let refreshing: Promise<any> = Promise.resolve()
export const getRefreshing = () => refreshing
export const setRefreshing = (refreshingPromise: Promise<any>) => { refreshing = refreshingPromise }

默认情况下,刷新变量保存已解决的 Promise,并在触发时设置为挂起的刷新请求。

然后在 request.ts:

    service.interceptors.request.use(
  (config) => {
    if (config.url !== '/users/login/refresh') {
      return getRefreshing().then(() => {
        // Add Authorization header to every request, you can add other custom headers here
        if (UserModule.token) {
          console.log('changing token to:', UserModule.token)
          console.log('calling', config.url, 'now')
          config.headers['Authorization'] = 'Bearer ' + UserModule.token
        }
        return config
      })
    } else {
      return Promise.resolve(config)
    }
  },
  (error) => {
    Promise.reject(error)
  }
)

如果请求是针对刷新端点的请求,我们会立即解决它,如果不是,我们会 return 刷新承诺,并在我们获得更新的令牌后将其与我们想在拦截器中执行的操作链接起来。 如果当前没有待处理的刷新请求,那么 promise 将设置为立即解析,如果有刷新请求,那么我们将等待它解析,我们将能够使用新令牌启动所有其他待处理的请求。

可以通过将拦截器配置为忽略刷新端点来改进,但我还不知道该怎么做。

我正在使用 React 来构建我的应用程序的前端,并经常使用几乎相同的策略来解决此类问题

#ReactJs #JasvaScript #Axios

import axios from 'axios';

const baseURL = process.env.REACT_APP_SERVICE_URL;

const service = axios.create({ baseURL });

function saveToken(access_token, refresh_token) {
  sessionStorage.setItem('access_token', access_token);
  sessionStorage.setItem('refresh_token', refresh_token);
}
function destroyToken() {
  sessionStorage.removeItem('access_token');
  sessionStorage.removeItem('refresh_token');
}
function refresh() {
  return new Promise((resolve, reject) => {
    service.post('/api/v1/refresh', {
      refresh_token: sessionStorage.getItem('refresh_token')
    }).then((response) => {
      saveToken(response.data.access_token, response.data.refresh_token);
      return resolve(response.data.access_token);
    }).catch((error) => {
      destroyToken();
      window.location.replace('/logout');
      return reject(error);
    });
  });
}

service.interceptors.response.use(
  (res) => res,
  (error) => {
    const status = error.response ? error.response.status : null;
    if (status === 401) {
      window.location.replace('/logout');
      sessionStorage.removeItem('access_token');
      sessionStorage.removeItem('refresh_token');
    }
    // status might be undefined
    if (!status) {
      refresh();
    }
    return Promise.reject(error);
  }
);

service.interceptors.request.use((config) => {
  const access_token = sessionStorage.getItem('access_token');
  config.headers.Authorization = `Bearer ${access_token}`;
  return config;
});

export { service };

在我的情况下,有时 error.response 未定义,所以这就是为什么我首先更新访问令牌(如果存在错误然后注销)

我的实施方案

~/main.js

Vue.use(axiosPlugin, {store})

~/plugins/axios.js

import axios from 'axios'
import JWT from '@/utils/jwt'
import {AuthService} from '@/services/auth.service'

const UNAUTHORIZED_URLS = [
    '/auth/login',
    '/auth/token/refresh',
    '/user/create'
]

export default {
    async requestInterceptor(config) {

        /* Token validation and adding Authorization header */
        if (!UNAUTHORIZED_URLS.includes(config.url)) {
            if (!JWT.validateToken(this.store.getters['auth/accessToken'])) {
                await this.store.dispatch('auth/refreshUserToken')
            }

            config.headers['Authorization'] = AuthService.getAuthorizationHeader()
        }
    
        return config
    },
    async responseErrorInterceptor(error) {
        if (![401, 419].includes(error.response.status)) {
            return Promise.reject(error)
        }

        /* If this is a repeated request */
        if (error.response.config._retry) {
            await this.store.dispatch('auth/logout')

            return Promise.reject(error)
        }

        /* Attempting to update the token and retry the request */
        try {
            this.store.dispatch('auth/refreshUserToken')

            error.response.config._retry = true

            error.response.config.headers['Authorization'] = AuthService.getAuthorizationHeader()

            return axios(error.response.config)
        } catch (error) {
            await this.store.dispatch('auth/logout')

            return Promise.reject(error)
        }
    },
    install(Vue, options) {
        axios.defaults.baseURL = process.env.VUE_APP_BASE_URL
        axios.interceptors.request.use(this.requestInterceptor.bind(options))
        axios.interceptors.response.use(undefined, this.responseErrorInterceptor.bind(options))
    }
}

这是我的实现,如果刷新路由 returns 401.

,可以避免无限循环

为了避免无限循环,我使用了一个没有拦截器的干净的 axios 实例来刷新令牌。

我用的是cookie,如果你用的是localStorage,可以参考其他答案。

src/api/index.js

import axios from 'axios'

const baseURL = process.env.NODE_ENV === 'production' ? '/api' : http://localhost:5000/api`

const axiosInstance = axios.create({
  baseURL,
  timeout: 30000
})

axiosInstance.interceptors.response.use(response => response, error => {
  const { response, config } = error

  if (response.status !== 401) {
    return Promise.reject(error)
  }

  // Use a 'clean' instance of axios without the interceptor to refresh the token. No more infinite refresh loop.
  return axios.get('/auth/refresh', {
    baseURL,
    timeout: 30000
  })
    .then(() => {
      // If you are using localStorage, update the token and Authorization header here
      return axiosInstance(config)
    })
    .catch(() => {
      return Promise.reject(error)
    })
})

export default axiosInstance

我在前端使用 Vue

在我的例子中 axios.interceptors.response.eject() 禁用拦截器但不 re-enable 它。所以,我检查了刷新令牌(是否过期)

const interceptor = $axios.interceptors.response.use(
          response => response,
          error => {
              // Reject promise if usual error
              if (error.response.status !== 401) {
                  return Promise.reject(error);
              }
              
              // Logout if Refresh token expires.
              if(error.response.config.url == 'your_response_url_after_refresh_token_expire') {
                localStorage.clear();
                $nuxt.$router.push("/");
                return Promise.reject(error);
              }
              
              // If refresh token is working
              let refreshToken = {'refresh': localStorage.getItem('refresh')}
                return $axios.post('/api/refresh_token', refreshToken)
                  .then((response) => {
                    localStorage.setItem('access', response.data.access)
                    return $axios(error.response.config);
                  }).catch((err) => {
                    localStorage.clear();
                    $nuxt.$router.push("/");
                    location.reload();
                    return Promise.reject(error);
                  })
          }
      );