DarkRiDDeR12 мин

Одна сессия от входа до logout: короткий контракт cookie и сервера

БезопасностьАутентификация

В обработчике входа часто видны четыре независимые строчки: выдать cookie, поставить Secure, обновить ID и сделать logout. Проблема начинается, когда каждая строчка живёт по своей логике. Старый идентификатор продолжает проходить после rotation, cookie очищается без серверного revoke или срок в браузере принимают за срок доступа. Цена ошибки конкретна: пользователь считает сессию закрытой, а сервер ещё принимает её; либо новый вход ломается поздним ответом со старым состоянием.

Ниже — один контракт учебной сессии: браузер хранит непрозрачный ID, сервер хранит статус, и оба слоя меняются одним решением. После входа запись active; rotation выпускает successor и выводит predecessor из обращения; logout revoke-ит current ID и просит клиент удалить cookie. Это не browser trace и не запуск приложения, а in-memory fixture с явно заданными состояниями и идентификаторами.

Сначала разделите носитель и источник истины

Cookie не является сессией. Это способ вернуть серверу строку в заголовке Cookie, если браузер решил, что её scope подходит запросу. RFC 6265 описывает именно такую передачу и допускает, что user agent удалит cookie раньше срока. Поэтому Max-Age полезен как клиентский предел хранения, но не должен быть единственной проверкой timeout. В контракте active, rotated и revoked принадлежат серверной записи; cookie только выбирает запись, которую сервер ещё обязан проверить.

Это разделение меняет и формулировку logout. Удалить cookie недостаточно: другой клиент, сохранённый запрос или уже отправленный заголовок может всё ещё предъявить идентификатор. Сервер должен решить, принимает ли он его. В учебной модели после logout запись получает статус revoked и исчезает из currentByLineage. Отдельный Set-Cookie с Expires в прошлом просит браузер убрать прежнее значение. Оба действия нужны, но они доказывают разное: первое закрывает доступ в модели, второе убирает удобный носитель на клиенте.

Контракт одной сессии: кто владеет решением
ОбъектМинимальное полеКто проверяетЧто происходит при logoutЧего этого недостаточно
Cookie__Host-session=ID; Path=/; Secure; HttpOnly; SameSite=Laxuser agent решает, приложить ли его к запросуполучает Set-Cookie с Expires в прошлом той же формыдоказать, что сервер уже revoke-ил ID
Session recordid, lineage, generation, statusсервер перед обработкой защищённого действияcurrent ID становится revokedпринять решение за другой сервис без общей политики
Lineage pointerlineage → active IDоперация rotation/logoutссылка на active ID удаляетсяописать logout всех устройств
Set-Cookie при rotationsuccessor ID и та же политика flagsсервер формирует ответне выдаётся при stale referenceсинхронизировать вкладки или порядок сетевых ответов

Минимальный fixture: проверить переходы, не притворяясь браузером

Скрипт этой партии экспортирует createTeachingSessionState, rotateTeachingSession и logoutTeachingSession. Первый вызов создаёт fixture-s-1 с состоянием active. Rotation принимает только текущий ID для линии fixture-lineage-a, помечает его rotated и создаёт fixture-s-2. Поздняя операция со старым ID возвращает stale-session и получает тот же объект state. Это намеренно узкая проверка: нет HTTP, хранилища браузера, случайных чисел, конкурентных запросов, clock skew, пользователя или настоящего сервера.

import {
  createTeachingSessionState,
  rotateTeachingSession,
  logoutTeachingSession,
} from './upgrade-2023-02.mjs';

const initial = createTeachingSessionState();
const rotated = rotateTeachingSession(initial, 'fixture-s-1');
const staleLogout = logoutTeachingSession(rotated.state, 'fixture-s-1');
const currentLogout = logoutTeachingSession(rotated.state, 'fixture-s-2');

console.log(rotated.accepted);       // true
console.log(staleLogout.reason);     // stale-session
console.log(currentLogout.clearCookie);
// __Host-session=; Path=/; Secure; HttpOnly; SameSite=Lax; Expires=Thu, 01 Jan 1970 00:00:00 GMT

Здесь stale logout для fixture-s-1 не revoke-ит fixture-s-2: это политика logout только предъявленной current-сессии. Другой продукт может выбрать logout всей lineage или всех устройств, но это должна быть отдельная операция с собственным scope и проверкой. Иначе поздний запрос молча закроет другую активную сессию.

Rotation — замена права, а не продление строки

Rotation начинается с проверки текущей записи, а не с Set-Cookie. Предшественник остаётся rotated для диагностики, но уже не current; successor получает новый ID, generation и ту же lineage. Так сервер различает unknown и уже устаревший ID. В production это изменение требует реальной гарантии хранилища; синхронный fixture не называет себя atomic.

