{ "index": 176, "slug": "editorial-2023-02-mechanism-sessions-auth", "title": "Сессия после rotation и logout: кто решает, действителен ли запрос", "excerpt": "Cookie переносит идентификатор, но не принимает решение о доступе. Разбираем серверную запись сессии, смену current ID, logout и проверяемый отрицательный путь со старым идентификатором.", "contentHtml": "

Пользователь нажимает «Сохранить» после logout в другой вкладке. Интерфейс показывает успешный ответ, хотя сервер уже должен был закрыть сессию. В другом варианте после rotation старый запрос получает новый доступ, потому что обработчик проверяет только наличие cookie. Цена ошибки — изменение данных после отзыва доступа, потеря новой сессии поздней операцией со старым ID и расследование, в котором нельзя назвать источник истины.

\n

Тезис статьи прост: cookie доставляет непрозрачный ID, а серверная запись принимает решение. Сервер хранит статус записи, связь между версиями сессии и current ID. Rotation заменяет current ID и делает старый ID недействительным. Logout отзывает запись и очищает cookie с тем же scope. Ни один флаг cookie не заменяет эти проверки.

\n
\"Контракт
Учебная схема разделяет доставку cookie и решение сервера. Она не показывает настоящий браузерный trace, reverse proxy или достаточность защиты от CSRF.
\n

Четыре факта, которые нельзя склеивать

\n

Аутентификация отвечает на вопрос «кто прошёл вход?». В этой модели она не представлена. Сервис может получать identity из другого механизма, но затем всё равно проверяет сессию.

\n

Cookie delivery отвечает на другой вопрос: какой ID браузер приложил к запросу. Имя, домен, путь, Secure и SameSite влияют на доставку. Наличие cookie не доказывает, что запись существует, не отозвана и относится к текущей версии.

\n

Server record хранит status, срок и поколение. Обработчик находит запись, проверяет active status, expiry и current ID, а потом передаёт контекст в authorization. Authorization отдельно решает, может ли identity выполнить конкретное действие.

\n
Слои сессии и границы решения
СлойВопросПроверкаЧего он не доказывает
Cookie deliveryКакой ID пришёл?Заголовок и scopeЧто ID действителен
Server recordПринимать ли ID?status, expiry, currentПраво на действие
LineageКакой ID заменил старый?generation или successorАтомарность двух запросов
AuthorizationМожно ли выполнить операцию?роль, ресурс, действиеБезопасность cookie
\n

Границы флагов cookie

\n

Secure ограничивает отправку по защищённому каналу. Он не шифрует запись сессии и не отзывает её. HttpOnly скрывает значение от обычного JavaScript API. Он уменьшает риск кражи через клиентский код, но не устраняет XSS и не запрещает браузеру приложить cookie.

\n

SameSite=Lax ограничивает часть cross-site отправок. Это слой защиты, а не замена CSRF-проверке mutation endpoint. Сценарии с embed, federation и несколькими доменами требуют отдельного решения.

\n

Max-Age и Expires задают срок хранения в user agent. Браузер может удалить cookie раньше. Сервер не должен принимать ID только потому, что cookie пришла, и не должен считать удаление cookie доказательством logout. Browser lifetime и server expiry — разные часы.

\n

Префикс __Host- подходит для cookie одного host: нужны Secure, Path=/ и отсутствие Domain. Это не граница авторизации. Административный маршрут всё равно проверяет права на сервере. Для нескольких поддоменов нужен другой scope и явное описание его владельца.

\n

Rotation заменяет current ID

\n

Rotation меняет идентификатор, который сервер считает текущим. Операция переводит старую запись в rotated, создаёт successor и передвигает указатель current. Старый ID не получает новый TTL. Он возвращает отказ вроде stale-session.

\n
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));
\n

Это учебный фрагмент. Он показывает порядок проверки и условие compare-and-swap, но не готовый adapter для конкретной базы. В реальном хранилище нужно определить транзакцию, уникальность successor и поведение повторной доставки.

\n

Без линейзации два запроса могут прочитать один active ID и выпустить двух successor. Нельзя лечить эту гонку увеличением TTL. Нужен атомарный переход от ожидаемого поколения.

\n

Logout отзывает серверную запись

\n

Logout-current отзывает одну текущую запись. Logout-lineage отзывает цепочку одного входа. Logout-all отзывает все записи identity. Это разные операции. Endpoint должен назвать scope. Иначе поздний logout старой вкладки выключит новую сессию или оставит действующий successor.

\n

После отзыва сервер очищает cookie с тем же именем, доменом и путём. Это улучшает UX и уменьшает повторные запросы. Источником истины остаётся status серверной записи.

\n
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();
\n

Старый ID после rotation не должен отзывать successor, если выбран logout-current. Это отрицательный путь. Happy path с текущим ID его не проверяет.

\n

Симптом → причина → проверка → действие

\n
Диагностика рассинхронизации cookie и серверной записи
СимптомПричинаПроверкаДействие
Cookie есть, но ответ 401Запись revoked, rotated или expiredСопоставить ID, status и currentВернуть единый reject и очистить cookie
Старый запрос изменил данныеПроверили наличие ID, но не status/currentПовторить запрос после rotationПроверять запись до эффекта
Старая вкладка выключила новуюLogout отзывает всю lineageСопоставить ID и scope в логеЯвно выбрать logout-current или logout-all
Стали действующими два IDRotation не линеаризованаОтправить два запроса одного generationДобавить транзакцию или conditional update
Прошла cross-site mutationSameSite принят за полную CSRF-защитуПроверить Origin и CSRF-контрактДобавить отдельную серверную проверку
\n

Порядок проверки

\n
  1. Назовите защищённый handler и его действие.
  2. Опишите владельца browser delivery, server record, lineage и authorization.
  3. Зафиксируйте active, rotated, revoked и expired и ответ для каждого состояния.
  4. Проверьте вход, запрос с current ID и успешную rotation.
  5. Проверьте старый ID после rotation: он получает reject и не меняет successor.
  6. Проверьте выбранный logout scope. Старый logout не меняет новую сессию при logout-current.
  7. Проверьте Set-Cookie и очистку в разрешённом интеграционном окружении. Unit fixture не доказывает поведение браузера.
  8. Сопоставьте active session с отдельной authorization-проверкой.
\n

Ограничения модели

\n

Пример не генерирует секреты, не читает cookie jar и не отправляет HTTP. Имена fixture-s-1, generation и фиксированный TTL учебные. Их нельзя копировать как production ID. Модель не покрывает распределённые блокировки, clock skew, несколько устройств, CORS, CSRF policy, reauthentication и reverse proxy.

\n

Документы IETF и NIST описывают протокол и термины, но не доказывают корректность конкретной платформы. Browser test не доказывает атомарность базы. Storage test не доказывает scope Set-Cookie. Эти границы проверяют отдельно.

\n

Проверяемый критерий готовности

\n

Механизм готов к интеграционной проверке, если для одного handler видны четыре результата: current ID проходит до authorization; rotated ID получает reject без изменения successor; выбранный logout отзывает ровно ожидаемые записи; сервер принимает решение независимо от наличия cookie. В отчёте есть correlation ID, lineage, generation и причина отказа, но нет самого секрета.

\n

Если один результат нельзя показать отдельно, контракт ещё не определён. Сначала фиксируют владельца перехода и состояние, затем выбирают хранилище и браузерный сценарий.

\n

Проверяемые источники

\n" }