{ "index": 176, "slug": "editorial-2023-02-mechanism-sessions-auth", "title": "Сессия после rotation и logout: кто решает, действителен ли запрос", "excerpt": "Cookie доставляет идентификатор, но не принимает решение о доступе. Разбираем серверную запись сессии, смену current ID, logout и отрицательные проверки со старым идентификатором.", "contentHtml": "
Пользователь нажимает «Сохранить» после logout в другой вкладке. Интерфейс показывает успешный ответ, хотя сервер уже должен был закрыть сессию. В другом варианте после rotation старый запрос получает доступ: обработчик проверяет только наличие cookie и сразу вызывает изменение данных. Цена ошибки измеряется не внешним видом страницы, а записью, которая прошла после отзыва права, или потерей новой сессии из-за позднего ответа старой вкладки.
\nРазберём один вопрос: какие проверки должны пройти до защищённого действия. Cookie — это транспорт непрозрачного идентификатора. Серверная запись — источник состояния. Она хранит статус, срок действия и принадлежность к текущей цепочке. В этой статье выбрана строгая политика: после rotation старый ID сразу отвергается, а logout-current отзывает только предъявленную текущую запись. Другие политики допустимы, но их границы надо назвать в контракте.
\nHTTP-сервер отправляет cookie через Set-Cookie, а браузер возвращает подходящие значения в заголовке Cookie. Так описывает обмен RFC 6265. Сервер может использовать значение как ключ к состоянию, но сам факт доставки не подтверждает, что ключ существует, не отозван и относится к последнему входу.
| Слой | Вопрос | Минимальная проверка | Чего она не доказывает |
|---|---|---|---|
| Cookie delivery | Какой ID прислал user agent? | Имя, scope и значение заголовка | Что запись активна |
| Server record | Можно ли принять этот ID сейчас? | status, expiry и связь с current | Право на конкретный ресурс |
| Lineage | Какой ID заменил предыдущий? | generation или successor | Что переход выполнен атомарно |
| Authorization | Можно ли выполнить действие? | identity, ресурс и операция | Что cookie безопасно доставлена |
Если cookie не пришла, это проблема браузерного scope или транспорта. Если ID пришёл, но запись revoked, это ожидаемый отказ сессии. Если запись active, но роль не разрешает удаление, отказ должен прийти из authorization. Повторная отправка запроса и увеличение TTL не исправляют ни одну из двух последних ошибок.
\nСтатус записи должен описывать решение, которое сервер принимает на входе защищённого handler. Для минимальной модели достаточно четырёх состояний: active, rotated, revoked и expired. У записи также есть lineageId, generation, время истечения и ссылка на текущий ID. Сам ID следует генерировать криптографически случайным и не включать в логи целиком.
| Состояние | Условие | Ответ | Побочный эффект |
|---|---|---|---|
| active и current | Срок сервера не истёк, поколение совпало | Продолжить к authorization | Только разрешённое действие |
| rotated | Есть successor, но ID больше не current | 401 с машинной причиной stale-session | Не выдавать новый successor |
| revoked | Logout или административный отзыв | 401 с причиной revoked-session | Не менять ресурс |
| expired | Истёк серверный срок | 401 с причиной expired-session | Удалить запись по политике хранения |
Причины отказа полезны для метрик, но не должны раскрывать секрет или лишние сведения анонимному клиенту. Внешний ответ может быть единым 401, а точную причину можно оставить в защищённом журнале с correlation ID. Код 403 оставляют для случая, когда субъект установлен, но authorization запретила действие.
Secure просит user agent отправлять cookie только по защищённому соединению, но не отзывает серверную запись и не превращает значение в доказательство подлинности. HttpOnly убирает cookie из обычного JavaScript API; это снижает риск чтения значения клиентским кодом, но не лечит XSS и не останавливает браузер от автоматической отправки cookie.
SameSite=Lax ограничивает часть cross-site запросов и обычно допускает верхнеуровневую навигацию безопасным методом. Это слой defense in depth, а не общий CSRF-контракт: GET, который меняет состояние, клиентский CSRF и неконтролируемые поддомены остаются проблемами. Для mutation endpoint задайте CSRF-токен или проверку Origin/Referer по требованиям приложения.
Пара Max-Age/Expires задаёт срок хранения в user agent. Серверный expiresAt — отдельные часы. Браузер может удалить cookie раньше, а сервер обязан отвергнуть запись после своего срока, даже если cookie всё ещё пришла.
Префикс __Host- ограничивает область cookie: нужны Secure, явный Path=/ и отсутствие Domain. Это привязывает cookie к конкретному host, но не является проверкой роли и не защищает от ошибки в серверном handler. Если продукт работает на нескольких поддоменах или в iframe, это решение может быть неприменимо: scope, SameSite=None, CSRF и доверие к соседним host надо проектировать отдельно.
Set-Cookie: __Host-session=<opaque-id>; Path=/; Secure; HttpOnly; SameSite=Lax
Cache-Control: no-store\nСтрока выше — контракт заголовка, а не готовое значение. Не подставляйте в cookie email, роль или JSON профиля. Приложение само выбирает срок, способ хранения и формат непрозрачного ID.
\nRotation нужна, когда меняется уровень доверия: например, анонимная сессия становится аутентифицированной или меняются привилегии. OWASP рекомендует регенерировать идентификатор после изменения уровня привилегий и считать действующим только current ID. В выбранной модели старая запись получает rotated, создаётся successor с увеличенным поколением, а указатель lineage атомарно переключается на него.
const presented = request.cookies['__Host-session'];
const current = await sessions.findById(presented);
if (!current || current.status !== 'active'
|| current.id !== current.lineageCurrentId
|| current.expiresAt <= now()) {
return reject401('invalid-session');
}
const next = await sessions.rotateIfCurrent({
oldId: current.id,
lineageId: current.lineageId,
expectedGeneration: current.generation,
});
if (!next) return reject401('stale-session');
response.setHeader('Set-Cookie', serializeSessionCookie(next.id));\nЭто псевдокод: функции хранилища и сериализации здесь не определены. Ключевое место — rotateIfCurrent. Оно должно в одной транзакции или условном обновлении проверить ожидаемое поколение, пометить старую запись и создать ровно одного successor. Проверка в приложении, отдельная запись и последующий update без условия оставляют гонку.
Два параллельных запроса могут прочитать один active ID. Если оба безусловно создают successor, браузер получит два ответа Set-Cookie, а последним станет случайный. Тест отправляет два запроса одного поколения и проверяет, что победил один, а проигравший получил stale-session или иной заранее оговорённый безопасный результат.
Logout-current отзывает предъявленную текущую запись. Logout-lineage отзывает все записи одного входа, включая successor. Logout-all отзывает все записи identity на устройствах. Это три разных операции и три разных ожидания пользователя. Старая вкладка не должна выключать новый вход, если endpoint заявляет logout-current.
\n| Операция | Что отзывает | Когда применять | Проверка |
|---|---|---|---|
| current | Текущий ID | Обычная кнопка выхода из этой вкладки | Новое поколение остаётся active |
| lineage | Цепочку одного входа | Подозрение на кражу этого входа | Старый и новый ID получают reject |
| all | Все записи identity | Смена пароля или аварийный отзыв | Другие устройства теряют доступ |
После серверного отзыва ответ может очистить cookie. Чтобы браузер удалил именно созданную cookie, имя, Path и Domain должны совпадать с исходными атрибутами; для __Host- не добавляйте Domain. Очистка улучшает UX, но logout считается выполненным только после смены серверного статуса.
const presented = request.cookies['__Host-session'];
const result = await sessions.revokeCurrentIfCurrent({
presentedId: presented,
});
if (result.kind === 'stale') return clearHostCookie(response, 401);
return clearHostCookie(response, 204);\nВызов с rotated ID в этой политике не отзывает successor и не создаёт новую запись. Если бизнесу нужен logout-lineage, это должен быть отдельный endpoint или явный параметр с отдельной авторизацией.
\nНиже приведён контрактный сценарий для тестового окружения. Переменная BASE_URL должна указывать на приложение, где реализованы /session/start, /session/rotate, /protected-resource и /session/logout. Названия учебные; они не являются стандартными маршрутами.
export BASE_URL='https://test.example.invalid'
# Сначала создаём тестовую сессию и сохраняем cookie jar.
curl -i -c cookie-jar.txt \"$BASE_URL/session/start\"
export OLD_ID='opaque-id-from-session-start'
# Current ID проходит до authorization.
curl -i -b cookie-jar.txt \"$BASE_URL/protected-resource\"
# Rotation должна обновить тот же jar.
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\"
# Старый ID: ожидаем 401, ресурс не изменился.
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\"
# Logout-current с новым ID и повторная проверка.
curl -i -X POST -b cookie-jar.txt -c cookie-jar.txt \"$BASE_URL/session/logout\"
curl -i -b cookie-jar.txt \"$BASE_URL/protected-resource\"\nПервые два запроса фиксируют happy path, третий — обязательный отрицательный путь: ожидание 401, ресурс не изменился. Проверяйте также единственный successor и атрибуты Set-Cookie. Для реального запуска сохраните cookie jar только во временном каталоге CI. Значение test.example.invalid намеренно не является рабочим доменом: его заменяет владелец стенда.
| Симптом | Гипотеза | Как проверить | Исправление |
|---|---|---|---|
| Cookie есть, ответ 401 | record revoked, rotated или expired | Сопоставить хеш ID, status, generation и срок | Оставить отказ и корректно очистить cookie |
| Старый запрос изменил данные | Проверено только наличие ID | Повторить запрос после rotation | Проверять record до вызова domain service |
| Старая вкладка выключила новую | Logout отозвал lineage вместо current | Сравнить scope и lineage в событии | Разделить операции отзыва |
| После двух rotation активны два ID | Нет условного перехода поколения | Параллельно отправить запросы одного generation | Добавить транзакцию или compare-and-swap |
| Cross-site mutation прошла | SameSite приняли за CSRF-защиту | Проверить Origin, метод и CSRF-токен | Закрыть mutation отдельным серверным контролем |
Для расследования нужны не значения cookie, а связи между событиями. Логируйте correlation ID, хешированный или усечённый идентификатор, lineage ID, старое и новое поколение, результат перехода и машинную причину отказа. Не пишите в лог полный session ID, заголовок Cookie или содержимое профиля.
Минимальная последовательность событий выглядит так: session.presented → session.lookup → session.transition → authorization.decision → protected.effect. Наличие последнего события при transition=stale — повод искать обход проверки или неправильный порядок вызовов. Лог должен позволять связать два параллельных запроса, но не давать материал для повторного входа.
Строгий reject старого ID подходит для обычной веб-сессии, если параллельные запросы не требуют grace period. Поток с длинной загрузкой, несколькими устройствами, offline-клиентом или несколькими BFF может потребовать иной политики. Тогда задайте срок и число повторных использований явно, привяжите их к операции и всё равно запрещайте старому ID выполнять неожиданные побочные эффекты.
\nЭта статья не задаёт алгоритм CSRF, CORS, reauthentication, распределённых блокировок, clock skew, хранения в конкретной БД или поведение reverse proxy. Browser test не доказывает атомарность базы; unit test хранилища не доказывает, что браузер применил нужные Path, Domain и SameSite. Эти свойства проверяются отдельными тестами на том стеке, который вы выпускаете.
NIST описывает жизненный цикл сессии и повторную аутентификацию, но не выбирает за проект схему таблиц. RFC описывает cookie-протокол, но не знает, какое действие разрешено субъекту. OWASP формулирует прикладные рекомендации, но их надо сопоставить с вашими браузерами, доменами и threat model. Официальная ссылка подтверждает факт или рекомендацию, а не готовность конкретного сервиса.
\nSet-Cookie и очистку в браузере или интеграционном стенде.Origin и без CSRF-доказательства.Cookie/Set-Cookie, область действия и удаление cookie.SameSite и префикс __Host-. Это Internet-Draft, поэтому сверяйте поддерживаемые браузеры и не выдавайте draft за готовый контракт приложения.SameSite и серверные способы защиты mutation endpoint.