Чому системи Concurrent 401 виводять користувачів, та як виправити це через оновлення одночасного запуску
Як паралельні запити та зміна токенів оновлення призводять до несподіваних виходів з системи, та як спільна обіцянка оновлення в інтерцепторі Axios запобігає цьому.
Користувач знаходиться на середині заповнення довгої форми, коли додаток повертає його на екран входу. Помилок чи збоїв немає, але все, що він ввів, зникає. Логіка закінчення терміну дії токена виглядає правильною, процес оновлення також правильний, проте сесії продовжують закінчуватися раніше часу. Нижче ви побачите, чому це відбувається, коли кілька запитів одночасно надходять до токена, термін дії якого вже закінчився, чому цю помилку так важко виявити, переглядаючи код, та як невелика зміна в інтерцепторі 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 від кінцевої точки оновлення спробує оновити себе знову.Щодо серверної частини такого ж дизайну, включаючи ротацію та скасування, дивіться наш посібник стратегію refresh-токенів для систем автентифікації Node.js.
Основні висновки
З впровадженням оновлення для одноразових запусків проблеми з несподіваним виходом з системи припиняються. Це є частиною загальної практики: розглядати одночасні виклики як стандартний випадок, а не як окрему ситуацію, яку потрібно вирішувати пізніше. Кожного разу, коли ви пишете інтерцептор, чергу чи будь-який обробник, який реагує на асинхронні помилки, запитуйте себе, що станеться, якщо він виконається чотири рази протягом п’ятдесяти мілісекунд. Трафік у продакшені від активних форм та нестабільних мобільних з’єднань рано чи пізно призведе до такої ситуації.
- Проблема насправді полягала не у токенах оновлення; вона стосувалася коду, який припускав, що події відбуваються по одній.
- Обмін токенами оновлення — це хороша практика забезпечення безпеки, але саме вона і дозволяє виявити дубльовані запити на оновлення.
- Код, який здається правильним при поштовховому читанні, все одно може зламатися під дією конкурентності; фіксуйте часи подій, щоб бачити, коли вони відбуваються, а не лише що саме відбувається.
finally та захистіться від циклів повторних спроб.Пов’язана література
- Впровадження токенів доступу та оновлення разом у Node.js — Дізнайтеся, як поєднувати токени доступу короткого терміну дії з токенами оновлення, які постійно змінюються, у Node.js для досягнення балансу між безпекою та безперешкодними сеансами користувачів.
- Чому керування функціями у React useEffect ніколи не повинні бути асинхронними — Дізнайтеся, чому повернення асинхронної функції з useEffect порушує принципи очищення даних у React, та ознайомтесь із чотирма правильними підходами до безпечної обробки асинхронної логіки.
- Express та Axios: оновлення токенів, відстеження запитів окремо — Створіть процес отримання та оновлення JWT-токенів за допомогою бекенду Express та клієнта Axios, а потім простежте, як помилка 401 перетворюється на спільне оновлення токену та прозору повторну спробу.