Files

8 lines
22 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 175,
"slug": "editorial-2023-02-field-sessions-auth",
"title": "Старый session ID после rotation: как проверить logout и не закрыть новую сессию",
"excerpt": "После renewal старая вкладка не всегда означает старую cookie: запрос мог уйти раньше, прийти позже или иметь другой scope. Разбираем server-side проверку, stale-события, logout и воспроизводимый fixture.",
"contentHtml": "<p>Симптом знакомый: после renewal одна вкладка получает новый идентификатор сессии, а запоздалый logout или запрос из другой вкладки внезапно меняет состояние не той сессии. На экране это выглядит как случайный выход, повторный вход или успешная операция после logout. Но экран не показывает, какой ID уже ушёл в сеть, какой ID сервер считает текущим и к какой области действия относится logout.</p>\n<p>Сначала уточним важную деталь. Вкладки одного origin обычно используют общий cookie jar. Поэтому сама по себе «старая вкладка» не доказывает, что у неё остался старый cookie. Старый ID может быть внутри уже отправленного запроса, в явно заданном заголовке другого клиента, в cookie с другим Path или Domain либо в ответе, который пришёл позже. Это разные причины, но для сервера требование одно: predecessor после rotation не должен менять защищённое состояние.</p>\n<p>Цена ошибки двойная. Если старый ID всё ещё принимается, rotation почти не уменьшает окно повтора украденного или задержанного идентификатора. Если stale logout отзывает successor, пользователь теряет новую сессию из-за сетевой задержки. Правильная диагностика разделяет доставку cookie, состояние записи на сервере, указатель current и scope операции logout.</p>\n<h2>Сначала разделите четыре объекта</h2>\n<p>Cookie — это транспорт. Браузер решает, приложить ли её к запросу с учётом имени, host, пути, Secure и SameSite. Сервер получает строку и должен найти запись. Наличие cookie не доказывает, что запись active, что ID current или что пользователь имеет право выполнить операцию.</p>\n<p>Session record — источник решения о состоянии конкретного ID. Минимальная учебная запись содержит <code>id</code>, <code>lineage</code>, <code>generation</code> и <code>status</code>. Значение <code>active</code> означает, что ID может пройти проверку сессии. Значение <code>rotated</code> означает, что запись известна, но заменена successor. Значение <code>revoked</code> означает, что сессия отозвана. <code>unknown</code> — это отсутствие записи.</p>\n<p>Lineage связывает последовательность ID одной сессии. У неё есть один current pointer. До rotation pointer указывает на <code>fixture-s-1</code>. После успешной rotation он указывает на <code>fixture-s-2</code>. Старый ID можно хранить ограниченное время для диагностики и явного reject, но он не должен снова становиться current.</p>\n<p>Authorization остаётся отдельным слоем. Active session отвечает на вопрос «какая сессия предъявлена?». Проверка прав отвечает на вопрос «может ли она выполнить это действие?». Не выдавайте active ID больше полномочий, чем описывает политика handler.</p>\n<table><caption>Контракт одной сессии</caption><thead><tr><th scope=\"col\">Объект</th><th scope=\"col\">Что он решает</th><th scope=\"col\">Минимальная проверка</th><th scope=\"col\">Чего он не доказывает</th></tr></thead><tbody><tr><td>Cookie</td><td>Какой ID браузер отправит</td><td>name, host, Path и атрибуты scope</td><td>Что сервер считает ID active</td></tr><tr><td>Session record</td><td>Принимать ли предъявленный ID сейчас</td><td>status и совпадение с current</td><td>Что у сессии есть нужное право</td></tr><tr><td>Lineage pointer</td><td>Какой ID является successor</td><td>Один current после rotation</td><td>Что logout означает для всех устройств</td></tr><tr><td>Authorization</td><td>Можно ли выполнить конкретное действие</td><td>Проверка права после session check</td><td>Что cookie доставлена безопасно</td></tr></tbody></table>\n<h2>Почему logout не заканчивается очисткой cookie</h2>\n<p>У logout две разные обязанности. Сервер должен изменить состояние записи и перестать принимать отозванный ID. Клиент должен получить <code>Set-Cookie</code> с тем же именем и тем же scope, но с пустым значением и сроком в прошлом. Первая ветвь закрывает доступ. Вторая убирает удобный носитель ID из браузера.</p>\n<p>Если выполнена только клиентская ветвь, сохранённый запрос, другой клиент или уже отправленный заголовок всё ещё может предъявить прежний ID. Если выполнена только серверная ветвь, доступ уже закрыт, но интерфейс может продолжать отправлять cookie до следующего ответа. Это разные симптомы и разные проверки.</p>\n<p>Scope очистки должен совпадать со scope выдачи. Имя и путь не являются единственными деталями: при использовании Domain он тоже входит в совпадение. Учебный контракт использует <code>__Host-session</code>, <code>Secure</code>, <code>HttpOnly</code>, <code>SameSite=Lax</code>, <code>Path=/</code> и не использует Domain. Если реальному продукту нужен общий cookie на нескольких поддоменах, этот выбор уже не подходит и требует отдельного контракта.</p>\n<pre><code>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</code></pre>\n<p>Это учебный фрагмент. Он не задаёт формат production cookie, не генерирует секрет и не доказывает, что конкретный framework или браузер применит ответ именно так. Его задача — отделить решение сервера от доставки значения браузером.</p>\n<h2>Rotation меняет право предъявления</h2>\n<p>Rotation начинается с проверки текущей записи. Сервер находит предъявленный ID, проверяет его статус и сравнивает с current pointer. Только после этого он переводит predecessor в <code>rotated</code>, создаёт successor с новым поколением и обновляет pointer. Ответ выдаёт cookie с successor.</p>\n<p>Старый ID не является неизвестным. Сервер может знать его lineage и successor, но всё равно должен отклонить его до защищённого эффекта. Внешний ответ может быть одинаковым для разных причин, однако журнал проверки должен отличать <code>unknown</code>, <code>rotated</code> и <code>revoked</code>. Иначе расследование не покажет, действительно ли rotation вывела старый ID из обращения.</p>\n<p>Параллельные запросы требуют отдельной гарантии: транзакции, conditional update, compare-and-swap или эквивалентного механизма хранилища. Нужен один исход — один запрос создаёт successor, другой получает stale/rejected. Синхронный fixture проверяет порядок вызовов в памяти и не моделирует гонку, распределённый cache, задержку базы или порядок HTTP-ответов.</p>\n<figure><img src=\"/assets/editorial/2023/sessions-auth-2023-stale-events.svg\" alt=\"Жизненный цикл учебной сессии: rotation заменяет fixture-s-1 на fixture-s-2, stale операции отклоняются, logout текущего ID отзывает successor и очищает cookie\" loading=\"lazy\" /><figcaption>Иллюстрация показывает правило для одной lineage и синхронных учебных вызовов. Это не browser trace, не журнал инцидента и не доказательство порядка реальных HTTP-ответов.</figcaption></figure>\n<h2>Отрицательный путь важнее зелёного login</h2>\n<p>Положительный сценарий показывает, что новый ID работает. Он не показывает, что старый больше не работает. Поэтому после rotation предъявите predecessor и проверьте reject до protected effect. Затем предъявите successor и проверьте обычный доступ. После logout текущего ID повторите запрос и ожидайте reject.</p>\n<p>Отдельно проверьте stale logout. При политике <code>logout-current</code> logout с predecessor не должен отзывать successor. Это не универсальная истина для всех продуктов. Если бизнесу нужен logout всей lineage или всех устройств, назовите scope явно, найдите все записи и проверьте другой инвариант. Нельзя получить такую семантику случайно из обработчика, который просто принимает любой известный ID.</p>\n<table><caption>Симптом → причина → проверка → действие</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Старый ID проходит после renewal</td><td>Handler не проверяет current или record не стал rotated</td><td>Сопоставить безопасный ID, status и current pointer на момент обработки</td><td>Отклонять rotated ID до эффекта и атомарно менять pointer</td></tr><tr><td>Logout меняет только экран</td><td>Очищена cookie, но сервер не revoke-нул record</td><td>Проверить audit logout и status той же записи</td><td>Revoke на сервере, затем вернуть clear cookie</td></tr><tr><td>Поздний logout закрывает новую сессию</td><td>Stale ID трактуется как current</td><td>Сравнить предъявленный ID с currentByLineage</td><td>Выбрать logout-current или явный более широкий scope</td></tr><tr><td>После clear видна другая cookie</td><td>Issue и clear расходятся по Path или Domain</td><td>Сравнить атрибуты Set-Cookie без значения</td><td>Повторить name, Path и Domain при наличии</td></tr><tr><td>Сбой только в одном браузере</td><td>Отличается cookie policy или порядок ответов</td><td>Повторить разрешённый сценарий на конкретной версии и собрать метаданные</td><td>Не менять server contract до подтверждения различия</td></tr></tbody></table>\n<h2>Воспроизводимый fixture на Node.js</h2>\n<p>Ниже — маленькая модель без зависимостей. Она проверяет инвариант: после rotation stale logout не отзывает successor, а logout current отзывает его. Команду можно запустить в Bash или Zsh на Node.js 18 и новее.</p>\n<pre><code>node --input-type=module &lt;&lt;'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</code></pre>\n<p>Ожидаемый результат содержит <code>\"staleLogout\": {\"accepted\": false, ...}</code>, затем успешный <code>currentLogout</code> и статус successor <code>revoked</code>. Если убрать сравнение <code>state.current !== presentedId</code>, fixture покажет опасное поведение: stale logout сможет изменить новую сессию.</p>\n<p>Fixture намеренно не является HTTP-тестом. Он не генерирует криптографически случайный ID, не проверяет TLS, реальный Set-Cookie, браузерный cookie jar, CSRF, reauthentication, несколько устройств, задержку базы, распределённую блокировку и порядок сетевых ответов. Для настоящего сервиса этот тест нужно дополнить интеграционными запросами и конкурентным тестом хранилища.</p>\n<h2>Порядок действий</h2>\n<ol><li><strong>Зафиксируйте симптом.</strong> Запишите внешний status, защищённый эффект, безопасную корреляцию операции и момент обработки. Не называйте историю browser trace, если его не собирали.</li><li><strong>Спрячьте секрет.</strong> Используйте surrogate ID и не помещайте bearer value в лог, таблицу или снимок.</li><li><strong>Назовите состояние.</strong> Разделите cookie delivery, server record, lineage pointer и authorization. Для каждого объекта назначьте владельца.</li><li><strong>Проверьте fixture.</strong> Выполните положительные и отрицательные переходы: rotation, stale rotation, stale logout и logout current.</li><li><strong>Проверьте реализацию.</strong> В разрешённой среде сравните status и current pointer до protected effect. Отдельно сверяйте атрибуты issue и clear cookie.</li><li><strong>Проверьте гонку.</strong> Для реального хранилища зафиксируйте гарантию, которая не допускает двух successor и не позволяет stale событию изменить новую запись.</li><li><strong>Выберите scope logout.</strong> Зафиксируйте logout-current, logout-lineage или logout-all как разные операции с разными проверками.</li><li><strong>Сделайте малый diff.</strong> Исправляйте подтверждённый слой: серверный reject, атомарность, cookie scope, audit или UX после reject.</li><li><strong>Опишите откат.</strong> Не возвращайте retired ID в active ради быстрого rollback. Подготовьте совместимую миграцию или откат экрана при сохранённом reject старого ID.</li></ol>\n<h2>Ограничения и критерий готовности</h2>\n<p>Cookie flags не заменяют server-side validation. <code>Secure</code> ограничивает канал доставки, <code>HttpOnly</code> ограничивает доступ через browser API, а <code>SameSite</code> ограничивает часть cross-site отправок. Ни один из них не проверяет статус записи, право на действие или успешность logout. <code>Max-Age</code> и <code>Expires</code> задают срок хранения у user agent, но не должны быть единственным серверным timeout.</p>\n<p>Эта модель не выбирает SQL, cache, signed token или конкретный framework. Она требует только одного current ID и явного решения для stale состояния. Если система не хранит lineage, можно выбрать другую реализацию, но тогда нужно доказать, как она отличает повторный старый ID от текущего и как предотвращает позднее изменение successor.</p>\n<p>Материал готов к применению как проверяемая схема, если команда может показать: predecessor и successor без секретов; status каждого в момент запроса; current pointer; scope logout; результат stale rotation и stale logout; совпадение name, Path и Domain у issue/clear cookie; и отдельную гарантию для конкурентной rotation. Один зелёный fixture или исчезнувшая cookie этот критерий не закрывают.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://datatracker.ietf.org/doc/html/rfc6265\" target=\"_blank\" rel=\"noopener noreferrer\">IETF RFC 6265: HTTP State Management Mechanism</a> — стандартный документ о Cookie и Set-Cookie, scope, Path, Domain, Secure, HttpOnly и удалении cookie датой в прошлом.</li><li><a href=\"https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-11\" target=\"_blank\" rel=\"noopener noreferrer\">IETF draft-ietf-httpbis-rfc6265bis-11</a> — датированный Internet-Draft, опубликованный в 2022 году; используется для определения префикса <code>__Host-</code>. Это рабочий draft, а не утверждение, что он заменяет RFC 6265.</li><li><a href=\"https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html\" target=\"_blank\" rel=\"noopener noreferrer\">OWASP Session Management Cheat Sheet</a> — официальная практическая рекомендация по renewal ID, уничтожению старых идентификаторов и logout; конкретные детали зависят от приложения.</li><li><a href=\"https://pages.nist.gov/800-63-3/sp800-63b.html\" target=\"_blank\" rel=\"noopener noreferrer\">NIST SP 800-63B, третья редакция</a> — официальный документ о reauthentication, inactivity timeout, overall timeout и завершении аутентифицированной сессии; он не является спецификацией cookie или вашей session database.</li></ul>"
}