Инкапсулируйте перехватчик axios для обновления access_token без ощущения пользователя

JavaScript

предисловие

Когда я недавно работал над проектом, он включал единый вход, то есть страницу входа в проект с использованием страницы входа, совместно используемой компанией, и унифицированную логику обработки на этой странице. Наконец, пользователям нужно войти в систему только один раз, и они могут получить доступ ко всем веб-сайтам компании в состоянии входа в систему.

Единый вход (Single Sign On, сокращенно SSO) — одно из наиболее популярных решений для бизнес-интеграции предприятия.Он используется между несколькими прикладными системами.Пользователям нужно войти в систему только один раз, чтобы получить доступ ко всем взаимно доверенным прикладным системам.

Эта статья о том, как управлять после входа в системуaccess_tokenа такжеrefresh_token, в основном для инкапсуляции перехватчика axios, который здесь записан.

нужно

  • Предварительный сценарий
  1. Перейти на страницу проектаhttp://xxxx.project.com/profileЕсли вам нужно войти в систему, перейдите на платформу входа SSO, не входя в систему. В настоящее время URL-адрес URL входаhttp://xxxxx.com/login?app_id=project_name_id&redirect_url=http://xxxx.project.com/profileapp_idЭто определяется соглашением на заднем плане.redirect_urlадрес обратного вызова, указанный после успешной авторизации.

  2. После ввода пароля учетной записи и правильного, он будет перенаправлен обратно на страницу, которую вы только что ввели, с параметром в адресной строке.?code=XXXXX, то естьhttp://xxxx.project.com/profile?code=XXXXXX, значение кода недействительно после одного использования и истекает в течение 10 минут

  3. Немедленно получите это кодовое значение, а затем запросите API/access_token/authenticate, несущие параметры{ verify_code: code }, и API уже поставляется сapp_idа такжеapp_secretДва параметра с фиксированным значением, через которые можно запросить авторизованный API и получить возвращаемое значение после успешного запроса.{ access_token: "xxxxxxx", refresh_token: "xxxxxxxx", expires_in: xxxxxxxx }, спастиaccess_tokenа такжеrefresh_tokenв файл cookie (localStorage также может), и в это время пользователь успешно вошел в систему.

  4. access_tokenОн в стандартном формате JWT и является токеном авторизации. Можно понять, что он проверяет личность пользователя. Это параметр, который приложение должно передать при вызове API для доступа и изменения пользовательских данных (в заголовках файла заголовок запроса), и срок его действия истекает через 2 часа. То есть после выполнения первых трех шагов вы можете вызывать API, которые требуют от пользователей входа в систему для их использования; но если вы ничего не сделаете и запросите эти API после двух часов молчания, вы получите сообщениеaccess_tokenистек, вызов не удался.

  5. Тогда вы не можете позволить пользователю выйти из системы через 2 часа Решение состоит в том, чтобы взять просроченный через два часа.access_tokenа такжеrefresh_token(refresh_tokenСрок действия обычно больше, например, один месяц или более), чтобы запросить/refreshAPI, возвращаемый результат{ access_token: "xxxxx", expires_in: xxxxx }, в обмен на новыйaccess_token,новыйaccess_tokenВремя истечения тоже 2 часа, и оно повторно сохраняется в куке, а цикл продолжает авторизоваться и вызывать API пользователя.refresh_tokenВ течение ограниченного времени истечения (например, недели или месяца и т. д.) вы можете продолжить обмен на новый в следующий раз.access_token, но по истечении ограниченного времени, даже если реальный смысл истечет, необходимо повторно ввести пароль учетной записи для входа в систему.

Время истечения логина на сайте компании всего два часа (время истечения токена), но мы хотим, чтобы часто активные пользователи в течение месяца не заходили повторно, поэтому есть такая необходимость запретить пользователям вводить свой аккаунт и пароль еще раз, чтобы войти.

