Головна / Статті

Коли автентифікація JWT стає залежною від стану: аргументи на користь сеансів з боку сервера в Node

Блок-листи та механізми оновлення даних усе одно роблять автентифікацію JWT залежною від стану. Сесії Express у Node.js, які базуються на Postgres, є простішими та підходять там, де JWT все ще є ефективним рішенням.

Видавець: REACTAPP.TOP7 хв читання1694 слів

Джерела: Forget JSON Web Tokens: Server-Side Sessions Cut Our Auth Bugs By 70%

Аутентифікація JWT без стану виглядає елегантно на дошці оголошень, але багато команд усвідомлюють, що вихід з системи, примусовий вихід та скидання паролів потайки повертають стан. Коли до цього додаються список заблокованих токенів, сховище токенів оновлення та користувацький мідлвейр, шар аутентифікації стає найбільш вразливою частиною системи. У цій статті розглядається така модель збою, показано, як налаштування сеансів з використанням Postgres у Express її замінює, та пояснюються рідкісні випадки, коли JWT залишаються кращим інструментом.

Знайома модель збою

Подібні баги зазвичай виникають у з’єднаннях між різними механізмами, кожен з яких додається для усунення обмежень попереднього.

Стандартні аргументи на користь JWT є привабливими. Не потрібен сховище сесій та пошук у базі даних: ви кладете ідентифікатор користувача у навантаження даних, підписуєте його, передаєте клієнту та дозволяєте самому токену бути джерелом істини.

Проблема полягає у скасуванні токенів. Підписаний токен залишається дійсним до закінчення терміну дії, і у сервера немає вбудованого способу достроково його скасувати. Виходження користувача з системи в усіх місцях, реагування на вкрадений токен чи блокування доступу після зміни пароля — усе це вимагає списку заблокованих токенів, що саме є тим типом спільного стану, який проект мав усунути.

У описаній вище ситуації команда змушена використовувати список заблокованих токенів у Redis, окремий сховище для токенів оновлення та власний шар проміжного програмування, який їх поєднує. Система, прийнята через відсутність стану, стала найбільш «статичною» складовою в архітектурі.

У що перетворюється перевірка за кожним запитом

Спрощена версія мідлвейру, яка виконується з кожним запитом, виглядає так. Він бере токен-носій з заголовка Authorization, шукає його у списку заборонених значень Redis та потім перевіряє підпис та дані за допомогою jwt.verify.

// verify access token
function verifyToken(req, res, next) {
  const token = req.headers.authorization?.split(" ")[1];
  if (!token) return res.status(401).json({ error: "No token" });

// check if revoked
  const revoked = await redis.get(`revoked:${token}`);
  if (revoked) return res.status(401).json({ error: "Token revoked" });
  jwt.verify(token, process.env.SECRET, (err, decoded) => {
    if (err) return res.status(401).json({ error: "Invalid token" });
    req.user = decoded;
    next();
  });
}

У нинішньому вигляді функція навіть не буде запущена: вона використовує await без оголошення як async. Більша проблема полягає в архітектурі – кожен запит тепер здійснює мережевий виклик до Redis, тож основна перевага JWT, а саме уникнення пошуку, зникає. Крім того, виникають витрати на обробку CPU для перевірки токена, а також окрема перевірка терміну дії та даних.

