Files
progcode/editorial/agent-rewrites/176.json
T

8 lines
26 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": 176,
"slug": "editorial-2023-02-mechanism-sessions-auth",
"title": "Сессия после rotation и logout: кто решает, действителен ли запрос",
"excerpt": "Cookie доставляет идентификатор, но не принимает решение о доступе. Разбираем серверную запись сессии, смену current ID, logout и отрицательные проверки со старым идентификатором.",
"contentHtml": "<p>Пользователь нажимает «Сохранить» после logout в другой вкладке. Интерфейс показывает успешный ответ, хотя сервер уже должен был закрыть сессию. В другом варианте после rotation старый запрос получает доступ: обработчик проверяет только наличие cookie и сразу вызывает изменение данных. Цена ошибки измеряется не внешним видом страницы, а записью, которая прошла после отзыва права, или потерей новой сессии из-за позднего ответа старой вкладки.</p>\n<p>Разберём один вопрос: какие проверки должны пройти до защищённого действия. Cookie — это транспорт непрозрачного идентификатора. Серверная запись — источник состояния. Она хранит статус, срок действия и принадлежность к текущей цепочке. В этой статье выбрана строгая политика: после rotation старый ID сразу отвергается, а logout-current отзывает только предъявленную текущую запись. Другие политики допустимы, но их границы надо назвать в контракте.</p>\n<figure><img src=\"/assets/editorial/2023/sessions-auth-2023-cookie-contract.svg\" alt=\"Схема пути от cookie __Host-session через серверную запись к решению принять или отклонить защищённое действие\" loading=\"lazy\" /><figcaption>Cookie сообщает серверу, какой идентификатор пришёл. Решение зависит от status, текущей lineage, срока и отдельной проверки authorization. Схема учебная: она не заменяет тест браузера, CSRF-контракт или проверку атомарности хранилища.</figcaption></figure>\n<h2>Cookie доставляет ID, а не доверие</h2>\n<p>HTTP-сервер отправляет cookie через <code>Set-Cookie</code>, а браузер возвращает подходящие значения в заголовке <code>Cookie</code>. Так описывает обмен RFC 6265. Сервер может использовать значение как ключ к состоянию, но сам факт доставки не подтверждает, что ключ существует, не отозван и относится к последнему входу.</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 прислал user agent?</td><td>Имя, scope и значение заголовка</td><td>Что запись активна</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>identity, ресурс и операция</td><td>Что cookie безопасно доставлена</td></tr></tbody></table>\n<p>Если cookie не пришла, это проблема браузерного scope или транспорта. Если ID пришёл, но запись revoked, это ожидаемый отказ сессии. Если запись active, но роль не разрешает удаление, отказ должен прийти из authorization. Повторная отправка запроса и увеличение TTL не исправляют ни одну из двух последних ошибок.</p>\n<h2>Какие состояния нужны серверной записи</h2>\n<p>Статус записи должен описывать решение, которое сервер принимает на входе защищённого handler. Для минимальной модели достаточно четырёх состояний: <code>active</code>, <code>rotated</code>, <code>revoked</code> и <code>expired</code>. У записи также есть <code>lineageId</code>, <code>generation</code>, время истечения и ссылка на текущий ID. Сам ID следует генерировать криптографически случайным и не включать в логи целиком.</p>\n<table><caption>Состояние записи и ответ защищённого endpoint</caption><thead><tr><th scope=\"col\">Состояние</th><th scope=\"col\">Условие</th><th scope=\"col\">Ответ</th><th scope=\"col\">Побочный эффект</th></tr></thead><tbody><tr><td>active и current</td><td>Срок сервера не истёк, поколение совпало</td><td>Продолжить к authorization</td><td>Только разрешённое действие</td></tr><tr><td>rotated</td><td>Есть successor, но ID больше не current</td><td>401 с машинной причиной stale-session</td><td>Не выдавать новый successor</td></tr><tr><td>revoked</td><td>Logout или административный отзыв</td><td>401 с причиной revoked-session</td><td>Не менять ресурс</td></tr><tr><td>expired</td><td>Истёк серверный срок</td><td>401 с причиной expired-session</td><td>Удалить запись по политике хранения</td></tr></tbody></table>\n<p>Причины отказа полезны для метрик, но не должны раскрывать секрет или лишние сведения анонимному клиенту. Внешний ответ может быть единым <code>401</code>, а точную причину можно оставить в защищённом журнале с correlation ID. Код <code>403</code> оставляют для случая, когда субъект установлен, но authorization запретила действие.</p>\n<h2>Флаги cookie ограничивают доставку</h2>\n<p><code>Secure</code> просит user agent отправлять cookie только по защищённому соединению, но не отзывает серверную запись и не превращает значение в доказательство подлинности. <code>HttpOnly</code> убирает cookie из обычного JavaScript API; это снижает риск чтения значения клиентским кодом, но не лечит XSS и не останавливает браузер от автоматической отправки cookie.</p>\n<p><code>SameSite=Lax</code> ограничивает часть cross-site запросов и обычно допускает верхнеуровневую навигацию безопасным методом. Это слой defense in depth, а не общий CSRF-контракт: GET, который меняет состояние, клиентский CSRF и неконтролируемые поддомены остаются проблемами. Для mutation endpoint задайте CSRF-токен или проверку <code>Origin</code>/<code>Referer</code> по требованиям приложения.</p>\n<p>Пара <code>Max-Age</code>/<code>Expires</code> задаёт срок хранения в user agent. Серверный <code>expiresAt</code> — отдельные часы. Браузер может удалить cookie раньше, а сервер обязан отвергнуть запись после своего срока, даже если cookie всё ещё пришла.</p>\n<p>Префикс <code>__Host-</code> ограничивает область cookie: нужны <code>Secure</code>, явный <code>Path=/</code> и отсутствие <code>Domain</code>. Это привязывает cookie к конкретному host, но не является проверкой роли и не защищает от ошибки в серверном handler. Если продукт работает на нескольких поддоменах или в iframe, это решение может быть неприменимо: scope, <code>SameSite=None</code>, CSRF и доверие к соседним host надо проектировать отдельно.</p>\n<pre><code>Set-Cookie: __Host-session=&lt;opaque-id&gt;; Path=/; Secure; HttpOnly; SameSite=Lax&#10;Cache-Control: no-store</code></pre>\n<p>Строка выше — контракт заголовка, а не готовое значение. Не подставляйте в cookie email, роль или JSON профиля. Приложение само выбирает срок, способ хранения и формат непрозрачного ID.</p>\n<h2>Rotation должна иметь один победивший переход</h2>\n<p>Rotation нужна, когда меняется уровень доверия: например, анонимная сессия становится аутентифицированной или меняются привилегии. OWASP рекомендует регенерировать идентификатор после изменения уровня привилегий и считать действующим только current ID. В выбранной модели старая запись получает <code>rotated</code>, создаётся successor с увеличенным поколением, а указатель lineage атомарно переключается на него.</p>\n<pre><code>const presented = request.cookies['__Host-session'];&#10;const current = await sessions.findById(presented);&#10;&#10;if (!current || current.status !== 'active'&#10; || current.id !== current.lineageCurrentId&#10; || current.expiresAt &lt;= now()) {&#10; return reject401('invalid-session');&#10;}&#10;&#10;const next = await sessions.rotateIfCurrent({&#10; oldId: current.id,&#10; lineageId: current.lineageId,&#10; expectedGeneration: current.generation,&#10;});&#10;&#10;if (!next) return reject401('stale-session');&#10;response.setHeader('Set-Cookie', serializeSessionCookie(next.id));</code></pre>\n<p>Это псевдокод: функции хранилища и сериализации здесь не определены. Ключевое место — <code>rotateIfCurrent</code>. Оно должно в одной транзакции или условном обновлении проверить ожидаемое поколение, пометить старую запись и создать ровно одного successor. Проверка в приложении, отдельная запись и последующий update без условия оставляют гонку.</p>\n<p>Два параллельных запроса могут прочитать один active ID. Если оба безусловно создают successor, браузер получит два ответа <code>Set-Cookie</code>, а последним станет случайный. Тест отправляет два запроса одного поколения и проверяет, что победил один, а проигравший получил <code>stale-session</code> или иной заранее оговорённый безопасный результат.</p>\n<h2>Logout должен назвать область отзыва</h2>\n<p>Logout-current отзывает предъявленную текущую запись. Logout-lineage отзывает все записи одного входа, включая successor. Logout-all отзывает все записи identity на устройствах. Это три разных операции и три разных ожидания пользователя. Старая вкладка не должна выключать новый вход, если endpoint заявляет logout-current.</p>\n<table><caption>Выбор scope для logout</caption><thead><tr><th scope=\"col\">Операция</th><th scope=\"col\">Что отзывает</th><th scope=\"col\">Когда применять</th><th scope=\"col\">Проверка</th></tr></thead><tbody><tr><td>current</td><td>Текущий ID</td><td>Обычная кнопка выхода из этой вкладки</td><td>Новое поколение остаётся active</td></tr><tr><td>lineage</td><td>Цепочку одного входа</td><td>Подозрение на кражу этого входа</td><td>Старый и новый ID получают reject</td></tr><tr><td>all</td><td>Все записи identity</td><td>Смена пароля или аварийный отзыв</td><td>Другие устройства теряют доступ</td></tr></tbody></table>\n<p>После серверного отзыва ответ может очистить cookie. Чтобы браузер удалил именно созданную cookie, имя, <code>Path</code> и <code>Domain</code> должны совпадать с исходными атрибутами; для <code>__Host-</code> не добавляйте <code>Domain</code>. Очистка улучшает UX, но logout считается выполненным только после смены серверного статуса.</p>\n<pre><code>const presented = request.cookies['__Host-session'];&#10;const result = await sessions.revokeCurrentIfCurrent({&#10; presentedId: presented,&#10;});&#10;&#10;if (result.kind === 'stale') return clearHostCookie(response, 401);&#10;return clearHostCookie(response, 204);</code></pre>\n<p>Вызов с rotated ID в этой политике не отзывает successor и не создаёт новую запись. Если бизнесу нужен logout-lineage, это должен быть отдельный endpoint или явный параметр с отдельной авторизацией.</p>\n<h2>Воспроизводимый интеграционный сценарий</h2>\n<p>Ниже приведён контрактный сценарий для тестового окружения. Переменная <code>BASE_URL</code> должна указывать на приложение, где реализованы <code>/session/start</code>, <code>/session/rotate</code>, <code>/protected-resource</code> и <code>/session/logout</code>. Названия учебные; они не являются стандартными маршрутами.</p>\n<pre><code>export BASE_URL='https://test.example.invalid'&#10;# Сначала создаём тестовую сессию и сохраняем cookie jar.&#10;curl -i -c cookie-jar.txt \"$BASE_URL/session/start\"&#10;export OLD_ID='opaque-id-from-session-start'&#10;# Current ID проходит до authorization.&#10;curl -i -b cookie-jar.txt \"$BASE_URL/protected-resource\"&#10;# Rotation должна обновить тот же jar.&#10;curl -i -X POST -b cookie-jar.txt -c cookie-jar.txt -H \"Cookie: __Host-session=$OLD_ID\" -H 'Origin: https://test.example.invalid' \"$BASE_URL/session/rotate\"&#10;# Старый ID: ожидаем 401, ресурс не изменился.&#10;curl -i -X POST -H \"Cookie: __Host-session=$OLD_ID\" -H 'Content-Type: application/json' -d '{\"value\":\"must-not-be-written\"}' \"$BASE_URL/protected-resource\"&#10;# Logout-current с новым ID и повторная проверка.&#10;curl -i -X POST -b cookie-jar.txt -c cookie-jar.txt \"$BASE_URL/session/logout\"&#10;curl -i -b cookie-jar.txt \"$BASE_URL/protected-resource\"</code></pre>\n<p>Первые два запроса фиксируют happy path, третий — обязательный отрицательный путь: ожидание <code>401</code>, ресурс не изменился. Проверяйте также единственный successor и атрибуты <code>Set-Cookie</code>. Для реального запуска сохраните cookie jar только во временном каталоге CI. Значение <code>test.example.invalid</code> намеренно не является рабочим доменом: его заменяет владелец стенда.</p>\n<h2>Диагностика по симптому</h2>\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 есть, ответ 401</td><td>record revoked, rotated или expired</td><td>Сопоставить хеш ID, status, generation и срок</td><td>Оставить отказ и корректно очистить cookie</td></tr><tr><td>Старый запрос изменил данные</td><td>Проверено только наличие ID</td><td>Повторить запрос после rotation</td><td>Проверять record до вызова domain service</td></tr><tr><td>Старая вкладка выключила новую</td><td>Logout отозвал lineage вместо current</td><td>Сравнить scope и lineage в событии</td><td>Разделить операции отзыва</td></tr><tr><td>После двух rotation активны два ID</td><td>Нет условного перехода поколения</td><td>Параллельно отправить запросы одного generation</td><td>Добавить транзакцию или compare-and-swap</td></tr><tr><td>Cross-site mutation прошла</td><td>SameSite приняли за CSRF-защиту</td><td>Проверить Origin, метод и CSRF-токен</td><td>Закрыть mutation отдельным серверным контролем</td></tr></tbody></table>\n<h2>Что логировать без утечки секрета</h2>\n<p>Для расследования нужны не значения cookie, а связи между событиями. Логируйте correlation ID, хешированный или усечённый идентификатор, lineage ID, старое и новое поколение, результат перехода и машинную причину отказа. Не пишите в лог полный session ID, заголовок <code>Cookie</code> или содержимое профиля.</p>\n<p>Минимальная последовательность событий выглядит так: <code>session.presented</code> → <code>session.lookup</code> → <code>session.transition</code> → <code>authorization.decision</code> → <code>protected.effect</code>. Наличие последнего события при <code>transition=stale</code> — повод искать обход проверки или неправильный порядок вызовов. Лог должен позволять связать два параллельных запроса, но не давать материал для повторного входа.</p>\n<h2>Пределы применимости модели</h2>\n<p>Строгий reject старого ID подходит для обычной веб-сессии, если параллельные запросы не требуют grace period. Поток с длинной загрузкой, несколькими устройствами, offline-клиентом или несколькими BFF может потребовать иной политики. Тогда задайте срок и число повторных использований явно, привяжите их к операции и всё равно запрещайте старому ID выполнять неожиданные побочные эффекты.</p>\n<p>Эта статья не задаёт алгоритм CSRF, CORS, reauthentication, распределённых блокировок, clock skew, хранения в конкретной БД или поведение reverse proxy. Browser test не доказывает атомарность базы; unit test хранилища не доказывает, что браузер применил нужные <code>Path</code>, <code>Domain</code> и <code>SameSite</code>. Эти свойства проверяются отдельными тестами на том стеке, который вы выпускаете.</p>\n<p>NIST описывает жизненный цикл сессии и повторную аутентификацию, но не выбирает за проект схему таблиц. RFC описывает cookie-протокол, но не знает, какое действие разрешено субъекту. OWASP формулирует прикладные рекомендации, но их надо сопоставить с вашими браузерами, доменами и threat model. Официальная ссылка подтверждает факт или рекомендацию, а не готовность конкретного сервиса.</p>\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 до authorization и убедитесь, что отказ не вызывает domain effect.</li><li>Проверьте rotation двумя параллельными запросами одного поколения: successor должен быть один.</li><li>Проверьте старый ID после rotation: он получает reject и не меняет ресурс.</li><li>Проверьте выбранный logout scope; для logout-current старый logout не должен отзывать новую сессию.</li><li>Проверьте фактический <code>Set-Cookie</code> и очистку в браузере или интеграционном стенде.</li><li>Проверьте mutation с чужим <code>Origin</code> и без CSRF-доказательства.</li><li>Сопоставьте события по correlation ID и убедитесь, что секреты не попадают в логи.</li></ol>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://datatracker.ietf.org/doc/html/rfc6265\" target=\"_blank\" rel=\"noopener noreferrer\">IETF RFC 6265: HTTP State Management Mechanism</a> — формат <code>Cookie</code>/<code>Set-Cookie</code>, область действия и удаление 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: Cookies</a> — <code>SameSite</code> и префикс <code>__Host-</code>. Это Internet-Draft, поэтому сверяйте поддерживаемые браузеры и не выдавайте draft за готовый контракт приложения.</li><li><a href=\"https://pages.nist.gov/800-63-3/sp800-63b.html\" target=\"_blank\" rel=\"noopener noreferrer\">NIST SP 800-63B: Authentication and Lifecycle Management</a> — требования к секрету сессии, завершению сессии и повторной аутентификации.</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> — регенерация ID при изменении привилегий и обработка прежних ID.</li><li><a href=\"https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html\" target=\"_blank\" rel=\"noopener noreferrer\">OWASP Cross-Site Request Forgery Prevention Cheat Sheet</a> — границы <code>SameSite</code> и серверные способы защиты mutation endpoint.</li></ul>"
}