Главная / Статьи

Когда авторизация JWT становится состоянийной: аргументы в пользу сессий на стороне сервера в Node

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

Издатель: REACTAPP.TOP7 мин чтения1694 слов

Источники: Forget JSON Web Tokens: Server-Side Sessions Cut Our Auth Bugs By 70%

Аутентификация с JWT без учета состояния кажется элегантной на доске объявлений, но многие команды обнаруживают, что процессы выхода из системы, принудительного выхода и сброса паролей тайно восстанавливают учет состояния. Как только в дело вступают список заблокированных токенов, хранилище токенов обновления и пользовательский промежуточный слой, слой аутентификации становится самой уязвимой частью системы. В этой статье рассматривается такая модель сбоев, показано, как настройка сессий с использованием Postgres в Express заменяет ее, и объясняются редкие случаи, когда JWT остается более подходящим инструментом.

Знакомая модель сбоев

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

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

Почему «безсостоянийный» редко остается безсостоянийным

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

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

В описанном выше сценарии команда вынуждена использовать список заблокированных токенов в 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%
    

    Количество сообщений об ошибках сократилось с 43 до 13, что соответствует примерно 70%-ной снижению, а среднее время отладки за одну ошибку упало с 2,4 часов до 0,6. Принудительный выход из системы стал возможен — теперь достаточно удалить запись за менее чем секунду. Это данные одной команды, а не эталонные показатели.

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

    Сравнение двух путей обработки запросов

    Если рассмотреть их параллельно, разница становится очевидной. При использовании JWT сервер сначала декодирует токен, запрашивает список заблокированных адресов в Redis и проверяет срок действия токена и его параметры перед тем, как запустить обработчик маршрута:

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

    При использовании сессий сервер сначала читает информацию о сессии из Postgres, а затем передает ее обработчику маршрута:

    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.

    Выбор подхода

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