Баги ховаються у проміжках між цими трьома перевірками:

  • Токен доступу закінчує термін дії, а виклик на оновлення працює без жодних повідомлень про помилку.
  • Ключ блок-листу в Redis закінчує свою дію раніше, ніж JWT, який він мав блокувати, тому анульований токен знову стає дійсним.
  • Запит на вихід реалізується одночасно з наступним викликом API, і користувач виходить та знову входить протягом однієї секунди.
  • Кожна з цих проблем потребує виправлення на різному рівні, і виправлення на одному рівні може легко порушити припущення на іншому.

    Заміна трьох механізмів одним сховищем сеансів

    Альтернативою є сеанси з боку сервера. Клієнт зберігає лише непрозорий ідентифікатор сеансу у куки, а дані сеансу знаходяться у вашій базі даних. У Express бібліотека express-session з сховищем connect-pg-simple зберігає сеанси у Postgres:

    const session = require("express-session");
    const PgSession = require("connect-pg-simple")(session);
    
    app.use(session({
      store: new PgSession({ pool: db }),
      secret: process.env.SESSION_SECRET,
      resave: false,
      saveUninitialized: false,
      cookie: {
        httpOnly: true,
        secure: true,
        maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
      }
    }));
    

    Кілька налаштувань виконують основну роботу зі забезпечення безпеки. Параметр httpOnly не дозволяє JavaScript у сторінці отримати доступ до куки, що ускладнює крадіжку токенів через XSS. Параметр secure гарантує, що кука надсилається лише через HTTPS. Параметр resave: false запобігає збереженню незмінених сеансів при кожному запиті, а параметр saveUninitialized: false не дозволяє сховищу заповнюватися порожніми сеансами анонімних відвідувачів. Значення maxAge визначає термін дії сеансів у сім днів. Зауважте, що модуль connect-pg-simple очікує наявності таблиці сеансів, тому створіть її як частину ваших міграцій.

    Потім операції стають майже простими:

    • Під час входу встановлюється значення req.session.userId.
    • Під час виходу викликається функція req.session.destroy().
    • Примусовий вихід видаляє рядки користувача з таблиці сеансів.

    Скасування тепер здійснюється одним простим операцією видалення, без необхідності очікування закінчення терміну дії списку заблокованих елементів, що усуває ризик втрати синхронізації, а кожен запит отримує однаковий авторитетний запис. Варто додати два кроки посилення безпеки: викликати req.session.regenerate() під час входу для запобігання фіксації сеансу та встановити параметр sameSite у кукі (а також захист CSRF за потреби), оскільки кукі автоматично надсилаються браузером, на відміну від заголовків-носіїв даних.

    У описаному тут сценарії міграція призвела до видалення приблизно 340 рядків логіки автентифікації, і кожен видалений рядок означав на одне менше місце для приховування помилок.

    Оцінка змін

    Щоб об’єктивно оцінити цю зміну, команда відстежувала кожен звіт про помилку, пов’язану з автентифікацією, протягом 90 днів до міграції та протягом 90 днів після неї. Отримані ними цифри:

    Before (JWT):
      - Auth bugs reported:        43
      - Avg debug time per bug:    2.4 hours
      - Force-logout capability:   not possible
    
    After (Sessions):
      - Auth bugs reported:        13
      - Avg debug time per bug:    0.6 hours
      - Force-logout time:         < 1 second (delete row)
    Reduction: ~70%
    

    Client --> [Token in header] --> Server
                                        |
                                Decode token (CPU)
                                        |
                                Check Redis blocklist (network)
                                        |
                                Validate expiry + claims
                                        |
                                    Route handler
    

    Client --> [Session cookie] --> Server
                                        |
                              Lookup session in Postgres
                                        |
                                    Route handler
    

    Одне пошукове запитання до єдиного джерела інформації означає, що коли щось ламається, існує лише одне місце для пошуку.

    Де все ще мають своє місце JWT

    Усе це не робить JWT поганими. Вони ідеально підходять для токенів короткого терміну дії з конкретною метою: посилань на підтвердження електронної пошти, процесів скидання пароля та інших самодостатніх доказів наміру, які закінчуються дію протягом кількох хвилин та ніколи не потребують скасування під час використання. Токен підтвердження, як показано нижче, містить ідентифікатор користувача та його призначення, і закінчує свою дію через 15 хвилин:

    // email verification — expires fast, never needs revocation
    function makeVerifyToken(userId) {
      return jwt.sign({ userId, purpose: "verify" }, process.env.SECRET, {
        expiresIn: "15m"
      });
    }
    

    Вказівка purpose має велике значення, адже вона запобігає прийманню токена верифікації там, де очікується інший тип токена. Пам’ятайте, що сам JWT не може гарантувати одноразового використання; якщо посилання на скидку має працювати лише один раз, необхідно зафіксувати, що воно було використане, або прив’язати токен до значення, яке змінюється після використання, наприклад, до хешу поточного пароля.

    Проблемою ніколи не були самі специфікації. Проблемою було їх розширення для підтримки постійних, скасовуваних та довготривалих сеансів користувачів, для чого взагалі були створені сеанси.

    Чому команди тримаються складних механізмів автентифікації

    Сеанси вважаються застарілими, тоді як JWT виглядають сучасними, і кількість навчальних матеріалів про JWT значно перевищує кількість матеріалів про сеанси. Цей дисбаланс впливає на те, що вважається правильним.

    Досвідчені інженери часто обирають протилежний підхід та вдаються до нудних рішень. Достатньо випадків з порушенням роботи продукту, щоб зрозуміти: нудні системи рідко сповіщають когось уночі, а сеанси з боку сервера надійно працювали протягом десятиліть ще до появи Redis.

    Вибір підходу

    • Створюєте новий веб-додаток? Використовуйте сеанси з боку сервера: cookie з параметрами httpOnly, secure, sameSite, сеанси зберігаються в Postgres, оновлюються під час входу та знищуються після виходу. Для більш детального порівняння ознайомтесь з нашим посібником щодо вибору між сеансами та JWT.
    • Використовуєте конфігурацію JWT, яка працює без проблем? Не змінюйте її; міграція заради самої міграції створює додаткові ризики.
  • Ви вже маневруєте з токенами оновлення, списком заблокованих адрес та ручним скасуванням доступу? Тоді сеанси вже існують у вашій системі, лише з більшою кількістю інфраструктури, більшою кількістю точок збою та більшою кількістю годин на дебагування.
  • Найкраще інженерне рішення — це часто те, яке дозволяє скоротити код. Чим менше рядків, тим менше місць для помилок, а чим менше шарів, тим швидше можна знайти наявні помилки.