Зачем использовать спец.refresh_tokenобновитьaccess_tokenШерстяная ткань? первыйaccess_tokenЭто будет связано с определенными правами пользователя.Если авторизация пользователя изменена, этоaccess_tokenЕго также необходимо обновить, чтобы связать новые разрешения, если нет.refresh_token, вы также можете обновитьaccess_token, но каждое обновление требует от пользователя ввода имени пользователя и пароля для входа в систему, что очень проблематично. имеютrefresh_ token, может уменьшить эту проблему, клиент напрямую используетrefresh_tokenобновитьaccess_token, никаких дополнительных действий со стороны пользователя не требуется.

Сказав так много, некоторые люди могут пожаловаться, используется логинaccess_tokenЛадно, добавимrefresh_tokenсделать это таким хлопотным, или некоторые компанииrefresh_tokenОн устроен в фоновом режиме и не требует предварительной обработки. Однако предварительный сценарий есть, и требования основаны на этом сценарии.

  • нужно
  1. когдаaccess_tokenКогда он истечет, используйтеrefresh_tokenзапросить новыйaccess_token, интерфейс должен быть обновлен без восприятия пользователемaccess_token. Например, когда пользователь инициирует запрос, если решениеaccess_tokenСрок действия истек, то вы должны сначала вызвать интерфейс токена обновления, чтобы получить новыйaccess_token, а затем повторно инициируйте запрос пользователя.

  2. Если несколько пользовательских запросов инициированы одновременно, первый пользователь запрашивает вызов интерфейса токена обновления. Если интерфейс не вернулся, остальные запросы пользователя по-прежнему инициируют запрос интерфейса токена обновления, что приведет к множественным запросам. Как справиться с этими запросами, содержание нашей статьи.

идеи

Вариант первый

Прописано в перехватчике запроса, перед запросом использовать поля, возвращаемые исходным запросомexpires_inполе для сужденияaccess_tokenНезависимо от того, истек ли срок его действия, если он истек, запрос будет приостановлен и сначала обновленaccess_tokenЗатем продолжайте запрашивать.

  • Преимущества: можно сохранять http-запросы
  • Недостатки: поскольку используется оценка местного времени, если местное время изменено, существует риск сбоя проверки.

Вариант 2

Написано в ответ на перехватчики, перехватить возвращенные данные. Сначала инициируйте запрос пользователя, если интерфейс возвращаетaccess_tokenпросрочено, сначала обновитеaccess_token, и попробуй еще раз.

  • Плюсы: не нужно оценивать время
  • Недостаток: потребуется еще один http-запрос.

Здесь я выбираю второй вариант.

выполнить

Здесь используется Axios, который должен перехватывать после запроса, поэтому используется перехватчик ответа axios.axios.interceptors.response.use()метод

Введение метода

  • @utils/auth.js
import Cookies from 'js-cookie'

const TOKEN_KEY = 'access_token'
const REGRESH_TOKEN_KEY = 'refresh_token'

export const getToken = () => Cookies.get(TOKEN_KEY)

export const setToken = (token, params = {}) => {
  Cookies.set(TOKEN_KEY, token, params)
}

export const setRefreshToken = (token) => {
  Cookies.set(REGRESH_TOKEN_KEY, token)
}
  • request.js
import axios from 'axios'
import { getToken, setToken, getRefreshToken } from '@utils/auth'

// 刷新 access_token 的接口
const refreshToken = () => {
  return instance.post('/auth/refresh', { refresh_token: getRefreshToken() }, true)
}

// 创建 axios 实例
const instance = axios.create({
  baseURL:  process.env.GATSBY_API_URL,
  timeout: 30000,
  headers: {
    'Content-Type': 'application/json',
  }
})

instance.interceptors.response.use(response => {
    return response
}, error => {
    if (!error.response) {
        return Promise.reject(error)
    }
    // token 过期或无效,返回 401 状态码,在此处理逻辑
    return Promise.reject(error)
})

// 给请求头添加 access_token
const setHeaderToken = (isNeedToken) => {
  const accessToken = isNeedToken ? getToken() : null
  if (isNeedToken) { // api 请求需要携带 access_token 
    if (!accessToken) { 
      console.log('不存在 access_token 则跳转回登录页')
    }
    instance.defaults.headers.common.Authorization = `Bearer ${accessToken}`
  }
}

