Files
progcode/editorial/agent-rewrites/175.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

2 lines
21 KiB
JSON
Raw 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 старая вкладка может отправить прежний ID, а logout — изменить только интерфейс. Разбираем границу между cookie и серверной записью, stale-события и проверяемый маршрут без выдуманного browser trace.","contentHtml":"<p>Симптом обычно выглядит безобидно: в одной вкладке нажали logout, а другая ещё открывает защищённый экран. Другой вариант — после renewal запрос из старой вкладки получает ошибку, а поздний logout неожиданно закрывает уже новую сессию. По одному экрану нельзя понять, какой ID пришёл на сервер, какой ID считался текущим и какой scope имел logout.</p>\n<p>Цена ошибки — не только плохой UX. Если сервер принимает старый ID после rotation, украденный или повторно отправленный идентификатор остаётся рабочим. Если logout по старому ID отзывает successor, пользователь теряет новую сессию после обычной сетевой задержки. Если система очищает только cookie, интерфейс говорит «вы вышли», но следующий запрос может пройти.</p>\n<p>Тезис статьи простой: cookie переносит идентификатор, но не принимает решение о доступе. Серверная запись хранит статус ID, его поколение и связь с lineage. Перед защищённым действием сервер принимает только current active ID. Rotation заменяет current ID. Logout отзывает запись в согласованном scope и отдельно просит браузер очистить cookie. Эти события нельзя свести к одной строке или одному флагу.</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: что он доказывает</h2>\n<p>Минимальный fixture создаёт <code>fixture-s-1</code>, выполняет rotation и получает <code>fixture-s-2</code>. Повторная rotation со старым ID возвращает reject. Stale logout со старым ID также возвращает reject и оставляет successor active. Logout с текущим ID переводит successor в revoked и удаляет current pointer.</p>\n<pre><code>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}</code></pre>\n<p>Пример ограничен памятью процесса. Он не проверяет HTTP endpoint, реальный <code>Set-Cookie</code>, браузерное хранилище, TLS, random ID, серверные часы, CSRF, reauthentication, несколько устройств, распределённую блокировку или конкурентные запросы. Команда <code>node web/scripts/upgrade-2023-02.mjs --verify-fixture</code> подтверждает assertions учебной модели, а не состояние неизвестной production-системы.</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> — официальный RFC о передаче Cookie/Set-Cookie, scope, Secure и HttpOnly; он не задаёт прикладной срок валидности серверной сессии.</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: Cookies</a> — датированный Internet-Draft ноября 2022 года для проверки SameSite и префикса <code>__Host-</code>; это work in progress, а не финальный RFC.</li><li><a href=\"https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-63b.pdf\" target=\"_blank\" rel=\"noopener noreferrer\">NIST SP 800-63B: Digital Identity Guidelines</a> — официальный документ NIST о lifecycle authentication; он задаёт контекст рекомендаций и не доказывает конфигурацию конкретного продукта.</li></ul>"}