8 lines
14 KiB
JSON
8 lines
14 KiB
JSON
{
|
||
"index": 176,
|
||
"slug": "editorial-2023-02-mechanism-sessions-auth",
|
||
"title": "Сессия после rotation и logout: кто решает, действителен ли запрос",
|
||
"excerpt": "Cookie переносит идентификатор, но не принимает решение о доступе. Разбираем серверную запись сессии, смену current ID, logout и проверяемый отрицательный путь со старым идентификатором.",
|
||
"contentHtml": "<p>Пользователь нажимает «Сохранить» после logout в другой вкладке. Интерфейс показывает успешный ответ, хотя сервер уже должен был закрыть сессию. В другом варианте после rotation старый запрос получает новый доступ, потому что обработчик проверяет только наличие cookie. Цена ошибки — изменение данных после отзыва доступа, потеря новой сессии поздней операцией со старым ID и расследование, в котором нельзя назвать источник истины.</p>\n<p>Тезис статьи прост: cookie доставляет непрозрачный ID, а серверная запись принимает решение. Сервер хранит статус записи, связь между версиями сессии и current ID. Rotation заменяет current ID и делает старый ID недействительным. Logout отзывает запись и очищает cookie с тем же scope. Ни один флаг cookie не заменяет эти проверки.</p>\n<figure><img src=\"/assets/editorial/2023/sessions-auth-2023-cookie-contract.svg\" alt=\"Контракт учебной cookie __Host-session и серверной проверки active status\" loading=\"lazy\" /><figcaption>Учебная схема разделяет доставку cookie и решение сервера. Она не показывает настоящий браузерный trace, reverse proxy или достаточность защиты от CSRF.</figcaption></figure>\n<h2>Четыре факта, которые нельзя склеивать</h2>\n<p>Аутентификация отвечает на вопрос «кто прошёл вход?». В этой модели она не представлена. Сервис может получать identity из другого механизма, но затем всё равно проверяет сессию.</p>\n<p>Cookie delivery отвечает на другой вопрос: какой ID браузер приложил к запросу. Имя, домен, путь, Secure и SameSite влияют на доставку. Наличие cookie не доказывает, что запись существует, не отозвана и относится к текущей версии.</p>\n<p>Server record хранит status, срок и поколение. Обработчик находит запись, проверяет active status, expiry и current ID, а потом передаёт контекст в authorization. Authorization отдельно решает, может ли identity выполнить конкретное действие.</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 delivery</td><td>Какой ID пришёл?</td><td>Заголовок и scope</td><td>Что ID действителен</td></tr><tr><td>Server record</td><td>Принимать ли ID?</td><td>status, expiry, current</td><td>Право на действие</td></tr><tr><td>Lineage</td><td>Какой ID заменил старый?</td><td>generation или successor</td><td>Атомарность двух запросов</td></tr><tr><td>Authorization</td><td>Можно ли выполнить операцию?</td><td>роль, ресурс, действие</td><td>Безопасность cookie</td></tr></tbody></table>\n<h2>Границы флагов cookie</h2>\n<p><code>Secure</code> ограничивает отправку по защищённому каналу. Он не шифрует запись сессии и не отзывает её. <code>HttpOnly</code> скрывает значение от обычного JavaScript API. Он уменьшает риск кражи через клиентский код, но не устраняет XSS и не запрещает браузеру приложить cookie.</p>\n<p><code>SameSite=Lax</code> ограничивает часть cross-site отправок. Это слой защиты, а не замена CSRF-проверке mutation endpoint. Сценарии с embed, federation и несколькими доменами требуют отдельного решения.</p>\n<p><code>Max-Age</code> и <code>Expires</code> задают срок хранения в user agent. Браузер может удалить cookie раньше. Сервер не должен принимать ID только потому, что cookie пришла, и не должен считать удаление cookie доказательством logout. Browser lifetime и server expiry — разные часы.</p>\n<p>Префикс <code>__Host-</code> подходит для cookie одного host: нужны <code>Secure</code>, <code>Path=/</code> и отсутствие <code>Domain</code>. Это не граница авторизации. Административный маршрут всё равно проверяет права на сервере. Для нескольких поддоменов нужен другой scope и явное описание его владельца.</p>\n<h2>Rotation заменяет current ID</h2>\n<p>Rotation меняет идентификатор, который сервер считает текущим. Операция переводит старую запись в <code>rotated</code>, создаёт successor и передвигает указатель current. Старый ID не получает новый TTL. Он возвращает отказ вроде <code>stale-session</code>.</p>\n<pre><code>const current = sessions.findById(request.cookies[SESSION_NAME]);\n\nif (!current || current.status !== 'active' || current.id !== lineage.currentId) {\n return response.status(401).json({ error: 'stale-session' });\n}\n\nconst next = sessions.rotateAtomically({ oldId: current.id, expectedGeneration: current.generation });\nresponse.setHeader('Set-Cookie', serializeSessionCookie(next.id));</code></pre>\n<p>Это учебный фрагмент. Он показывает порядок проверки и условие compare-and-swap, но не готовый adapter для конкретной базы. В реальном хранилище нужно определить транзакцию, уникальность successor и поведение повторной доставки.</p>\n<p>Без линейзации два запроса могут прочитать один active ID и выпустить двух successor. Нельзя лечить эту гонку увеличением TTL. Нужен атомарный переход от ожидаемого поколения.</p>\n<h2>Logout отзывает серверную запись</h2>\n<p>Logout-current отзывает одну текущую запись. Logout-lineage отзывает цепочку одного входа. Logout-all отзывает все записи identity. Это разные операции. Endpoint должен назвать scope. Иначе поздний logout старой вкладки выключит новую сессию или оставит действующий successor.</p>\n<p>После отзыва сервер очищает cookie с тем же именем, доменом и путём. Это улучшает UX и уменьшает повторные запросы. Источником истины остаётся status серверной записи.</p>\n<pre><code>const result = logoutCurrent({ presentedId: request.cookies[SESSION_NAME], lineageId: request.sessionLineage });\nif (result.kind === 'stale-session') return clearCookie(response).status(401).end();\nsessions.revoke(result.currentId);\nreturn clearCookie(response).status(204).end();</code></pre>\n<p>Старый ID после rotation не должен отзывать successor, если выбран logout-current. Это отрицательный путь. Happy path с текущим ID его не проверяет.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<table><caption>Диагностика рассинхронизации cookie и серверной записи</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 есть, но ответ 401</td><td>Запись revoked, rotated или expired</td><td>Сопоставить ID, status и current</td><td>Вернуть единый reject и очистить cookie</td></tr><tr><td>Старый запрос изменил данные</td><td>Проверили наличие ID, но не status/current</td><td>Повторить запрос после rotation</td><td>Проверять запись до эффекта</td></tr><tr><td>Старая вкладка выключила новую</td><td>Logout отзывает всю lineage</td><td>Сопоставить ID и scope в логе</td><td>Явно выбрать logout-current или logout-all</td></tr><tr><td>Стали действующими два ID</td><td>Rotation не линеаризована</td><td>Отправить два запроса одного generation</td><td>Добавить транзакцию или conditional update</td></tr><tr><td>Прошла cross-site mutation</td><td>SameSite принят за полную CSRF-защиту</td><td>Проверить Origin и CSRF-контракт</td><td>Добавить отдельную серверную проверку</td></tr></tbody></table>\n<h2>Порядок проверки</h2>\n<ol><li>Назовите защищённый handler и его действие.</li><li>Опишите владельца browser delivery, server record, lineage и authorization.</li><li>Зафиксируйте active, rotated, revoked и expired и ответ для каждого состояния.</li><li>Проверьте вход, запрос с current ID и успешную rotation.</li><li>Проверьте старый ID после rotation: он получает reject и не меняет successor.</li><li>Проверьте выбранный logout scope. Старый logout не меняет новую сессию при logout-current.</li><li>Проверьте Set-Cookie и очистку в разрешённом интеграционном окружении. Unit fixture не доказывает поведение браузера.</li><li>Сопоставьте active session с отдельной authorization-проверкой.</li></ol>\n<h2>Ограничения модели</h2>\n<p>Пример не генерирует секреты, не читает cookie jar и не отправляет HTTP. Имена <code>fixture-s-1</code>, generation и фиксированный TTL учебные. Их нельзя копировать как production ID. Модель не покрывает распределённые блокировки, clock skew, несколько устройств, CORS, CSRF policy, reauthentication и reverse proxy.</p>\n<p>Документы IETF и NIST описывают протокол и термины, но не доказывают корректность конкретной платформы. Browser test не доказывает атомарность базы. Storage test не доказывает scope Set-Cookie. Эти границы проверяют отдельно.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Механизм готов к интеграционной проверке, если для одного handler видны четыре результата: current ID проходит до authorization; rotated ID получает reject без изменения successor; выбранный logout отзывает ровно ожидаемые записи; сервер принимает решение независимо от наличия cookie. В отчёте есть correlation ID, lineage, generation и причина отказа, но нет самого секрета.</p>\n<p>Если один результат нельзя показать отдельно, контракт ещё не определён. Сначала фиксируют владельца перехода и состояние, затем выбирают хранилище и браузерный сценарий.</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></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></li><li><a href=\"https://pages.nist.gov/800-63-3/sp800-63b.html\" target=\"_blank\" rel=\"noopener noreferrer\">NIST SP 800-63B: Digital Identity Guidelines, Authentication and Lifecycle Management</a></li></ul>"
|
||
}
|