Главная / Статьи / Почему системы Concurrent 401 выходят пользователей из аккаунтов и как исправить проблему обновления при одновременном использовании

Почему системы Concurrent 401 выходят пользователей из аккаунтов и как исправить проблему обновления при одновременном использовании

Как параллельные запросы и обновление токенов могут привести к неожиданному выходу из аккаунта, и как единое обещание обновления в интерцепторе Axios предотвращает это.

1371 слов

Пользователь находится на полпути заполнения длинной формы, когда приложение возвращает его на экран входа. Ошибок или сбоев нет, но всё, что он ввел, исчезает. Логика истечения срока действия токена кажется корректной, процесс обновления тоже, однако сессии продолжают заканчиваться преждевременно. Ниже вы узнаете, почему это происходит, когда несколько запросов одновременно сталкиваются с истекшим токеном, почему этот баг так трудно обнаружить при просмотре кода, и как небольшое изменение в интерцепторе Axios с использованием единственного обещания обновления решает проблему.

Симптом: выходы из аккаунта, которые не должны происходить

Представьте платформу управления делами, используемую сотрудниками на местах. Сотрудники вводят данные об инспекциях или осмотрах на планшетах, часто через ненадежные мобильные соединения. Появляется отчет, затем еще один от другого пользователя: приложение вывело его из сессии во время заполнения формы. В такой системе это — не просто незначительная неприятность; это может означать необходимость повторной ручной ввода данных, занимающей двадцать минут.

Очевидным виновником является истечение срока действия токена, но на первый взгляд это кажется безобидным. Токены доступа действуют 15 минут. Когда запрос API возвращает код 401, клиент обращается к точке обновления, сохраняет новый токен и продолжает работу. Ничто в описанной схеме не должно приводить к прерыванию сессии в процессе использования. Если очевидное объяснение подтверждается, правильным шагом будет перестать полагаться на собственное понимание кода и воспроизвести сбой.

Воспроизведение проблемы, связанной с истечением срока действия токена

Эта ошибка возникает только при одном условии: несколько запросов к API отправляются почти одновременно, примерно в момент истечения срока действия токена доступа. Форма с несколькими разделами — идеальный триггер: она может отправлять запрос на проверку валидности, запрос на автосохранение и запрос для проверки статуса загрузки файла в течение нескольких миллисекунд друг от друга. Если токен истекает в течение этого временинного промежутка, все запросы практически одновременно возвращают код 401.

Интерцептор был написан для ситуации с одним запросом: при получении кода 401 вызывается конечная точка обновления, получается новый токен, и отправляется первоначальный запрос заново. При тестировании с одним запросом за раз он работает идеально. Однако при одновременной неудаче четырех запросов он начинает отправлять четыре независимых запроса на обновление токена одновременно.

Это согласуется с разумным решением со стороны серверной части — ротацией токенов обновления. Когда используется токен обновления, сервер аннулирует его и выдает новый, так что украденный токен обновления нельзя бесконечно использовать снова. Теперь рассмотрим четыре параллельных запроса на обновление:

  • Первый запрос на обновление успешен и возвращает новый токен обновления.
  • Второй, третий и четвертый запросы уже были отправлены с использованием старого токена обновления до поступления первого ответа.
  • Сервер отклоняет их, поскольку этот токен только что был аннулирован.
  • Интерцептор интерпретирует неудачу обновления как «сессия действительно завершилась» и выходит пользователя из системы.

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

Почему просмотр кода этого не показал

Возможно, это более ценный урок, чем само решение проблемы. Если читать код сверху вниз, интерцептор работает правильно: он ловит ошибку 401, обновляет токен и пытается снова. Именно такая последовательность была задумана при его написании, и повторный просмотр кода лишь подтверждает её.

Однако просмотр кода скрывает тот факт, что интерцептор выполняется не один раз. Он запускается каждый раз при неудачном запросе, причём эти запросы происходят одновременно, а не по порядку. Попытка отладки как у линейного скрипта приводит к тому, что анализируется то, что делает каждый шаг, при этом игнорируется момент его выполнения.

Шаблон становится видимым практически сразу, как только вы записываете временную метку при каждом запросе на обновление. В подобной ситуации вы увидите четыре попытки обновления примерно через 40 миллисекунд друг от друга, все они направлены на один и тот же конечный пункт. Час разбора логики можно заменить двумя минутами анализа временных характеристик. Когда согласно коду ошибка «не может возникнуть», отслеживание порядка и времени событий часто бывает быстрее, чем повторное чтение кода.

