{ "index": 175, "slug": "editorial-2023-02-field-sessions-auth", "title": "Старый session ID после rotation: как проверить logout и не закрыть новую сессию", "excerpt": "После renewal старая вкладка не всегда означает старую cookie: запрос мог уйти раньше, прийти позже или иметь другой scope. Разбираем server-side проверку, stale-события, logout и воспроизводимый fixture.", "contentHtml": "

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

\n

Сначала уточним важную деталь. Вкладки одного origin обычно используют общий cookie jar. Поэтому сама по себе «старая вкладка» не доказывает, что у неё остался старый cookie. Старый ID может быть внутри уже отправленного запроса, в явно заданном заголовке другого клиента, в cookie с другим Path или Domain либо в ответе, который пришёл позже. Это разные причины, но для сервера требование одно: predecessor после rotation не должен менять защищённое состояние.

\n

Цена ошибки двойная. Если старый ID всё ещё принимается, rotation почти не уменьшает окно повтора украденного или задержанного идентификатора. Если stale logout отзывает successor, пользователь теряет новую сессию из-за сетевой задержки. Правильная диагностика разделяет доставку cookie, состояние записи на сервере, указатель current и scope операции logout.

\n

Сначала разделите четыре объекта

\n

Cookie — это транспорт. Браузер решает, приложить ли её к запросу с учётом имени, host, пути, Secure и SameSite. Сервер получает строку и должен найти запись. Наличие cookie не доказывает, что запись active, что ID current или что пользователь имеет право выполнить операцию.

\n

Session record — источник решения о состоянии конкретного ID. Минимальная учебная запись содержит id, lineage, generation и status. Значение active означает, что ID может пройти проверку сессии. Значение rotated означает, что запись известна, но заменена successor. Значение revoked означает, что сессия отозвана. unknown — это отсутствие записи.

\n

Lineage связывает последовательность ID одной сессии. У неё есть один current pointer. До rotation pointer указывает на fixture-s-1. После успешной rotation он указывает на fixture-s-2. Старый ID можно хранить ограниченное время для диагностики и явного reject, но он не должен снова становиться current.

\n

Authorization остаётся отдельным слоем. Active session отвечает на вопрос «какая сессия предъявлена?». Проверка прав отвечает на вопрос «может ли она выполнить это действие?». Не выдавайте active ID больше полномочий, чем описывает политика handler.

\n
Контракт одной сессии
ОбъектЧто он решаетМинимальная проверкаЧего он не доказывает
CookieКакой ID браузер отправитname, host, Path и атрибуты scopeЧто сервер считает ID active
Session recordПринимать ли предъявленный ID сейчасstatus и совпадение с currentЧто у сессии есть нужное право
Lineage pointerКакой ID является successorОдин current после rotationЧто logout означает для всех устройств
AuthorizationМожно ли выполнить конкретное действиеПроверка права после session checkЧто cookie доставлена безопасно
\n

Почему logout не заканчивается очисткой cookie

\n

У logout две разные обязанности. Сервер должен изменить состояние записи и перестать принимать отозванный ID. Клиент должен получить Set-Cookie с тем же именем и тем же scope, но с пустым значением и сроком в прошлом. Первая ветвь закрывает доступ. Вторая убирает удобный носитель ID из браузера.

\n

Если выполнена только клиентская ветвь, сохранённый запрос, другой клиент или уже отправленный заголовок всё ещё может предъявить прежний ID. Если выполнена только серверная ветвь, доступ уже закрыт, но интерфейс может продолжать отправлять cookie до следующего ответа. Это разные симптомы и разные проверки.

\n

Scope очистки должен совпадать со scope выдачи. Имя и путь не являются единственными деталями: при использовании Domain он тоже входит в совпадение. Учебный контракт использует __Host-session, Secure, HttpOnly, SameSite=Lax, Path=/ и не использует Domain. Если реальному продукту нужен общий cookie на нескольких поддоменах, этот выбор уже не подходит и требует отдельного контракта.

\n
Cookie: __Host-session=fixture-s-2\n\n// server-side decision, учебная модель\nrecord.status === 'active'\n  && currentByLineage[record.lineage] === record.id\n  && can(record, action)\n\n// logout-current\nrecord.status = 'revoked'\ndelete currentByLineage[record.lineage]\nSet-Cookie: __Host-session=; Path=/; Secure; HttpOnly; SameSite=Lax; Expires=Thu, 01 Jan 1970 00:00:00 GMT
\n

Это учебный фрагмент. Он не задаёт формат production cookie, не генерирует секрет и не доказывает, что конкретный framework или браузер применит ответ именно так. Его задача — отделить решение сервера от доставки значения браузером.

\n

Rotation меняет право предъявления

\n

Rotation начинается с проверки текущей записи. Сервер находит предъявленный ID, проверяет его статус и сравнивает с current pointer. Только после этого он переводит predecessor в rotated, создаёт successor с новым поколением и обновляет pointer. Ответ выдаёт cookie с successor.

\n

Старый ID не является неизвестным. Сервер может знать его lineage и successor, но всё равно должен отклонить его до защищённого эффекта. Внешний ответ может быть одинаковым для разных причин, однако журнал проверки должен отличать unknown, rotated и revoked. Иначе расследование не покажет, действительно ли rotation вывела старый ID из обращения.

\n

Параллельные запросы требуют отдельной гарантии: транзакции, conditional update, compare-and-swap или эквивалентного механизма хранилища. Нужен один исход — один запрос создаёт successor, другой получает stale/rejected. Синхронный fixture проверяет порядок вызовов в памяти и не моделирует гонку, распределённый cache, задержку базы или порядок HTTP-ответов.

\n
\"Жизненный
Иллюстрация показывает правило для одной lineage и синхронных учебных вызовов. Это не browser trace, не журнал инцидента и не доказательство порядка реальных HTTP-ответов.
\n

Отрицательный путь важнее зелёного login

\n

Положительный сценарий показывает, что новый ID работает. Он не показывает, что старый больше не работает. Поэтому после rotation предъявите predecessor и проверьте reject до protected effect. Затем предъявите successor и проверьте обычный доступ. После logout текущего ID повторите запрос и ожидайте reject.

\n

Отдельно проверьте stale logout. При политике logout-current logout с predecessor не должен отзывать successor. Это не универсальная истина для всех продуктов. Если бизнесу нужен logout всей lineage или всех устройств, назовите scope явно, найдите все записи и проверьте другой инвариант. Нельзя получить такую семантику случайно из обработчика, который просто принимает любой известный ID.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Старый ID проходит после renewalHandler не проверяет current или record не стал rotatedСопоставить безопасный ID, status и current pointer на момент обработкиОтклонять rotated ID до эффекта и атомарно менять pointer
Logout меняет только экранОчищена cookie, но сервер не revoke-нул recordПроверить audit logout и status той же записиRevoke на сервере, затем вернуть clear cookie
Поздний logout закрывает новую сессиюStale ID трактуется как currentСравнить предъявленный ID с currentByLineageВыбрать logout-current или явный более широкий scope
После clear видна другая cookieIssue и clear расходятся по Path или DomainСравнить атрибуты Set-Cookie без значенияПовторить name, Path и Domain при наличии
Сбой только в одном браузереОтличается cookie policy или порядок ответовПовторить разрешённый сценарий на конкретной версии и собрать метаданныеНе менять server contract до подтверждения различия
\n

Воспроизводимый fixture на Node.js

\n

Ниже — маленькая модель без зависимостей. Она проверяет инвариант: после rotation stale logout не отзывает successor, а logout current отзывает его. Команду можно запустить в Bash или Zsh на Node.js 18 и новее.

\n
node --input-type=module <<'NODE'\nconst state = {\n  current: 's-1',\n  records: new Map([['s-1', {status: 'active'}]]),\n};\n\nfunction rotate(presentedId) {\n  const record = state.records.get(presentedId);\n  if (!record || record.status !== 'active' || state.current !== presentedId) {\n    return {accepted: false, reason: 'stale-or-invalid'};\n  }\n\n  const successor = 's-2';\n  record.status = 'rotated';\n  state.records.set(successor, {status: 'active'});\n  state.current = successor;\n  return {accepted: true, successor};\n}\n\nfunction logoutCurrent(presentedId) {\n  const record = state.records.get(presentedId);\n  if (!record || record.status !== 'active' || state.current !== presentedId) {\n    return {accepted: false, reason: 'stale-or-invalid'};\n  }\n\n  record.status = 'revoked';\n  state.current = null;\n  return {accepted: true};\n}\n\nconst rotation = rotate('s-1');\nconst staleLogout = logoutCurrent('s-1');\nconst currentLogout = logoutCurrent(rotation.successor);\nconst result = {\n  rotation,\n  staleLogout,\n  currentLogout,\n  current: state.current,\n  successorStatus: state.records.get('s-2').status,\n};\n\nconsole.log(JSON.stringify(result, null, 2));\nif (staleLogout.accepted\n    || !currentLogout.accepted\n    || result.successorStatus !== 'revoked') {\n  process.exitCode = 1;\n}\nNODE
\n

Ожидаемый результат содержит \"staleLogout\": {\"accepted\": false, ...}, затем успешный currentLogout и статус successor revoked. Если убрать сравнение state.current !== presentedId, fixture покажет опасное поведение: stale logout сможет изменить новую сессию.

\n

Fixture намеренно не является HTTP-тестом. Он не генерирует криптографически случайный ID, не проверяет TLS, реальный Set-Cookie, браузерный cookie jar, CSRF, reauthentication, несколько устройств, задержку базы, распределённую блокировку и порядок сетевых ответов. Для настоящего сервиса этот тест нужно дополнить интеграционными запросами и конкурентным тестом хранилища.

\n

Порядок действий

\n
  1. Зафиксируйте симптом. Запишите внешний status, защищённый эффект, безопасную корреляцию операции и момент обработки. Не называйте историю browser trace, если его не собирали.
  2. Спрячьте секрет. Используйте surrogate ID и не помещайте bearer value в лог, таблицу или снимок.
  3. Назовите состояние. Разделите cookie delivery, server record, lineage pointer и authorization. Для каждого объекта назначьте владельца.
  4. Проверьте fixture. Выполните положительные и отрицательные переходы: rotation, stale rotation, stale logout и logout current.
  5. Проверьте реализацию. В разрешённой среде сравните status и current pointer до protected effect. Отдельно сверяйте атрибуты issue и clear cookie.
  6. Проверьте гонку. Для реального хранилища зафиксируйте гарантию, которая не допускает двух successor и не позволяет stale событию изменить новую запись.
  7. Выберите scope logout. Зафиксируйте logout-current, logout-lineage или logout-all как разные операции с разными проверками.
  8. Сделайте малый diff. Исправляйте подтверждённый слой: серверный reject, атомарность, cookie scope, audit или UX после reject.
  9. Опишите откат. Не возвращайте retired ID в active ради быстрого rollback. Подготовьте совместимую миграцию или откат экрана при сохранённом reject старого ID.
\n

Ограничения и критерий готовности

\n

Cookie flags не заменяют server-side validation. Secure ограничивает канал доставки, HttpOnly ограничивает доступ через browser API, а SameSite ограничивает часть cross-site отправок. Ни один из них не проверяет статус записи, право на действие или успешность logout. Max-Age и Expires задают срок хранения у user agent, но не должны быть единственным серверным timeout.

\n

Эта модель не выбирает SQL, cache, signed token или конкретный framework. Она требует только одного current ID и явного решения для stale состояния. Если система не хранит lineage, можно выбрать другую реализацию, но тогда нужно доказать, как она отличает повторный старый ID от текущего и как предотвращает позднее изменение successor.

\n

Материал готов к применению как проверяемая схема, если команда может показать: predecessor и successor без секретов; status каждого в момент запроса; current pointer; scope logout; результат stale rotation и stale logout; совпадение name, Path и Domain у issue/clear cookie; и отдельную гарантию для конкурентной rotation. Один зелёный fixture или исчезнувшая cookie этот критерий не закрывают.

\n

Проверяемые источники

\n" }