Для двух одновременных запросов заранее определите один исход: один создаёт successor, другой получает stale/rejected. Без этого появятся два successor. Fixture не моделирует гонку, но фиксирует инвариант для реализации: после rotation один current ID, а повторный старый ID не создаёт fixture-s-3.

Жизненный цикл учебной сессии: после входа fixture-s-1 active, rotation переводит его в rotated и выпускает fixture-s-2 active, stale операции со старым ID отклоняются, logout текущего ID делает запись revoked и очищает cookie.
Схема показывает контракт одного идентификатора и одной lineage в памяти. Это не trace браузера, не последовательность настоящих HTTP-ответов и не модель нескольких устройств.

Cookie flags описывают область доставки, не авторизацию

Для учебного ID выбран __Host-session. В draft-ietf-httpbis-rfc6265bis-11 префикс __Host- требует Secure, Path=/ и отсутствия Domain, то есть фиксирует host-only область. Он не обещает пользователя, права, logout или серверный срок. В феврале 2023 это был Internet-Draft ноября 2022, не финальный RFC. __Host- не годится, если cookie обязана жить на нескольких поддоменах.

Secure ограничивает канал доставки, HttpOnly — доступ из browser API, SameSite=Lax — часть cross-site доставок. Ни один не заменяет серверную проверку ID, прав и запроса. Path задаёт маршрут, но не является security boundary. Поэтому каждый flag записывают с его узкой причиной, а не как список безопасности.

Logout: две синхронные обязанности

Logout имеет серверную и клиентскую ветви. Сервер revoke-ит только current active ID и снимает pointer; клиент получает то же имя и Path=/ с пустым значением и Expires в прошлом. RFC 6265 связывает замену/удаление с name, Domain и Path: если issue содержал Domain, а clear нет, браузер может оставить другую cookie. Учебный контракт исключает Domain с самого начала.

Удаление cookie не доказывает, что logout дошёл до сервера: ответ мог не примениться, а другой запрос уже нести ID. Критерий доступа — server reject после revoke. Очищение остаётся нужно для UX. CSRF-защита logout, если она есть, — отдельный контракт; SameSite не отменяет его.

Маршрут: симптом → причина → проверка → действие

  1. Симптом. В выписке change найдите одно из трёх наблюдений: старый ID принимается после renewal, logout меняет только интерфейс или cookie живёт дольше серверной записи. Не называйте это инцидентом, пока нет проверяемых данных.
  2. Причина. Для одного ID нарисуйте record, lineage pointer, Set-Cookie при issue/rotation и Set-Cookie при clear. Если один из объектов не имеет владельца, контракт уже разорван.
  3. Проверка fixture. Запустите node web/scripts/upgrade-2023-02.mjs --verify-fixture. Он проверяет только 15 предзаданных assertions учебной модели, включая stale rotation и stale logout; он не проверяет браузер, endpoint или конфигурацию.
  4. Проверка реализации. Отдельно выберите разрешённую среду и проверьте, что сервер отклоняет rotated/revoked ID, а clear cookie повторяет name, Domain при наличии и Path исходной cookie. Не переносите в отчёт реальные session ID.
  5. Действие. Сделайте server-side status обязательным до защищённой операции; rotation обновляет current pointer, logout revoke-ит current pointer. Добавьте явное отдельное действие, если бизнесу нужен logout всех сессий.
  6. Откат. Не используйте fixture как план отката. Для production заранее опишите совместимость старых ID, миграцию хранилища, ожидаемый reject и способ вернуть change без повторной легитимации stale идентификатора.

Границы и следующий проверяемый шаг

Модель не генерирует секреты: fixture-s-1 и fixture-s-2 нельзя копировать как production ID. В ней нет шифрования, CSRF, пользователя, reauthentication, browser Network/Storage и реальной гонки. NIST SP 800-63B помогает отделить session secret, минимальный cookie scope и server timeout; это контекст документа NIST, а не готовая политика продукта.

Следующий шаг — зафиксировать для команды status, lineage, reject rotated/revoked ID и отдельный план logout всех устройств. Затем провести разрешённую интеграционную проверку вне fixture. Если нельзя назвать current ID после rotation, cookie-конфигурации ещё рано доверять.

Историческая граница февраля 2023

К февралю 2023 уже существовали RFC 6265 от апреля 2011, NIST SP 800-63B от июня 2017 с обновлениями до 2 марта 2020 и draft-ietf-httpbis-rfc6265bis-11 от 7 ноября 2022. Последний в статье назван work in progress, а не стандартом. Источники объясняют свойства cookie и сессий, но не подтверждают состояние конкретного браузера, балансировщика, базы данных или auth-платформы.

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