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

Симптом обычно выглядит безобидно: в одной вкладке нажали logout, а другая ещё открывает защищённый экран. Другой вариант — после renewal запрос из старой вкладки получает ошибку, а поздний logout неожиданно закрывает уже новую сессию. По одному экрану нельзя понять, какой ID пришёл на сервер, какой ID считался текущим и какой scope имел logout.

\n

Цена ошибки — не только плохой UX. Если сервер принимает старый ID после rotation, украденный или повторно отправленный идентификатор остаётся рабочим. Если logout по старому ID отзывает successor, пользователь теряет новую сессию после обычной сетевой задержки. Если система очищает только cookie, интерфейс говорит «вы вышли», но следующий запрос может пройти.

\n

Тезис статьи простой: cookie переносит идентификатор, но не принимает решение о доступе. Серверная запись хранит статус ID, его поколение и связь с lineage. Перед защищённым действием сервер принимает только current active ID. Rotation заменяет current ID. Logout отзывает запись в согласованном scope и отдельно просит браузер очистить cookie. Эти события нельзя свести к одной строке или одному флагу.

\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: что он доказывает

\n

Минимальный fixture создаёт fixture-s-1, выполняет rotation и получает fixture-s-2. Повторная rotation со старым ID возвращает reject. Stale logout со старым ID также возвращает reject и оставляет successor active. Logout с текущим ID переводит successor в revoked и удаляет current pointer.

\n
const first = createTeachingSessionState();\nconst replacement = rotateTeachingSession(first, 'fixture-s-1');\nconst staleRotation = rotateTeachingSession(replacement.state, 'fixture-s-1');\nconst staleLogout = logoutTeachingSession(replacement.state, 'fixture-s-1');\n\nif (staleRotation.accepted || staleLogout.accepted) {\n  throw new Error('old ID changed current session');\n}\nif (replacement.state.records['fixture-s-2'].status !== 'active') {\n  throw new Error('successor is not active');\n}
\n

Пример ограничен памятью процесса. Он не проверяет HTTP endpoint, реальный Set-Cookie, браузерное хранилище, TLS, random ID, серверные часы, CSRF, reauthentication, несколько устройств, распределённую блокировку или конкурентные запросы. Команда node web/scripts/upgrade-2023-02.mjs --verify-fixture подтверждает assertions учебной модели, а не состояние неизвестной production-системы.

\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"}