Решение: одно обновление в процессе, все остальные ждут

Как только становится ясна истинная природа проблемы, решение оказывается простым. Вместо того чтобы каждый сигнал 401 запускал свое собственное обновление, интерцептор проверяет, уже ли ведется какое-либо обновление. Если оно уже идет, новая ошибка не запускает еще одно обновление; он ждет той же пока не выполненной задачи и повторяет попытку, когда эта задача будет решена. Это часто называется паттерном одного запуска.

Первым элементом является переменная на уровне модуля, которая хранит информацию о ведущемся обновлении или значение null, когда никакое обновление не выполняется:

let refreshPromise = null;

Обработчик ниже вызывается при запросе, который завершился с ошибкой 401. Если переменная refreshPromise пуста, он вызывает функцию refreshAccessToken() и сохраняет полученный обещание, привязав к нему блок finally, который сбрасывает значение переменной в null независимо от того, успешен ли процесс обновления или нет, чтобы следующее истечение срока могло запустить новую попытку. Каждый вызывающий код, первый и все последующие, ожидает это же обещание, устанавливает новый токен-носитель в исходную конфигурацию запроса и повторно отправляет запрос с помощью axios.

async function handleUnauthorized(originalRequest) {
  if (!refreshPromise) {
    refreshPromise = refreshAccessToken().finally(() => {
      refreshPromise = null;
    });
  }  const newToken = await refreshPromise;
  originalRequest.headers.Authorization = `Bearer ${newToken}`;
  return axios(originalRequest);
}

Синтаксис имеет меньшее значение, чем суть: единый общий объект обещания, на котором ожидают все неудачные запросы, вместо того чтобы каждый из них пытался обновить данные самостоятельно. Первый ответ 401 запускает процесс обновления; любой последующий ответ 401 во время его выполнения использует уже полученный результат, вместо того чтобы снова использовать токен обновления. Поскольку JavaScript выполняет этот код на одной потоке, проверка и присваивание значения refreshPromise не могут происходить одновременно для разных вызовов, и именно поэтому достаточно такой простой защиты.

При интеграции этого механизма в реальный интерцептор стоит учесть несколько деталей:

  • Если сам процесс обновления тоже проваливается, все ожидающие запросы отклоняются одновременно, что позволяет приложению выполнить один чистый процесс выхода из аккаунта вместо нескольких конкурирующих операций.
  • Марк добавил повторные запросы (например, с флагом _retry в настройках), чтобы запрос, совершивший ошибку 401, после обновления не заходил в бесконечный цикл.
  • Убедитесь, что сам запрос на обновление обходит этот обработчик, иначе ответ 401 от конечной точки обновления приведёт к попытке ещё одного обновления.
  • Каждая отдельная вкладка браузера имеет свой собственный контекст JavaScript, поэтому они могут конкурировать друг с другом; если это важно для вашего приложения, координируйте действия между вкладками или проактивно обновляйте данные до истечения срока действия.
  • Чтобы узнать больше о серверной части такой же архитектуры, включая ротацию и аннулирование токенов, ознакомьтесь с нашим руководством по стратегии использования токенов обновления для систем аутентификации Node.js.

    Основные выводы

    После внедрения обновления с однократным запуском проблемы с внезапными выходами из аккаунта исчезли. Более общий принцип заключается в следующем: рассматривайте одновременные запросы как стандартный случай, а не как исключение, которое нужно решать позже. Каждый раз, когда вы пишете интерцептор, очередь или любой обработчик, реагирующий на асинхронные сбои, задавайте себе вопрос: что произойдёт, если он выполнится четыре раза в течение пятидесяти миллисекунд? Трафик из загруженных форм и нестабильных мобильных соединений рано или поздно приведёт к такой ситуации.

    • Проблема на самом деле была не в токенах обновления; она касалась кода, предполагавшего, что события происходят по одному.
    • Ротация токенов обновления — это хорошая практика безопасности, но именно она приводит к появлению дублирующихся запросов на обновление.
    • Код, который кажется корректным при пошаговом чтении, всё равно может дать сбой в условиях конкурентности; записывайте временные метки, чтобы понимать, когда происходят события, а не только что они происходят.
  • Реализуйте единый механизм обновления данных во время работы приложения для всех неудачных запросов, сбросьте его в блоке finally и избегайте циклов повторных попыток.
  • Связанные статьи