// 有些 api 并不需要用户授权使用,则不携带 access_token;默认不携带,需要传则设置第三个参数为 true
export const get = (url, params = {}, isNeedToken = false) => {
  setHeaderToken(isNeedToken)
  return instance({
    method: 'get',
    url,
    params,
  })
}

export const post = (url, params = {}, isNeedToken = false) => {
  setHeaderToken(isNeedToken)
  return instance({
    method: 'post',
    url,
    data: params,
  })
}

Затем преобразуйте перехватчик ответов axios в request.js.

instance.interceptors.response.use(response => {
    return response
}, error => {
    if (!error.response) {
        return Promise.reject(error)
    }
    if (error.response.status === 401) {
        const { config } = error
        return refreshToken().then(res=> {
            const { access_token } = res.data
            setToken(access_token)
            config.headers.Authorization = `Bearer ${access_token}`
            return instance(config)
        }).catch(err => {
            console.log('抱歉,您的登录状态已失效,请重新登录!')
            return Promise.reject(err)
        })
    }
    return Promise.reject(error)
})

Согласовано возвращать код состояния 401, чтобы указатьaccess_tokenПросрочено или недействительно, если пользователь инициирует запрос и возвращает результатaccess_tokenистек, запросите обновлениеaccess_tokenИнтерфейс. Введите, если запрос выполнен успешноthenВнутри сбросьте конфигурацию и обновитеaccess_tokenи повторите исходный запрос.

но еслиrefresh_tokenТакже просрочен, запрос также возвращает 401. На этом этапе отладка обнаружит, что функция не может быть введенаrefreshToken()изcatchвнутри, потому чтоrefreshToken()Этот же метод используется и внутри методаinstanceПример, повторение логики обработки перехватчика ответа 401, но сама функция является обновлениемaccess_token, поэтому интерфейс нужно исключить, а именно:

if (error.response.status === 401 && !error.config.url.includes('/auth/refresh')) {}

Приведенный выше код уже достиг бессмысленного обновленияaccess_token, когдаaccess_tokenЕсли он не истекает, он вернется в обычном режиме; когда он истечет, axios обновит токен один раз, а затем повторно инициирует исходный запрос.

оптимизация

Запретить несколько токенов обновления

Если токен истек, запросите обновлениеaccess_tokenТакже существует определенный интервал времени для возврата интерфейса, если в это время будут отправлены другие запросы, то обновление будет выполнено снова.access_tokenинтерфейс, это приведет к многократному обновлениюaccess_token. Поэтому нам нужно принять решение и определить метку, чтобы определить, обновляется ли она в данный момент.access_tokenсостояние, если оно находится в состоянии обновления, другим запросам больше не разрешается вызывать этот интерфейс.

let isRefreshing = false // 标记是否正在刷新 token
instance.interceptors.response.use(response => {
    return response
}, error => {
    if (!error.response) {
        return Promise.reject(error)
    }
    if (error.response.status === 401 && !error.config.url.includes('/auth/refresh')) {
        const { config } = error
        if (!isRefreshing) {
            isRefreshing = true
            return refreshToken().then(res=> {
                const { access_token } = res.data
                setToken(access_token)
                config.headers.Authorization = `Bearer ${access_token}`
                return instance(config)
            }).catch(err => {
                console.log('抱歉,您的登录状态已失效,请重新登录!')
                return Promise.reject(err)
            }).finally(() => {
                isRefreshing = false
            })
        }
    }
    return Promise.reject(error)
})

Обработка нескольких запросов одновременно

Вышеупомянутого подхода недостаточно, потому что, если несколько запросов инициируются одновременно, по истечении срока действия токена первый запрос входит в метод обновления токена, затем другие запросы входят без какой-либо логической обработки, просто возвращают ошибку и, наконец, только выполняется первый запрос. , что явно необоснованно.

Например, если три запроса инициированы одновременно, первый запрос входит в процесс обновления токена, второй и третий запросы необходимо сохранить, а запрос будет перевыпущен после обновления токена.

