{ "index": 177, "slug": "editorial-2023-02-practice-sessions-auth", "title": "Сессия после rotation и logout: один контракт для cookie и сервера", "excerpt": "Как отделить cookie от серверной сессии, не принять старый ID после rotation и доказать logout проверкой доступа, а не только очисткой браузера.", "contentHtml": "
Пользователь нажимает «Сохранить» после logout в другой вкладке. Интерфейс получает успешный ответ, хотя сессию уже должны были закрыть. В другом варианте старый запрос приходит после rotation и снова проходит, потому что обработчик проверяет только наличие cookie. Ошибка выглядит случайной. Цена ошибки измерима: сервер меняет данные после отзыва доступа, новая сессия может исчезнуть из-за позднего ответа, а расследование не знает, какой ID считался действующим.
\\\nТезис простой: cookie переносит непрозрачный ID, но не принимает решение о доступе. Сервер хранит запись сессии, её статус, срок и связь с текущей версией. Rotation заменяет current ID и делает старый ID непригодным. Logout отзывает серверную запись и отправляет браузеру cookie с тем же scope в прошлом. Эти действия связаны, но не заменяют друг друга.
\\\nАутентификация отвечает на вопрос «кто прошёл вход». Эта статья не моделирует сам вход. После него приложение создаёт серверную сессию и связывает её с identity. Дальше каждый защищённый запрос проходит несколько границ. Если их склеить в одну проверку if (cookie), система начнёт путать носитель, состояние и право.
Первый факт — доставка cookie. User agent прикладывает значение, если имя, host, Path, Secure и SameSite подходят запросу. Это только входная строка. Она может быть старой, отозванной или украденной. Даже отсутствие cookie не доказывает, что серверная запись исчезла.
\\\nВторой факт — серверная запись. Она хранит ID или его безопасный отпечаток, identity, статус active, rotated или revoked, срок действия и поколение. Обработчик сначала находит запись и проверяет её. Только после этого он передаёт подтверждённый контекст в authorization.
Третий факт — lineage. Это связь последовательных версий одной сессии. Она отвечает на вопрос «какой ID сейчас current». После rotation у линии должен остаться один current ID. Старый ID можно сохранить для диагностики, но нельзя снова сделать его действующим.
\\\nЧетвёртый факт — право на операцию. Active session не означает право менять профиль, выплачивать деньги или читать административные данные. Authorization отдельно проверяет subject, ресурс и действие. Наличие cookie не даёт ни одной из этих гарантий.
\\\n| Слой | Вопрос | Проверка | Чего он не доказывает |
|---|---|---|---|
| Cookie delivery | Какой ID пришёл? | Заголовок, имя и scope | Что ID действителен |
| Session record | Принимать ли ID? | status, expiry и current pointer | Право на конкретное действие |
| Lineage | Какой ID заменил старый? | generation и successor | Что два запроса выполнятся по порядку |
| Authorization | Разрешена ли операция? | роль, ресурс и действие | Что cookie настроена безопасно |
Secure ограничивает отправку cookie защищённым каналом. Он не шифрует запись в базе и не отзывает её при logout. HttpOnly убирает значение из обычного JavaScript API. Он уменьшает поверхность кражи через клиентский код, но не устраняет XSS и не запрещает серверу ошибочно принимать старый ID.
SameSite=Lax ограничивает часть cross-site отправок. Это слой защиты браузера, а не проверка mutation endpoint. Потоки с несколькими доменами, embed или внешним провайдером могут потребовать другую политику. Нельзя объявлять запрос безопасным только по одному flag.
Max-Age и Expires задают срок хранения в user agent. Браузер может удалить cookie раньше. Серверный timeout живёт в другом месте и должен проверяться независимо. Поэтому «cookie ещё пришла» не означает «сессия ещё активна», а «cookie исчезла» не означает «logout дошёл до сервера».
Префикс __Host- подходит для host-only cookie: нужны Secure, Path=/ и отсутствие Domain. Это фиксирует область доставки. Префикс не выдаёт identity, не проверяет права и не закрывает доступ после отзыва записи. Если cookie должна работать на нескольких поддоменах, такой scope не подходит.
Rotation нужен, когда сервис хочет заменить предъявляемый идентификатор: после входа, повышения доверия или другого заданного события. Сначала сервер проверяет, что пришёл current ID. Затем одна операция помечает predecessor как rotated, создаёт successor с новым поколением и передвигает pointer. Ответ выдаёт cookie successor.
Поздний запрос со старым ID должен получить отказ вроде stale-session. Он не должен продлевать старую запись, повторно создавать successor или менять данные. Нельзя решать эту задачу одним TTL. TTL отвечает за срок, а rotation — за замену владельца current ID.
В реальном хранилище нужна линейзация: транзакция, conditional update, compare-and-swap или эквивалентная гарантия. Два параллельных запроса не должны выпустить два current successor. Учебный пример ниже фиксирует требование к переходам, но не моделирует конкурентность, браузер, сеть или базу.
\\\nconst current = sessions.findById(request.cookies[SESSION_NAME]);\\\n\\\nif (!current || current.status !== 'active' || current.expiresAt <= now) {\\\n return response.status(401).end();\\\n}\\\n\\\nconst successor = rotateOnce(current); // transaction or compare-and-swap\\\nsetCookie(response, SESSION_NAME, successor.id, {\\\n secure: true,\\\n httpOnly: true,\\\n sameSite: 'lax',\\\n path: '/',\\\n});\\\n\\\n// A later request with current.id must return stale-session.\\\nreturn response.json({ status: 'rotated' });\\\nЭтот код — учебная форма контракта. В нём нет настоящих секретов, обработки ошибок хранилища и выбора политики reauthentication. Функция rotateOnce должна сделать проверку и переход одной защищённой операцией. Если она только читает запись, а потом отдельно пишет новую, пример не решает гонку.
Серверная ветвь закрывает доступ. Она находит предъявленный current ID, помечает запись revoked и убирает его из current pointer. Повторный запрос с тем же ID получает отказ. Если запрос уже stale, он не должен отозвать successor: иначе поздний ответ в старой вкладке выключит новую сессию.
Клиентская ветвь убирает удобный носитель. Ответ возвращает пустое значение с теми же именем, host, Path и, если он был, Domain. Дата истечения должна быть в прошлом. Совпадение scope важно: clear cookie с другим Path может оставить исходное значение.
\\\nЭти ветви доказывают разное. Clear cookie улучшает состояние браузера и интерфейс. Только server-side reject доказывает, что отозванный ID больше не принимают. Если logout защищён от CSRF, это отдельная проверка. SameSite не заменяет её во всех потоках.
\\\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Старый ID проходит после rotation | Handler проверяет наличие cookie, а не status и current | Отправить predecessor после успешного rotation | Отклонять rotated ID до protected effect |
| Logout меняет только интерфейс | Удалили cookie, но не отозвали запись | Повторить запрос с сохранённым ID | Revoke-ить запись и проверить ответ 401/403 |
| Поздний logout закрывает новую сессию | Операция не различает stale и current | Сначала сделать rotation, затем logout старым ID | Выбрать scope logout-current, lineage или all и закрепить его |
| Cookie живёт не там, где её чистят | При issue и clear различаются Path, Domain или host | Сравнить оба Set-Cookie по каждому атрибуту | Сформировать clear из того же scope-контракта |
| Два запроса создают два successor | Rotation разделён между чтением и записью | Проверить concurrent path в разрешённой среде | Добавить транзакционную или conditional линейзацию |
Модель не выбирает SQL, кеш, signed token или framework store. Она не моделирует несколько устройств, вкладки, clock skew, reverse proxy, reauthentication, CSRF-токены и сетевой порядок ответов. Идентификаторы в примере учебные. Их нельзя использовать в production. Для logout всех устройств нужна отдельная операция, которая явно выбирает identity и все её записи.
\\\nРабота готова, если можно показать четыре независимых доказательства: запрос с revoked или rotated ID получает отказ; rotation оставляет ровно один current ID; logout очищает cookie с тем же scope; active session без подходящего authorization получает отказ. Для конкурентного rotation дополнительно нужен тест, который не допускает двух successor. Если доказательство есть только в UI или только в памяти, контракт ещё не проверен.
\\\n