{ "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 переносит значение, а сервер принимает решение. Сессия должна иметь запись со статусом, сроком, identity и указателем на текущую версию. Rotation заменяет current ID и делает predecessor непригодным. Logout отзывает серверное состояние и отдельно очищает cookie с тем же scope. Очистка браузера без отказа сервера не является logout.
\\\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 не заменяет её во всех потоках.
Проверять нужно не только финальный экран, но и HTTP-ответ. Сохраните заголовки без секретов и сравните scope cookie при issue и clear. Последовательность ниже использует учебные ID; адрес и endpoint должны существовать в вашей тестовой среде.
# predecessor после rotation должен получить отказ\ncurl -i -H 'Cookie: sid=fixture-s-1' -X POST 'https://example.test/profile'\n\n# successor проходит проверку сессии, затем authorization\ncurl -i -H 'Cookie: sid=fixture-s-2' -X POST 'https://example.test/profile'\n\n# logout-current отзывает successor и возвращает Set-Cookie с датой в прошлом\ncurl -i -H 'Cookie: sid=fixture-s-2' -X POST 'https://example.test/logout'\n\n# тот же ID после logout снова должен получить отказ\ncurl -i -H 'Cookie: sid=fixture-s-2' -X POST 'https://example.test/profile'\\\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