Здесь мы определяем массивrequests, используемый для сохранения ожидающего запроса, а затем возвратаPromise, пока вы не позвонитеresolveметод, запрос будет в состоянии ожидания, и вы можете знать, что массив фактически хранит функцию; при обновлении токена функция выполняется через цикл массива, то есть выполняется разрешение по одному на повторную отправку запрос.

let isRefreshing = false // 标记是否正在刷新 token
let requests = [] // 存储待重发请求的数组

instance.interceptors.response.use(response => {
    return response
}, error => {
    if (!error.response) {
        return Promise.reject(error)
    }
    if (error.response.status === 401 && !error.config.url.includes('/auth/refresh')) {
        const { config } = error
        if (!isRefreshing) {
            isRefreshing = true
            return refreshToken().then(res=> {
                const { access_token } = res.data
                setToken(access_token)
                config.headers.Authorization = `Bearer ${access_token}`
                // token 刷新后将数组的方法重新执行
                requests.forEach((cb) => cb(access_token))
                requests = [] // 重新请求完清空
                return instance(config)
            }).catch(err => {
                console.log('抱歉,您的登录状态已失效,请重新登录!')
                return Promise.reject(err)
            }).finally(() => {
                isRefreshing = false
            })
        } else {
            // 返回未执行 resolve 的 Promise
            return new Promise(resolve => {
                // 用函数形式将 resolve 存入,等待刷新后再执行
                requests.push(token => {
                    config.headers.Authorization = `Bearer ${token}`
                    resolve(instance(config))
                })  
            })
        }
    }
    return Promise.reject(error)
})

Окончательный код request.js

import axios from 'axios'
import { getToken, setToken, getRefreshToken } from '@utils/auth'

// 刷新 access_token 的接口
const refreshToken = () => {
  return instance.post('/auth/refresh', { refresh_token: getRefreshToken() }, true)
}

// 创建 axios 实例
const instance = axios.create({
  baseURL:  process.env.GATSBY_API_URL,
  timeout: 30000,
  headers: {
    'Content-Type': 'application/json',
  }
})

let isRefreshing = false // 标记是否正在刷新 token
let requests = [] // 存储待重发请求的数组

instance.interceptors.response.use(response => {
    return response
}, error => {
    if (!error.response) {
        return Promise.reject(error)
    }
    if (error.response.status === 401 && !error.config.url.includes('/auth/refresh')) {
        const { config } = error
        if (!isRefreshing) {
            isRefreshing = true
            return refreshToken().then(res=> {
                const { access_token } = res.data
                setToken(access_token)
                config.headers.Authorization = `Bearer ${access_token}`
                // token 刷新后将数组的方法重新执行
                requests.forEach((cb) => cb(access_token))
                requests = [] // 重新请求完清空
                return instance(config)
            }).catch(err => {
                console.log('抱歉,您的登录状态已失效,请重新登录!')
                return Promise.reject(err)
            }).finally(() => {
                isRefreshing = false
            })
        } else {
            // 返回未执行 resolve 的 Promise
            return new Promise(resolve => {
                // 用函数形式将 resolve 存入,等待刷新后再执行
                requests.push(token => {
                    config.headers.Authorization = `Bearer ${token}`
                    resolve(instance(config))
                })  
            })
        }
    }
    return Promise.reject(error)
})

// 给请求头添加 access_token
const setHeaderToken = (isNeedToken) => {
  const accessToken = isNeedToken ? getToken() : null
  if (isNeedToken) { // api 请求需要携带 access_token 
    if (!accessToken) { 
      console.log('不存在 access_token 则跳转回登录页')
    }
    instance.defaults.headers.common.Authorization = `Bearer ${accessToken}`
  }
}

// 有些 api 并不需要用户授权使用,则无需携带 access_token;默认不携带,需要传则设置第三个参数为 true
export const get = (url, params = {}, isNeedToken = false) => {
  setHeaderToken(isNeedToken)
  return instance({
    method: 'get',
    url,
    params,
  })
}

export const post = (url, params = {}, isNeedToken = false) => {
  setHeaderToken(isNeedToken)
  return instance({
    method: 'post',
    url,
    data: params,
  })
}

Справочная статья: