{ "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.
\nCookie — это транспорт. Браузер решает, приложить ли её к запросу с учётом имени, host, пути, Secure и SameSite. Сервер получает строку и должен найти запись. Наличие cookie не доказывает, что запись active, что ID current или что пользователь имеет право выполнить операцию.
\nSession record — источник решения о состоянии конкретного ID. Минимальная учебная запись содержит id, lineage, generation и status. Значение active означает, что ID может пройти проверку сессии. Значение rotated означает, что запись известна, но заменена successor. Значение revoked означает, что сессия отозвана. unknown — это отсутствие записи.
Lineage связывает последовательность ID одной сессии. У неё есть один current pointer. До rotation pointer указывает на fixture-s-1. После успешной rotation он указывает на fixture-s-2. Старый ID можно хранить ограниченное время для диагностики и явного reject, но он не должен снова становиться current.
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 доставлена безопасно |
У logout две разные обязанности. Сервер должен изменить состояние записи и перестать принимать отозванный ID. Клиент должен получить Set-Cookie с тем же именем и тем же scope, но с пустым значением и сроком в прошлом. Первая ветвь закрывает доступ. Вторая убирает удобный носитель ID из браузера.
Если выполнена только клиентская ветвь, сохранённый запрос, другой клиент или уже отправленный заголовок всё ещё может предъявить прежний ID. Если выполнена только серверная ветвь, доступ уже закрыт, но интерфейс может продолжать отправлять cookie до следующего ответа. Это разные симптомы и разные проверки.
\nScope очистки должен совпадать со scope выдачи. Имя и путь не являются единственными деталями: при использовании Domain он тоже входит в совпадение. Учебный контракт использует __Host-session, Secure, HttpOnly, SameSite=Lax, Path=/ и не использует Domain. Если реальному продукту нужен общий cookie на нескольких поддоменах, этот выбор уже не подходит и требует отдельного контракта.
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 или браузер применит ответ именно так. Его задача — отделить решение сервера от доставки значения браузером.
\nRotation начинается с проверки текущей записи. Сервер находит предъявленный ID, проверяет его статус и сравнивает с current pointer. Только после этого он переводит predecessor в rotated, создаёт successor с новым поколением и обновляет pointer. Ответ выдаёт cookie с successor.
Старый ID не является неизвестным. Сервер может знать его lineage и successor, но всё равно должен отклонить его до защищённого эффекта. Внешний ответ может быть одинаковым для разных причин, однако журнал проверки должен отличать unknown, rotated и revoked. Иначе расследование не покажет, действительно ли rotation вывела старый ID из обращения.
Параллельные запросы требуют отдельной гарантии: транзакции, conditional update, compare-and-swap или эквивалентного механизма хранилища. Нужен один исход — один запрос создаёт successor, другой получает stale/rejected. Синхронный fixture проверяет порядок вызовов в памяти и не моделирует гонку, распределённый cache, задержку базы или порядок HTTP-ответов.
\nПоложительный сценарий показывает, что новый ID работает. Он не показывает, что старый больше не работает. Поэтому после rotation предъявите predecessor и проверьте reject до protected effect. Затем предъявите successor и проверьте обычный доступ. После logout текущего ID повторите запрос и ожидайте reject.
\nОтдельно проверьте stale logout. При политике logout-current logout с predecessor не должен отзывать successor. Это не универсальная истина для всех продуктов. Если бизнесу нужен logout всей lineage или всех устройств, назовите scope явно, найдите все записи и проверьте другой инвариант. Нельзя получить такую семантику случайно из обработчика, который просто принимает любой известный ID.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Старый ID проходит после renewal | Handler не проверяет 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 видна другая cookie | Issue и clear расходятся по Path или Domain | Сравнить атрибуты Set-Cookie без значения | Повторить name, Path и Domain при наличии |
| Сбой только в одном браузере | Отличается cookie policy или порядок ответов | Повторить разрешённый сценарий на конкретной версии и собрать метаданные | Не менять server contract до подтверждения различия |
Ниже — маленькая модель без зависимостей. Она проверяет инвариант: после rotation stale logout не отзывает successor, а logout current отзывает его. Команду можно запустить в Bash или Zsh на Node.js 18 и новее.
\nnode --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 сможет изменить новую сессию.
Fixture намеренно не является HTTP-тестом. Он не генерирует криптографически случайный ID, не проверяет TLS, реальный Set-Cookie, браузерный cookie jar, CSRF, reauthentication, несколько устройств, задержку базы, распределённую блокировку и порядок сетевых ответов. Для настоящего сервиса этот тест нужно дополнить интеграционными запросами и конкурентным тестом хранилища.
\nCookie flags не заменяют server-side validation. Secure ограничивает канал доставки, HttpOnly ограничивает доступ через browser API, а SameSite ограничивает часть cross-site отправок. Ни один из них не проверяет статус записи, право на действие или успешность logout. Max-Age и Expires задают срок хранения у user agent, но не должны быть единственным серверным timeout.
Эта модель не выбирает 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__Host-. Это рабочий draft, а не утверждение, что он заменяет RFC 6265.