CSRF: перевыдавать токен, если сессия есть, а cookie нет

На проде любые изменения данных, включая выход из аккаунта, отвечали 403
CSRF_FAILED, хотя сайт оставался залогиненным. Cookie csrf_token одна на игрока
и админку: вход в админку перезаписывал её со сроком 8 часов, а сессия игрока
живёт 7 дней. Когда токен истекал, сервер выдавал новый только при входе, а войти
и выйти мешала та же проверка. Из этого состояния было не выбраться, кроме как
стереть cookie сайта руками.

Теперь CSRFMiddleware перевыдаёт токен на любом ответе /api, если запрос несёт
сессионную cookie без csrf_token, в том числе на самом отказе. SPA на загрузке
делает GET /api/users/me, поэтому пользователю хватает перезагрузить страницу.
Правится только стартовое сообщение ответа, тело идёт насквозь, и SSE-поток
не буферизуется. Срок токена теперь не короче самой долгой сессии, так что вход
в админку больше не укорачивает токен игрока.

Проверка double-submit не ослаблена: запрос с cookie, но без заголовка или с
чужим токеном по-прежнему получает 403, и cookie в этом случае не перевыдаётся.
Перевыданный токен из кросс-сайтового ответа атакующему ничего не даёт: прочитать
cookie может только JS того же origin.

Тесты закрепляют восстановление на GET и на отказе, срок после входа в админку,
прежнюю строгость проверки, отсутствие токена у анонимов и то, что middleware
не склеивает чанки потока. На старом коде четыре из них падают.

#50

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
This commit is contained in:
2026-09-13 13:14:14 +03:00
co-authored by Claude Opus 5
parent 65561155b4
commit cb3af484cd
3 changed files with 198 additions and 8 deletions
+19 -4
View File
@@ -65,12 +65,19 @@ def generate_csrf_token() -> str:
return secrets.token_urlsafe(24)
def _set_csrf_cookie(response: Response, max_age: int) -> str:
def _csrf_max_age() -> int:
"""Токен один на обе сессии, поэтому живёт не меньше самой долгой из них: иначе вход
в админку (8 ч) перезаписывал токен игрока (7 дней), и после его истечения все мутации
игрока падали с CSRF_FAILED. Без сессии токен бесполезен — лишний срок безопасен."""
return max(settings.jwt_user_ttl_minutes, settings.jwt_admin_ttl_minutes) * 60
def _set_csrf_cookie(response: Response) -> str:
csrf = generate_csrf_token()
response.set_cookie(
key=CSRF_COOKIE,
value=csrf,
max_age=max_age,
max_age=_csrf_max_age(),
httponly=False, # должен читаться JS, чтобы продублировать в заголовок
secure=settings.cookie_secure,
samesite="lax",
@@ -80,6 +87,14 @@ def _set_csrf_cookie(response: Response, max_age: int) -> str:
return csrf
def fresh_csrf_set_cookie() -> tuple[bytes, bytes]:
"""Готовый заголовок Set-Cookie со свежим токеном — для ASGI-middleware, где объекта
Response нет. Строится тем же _set_csrf_cookie, чтобы атрибуты не разъехались."""
carrier = Response()
_set_csrf_cookie(carrier)
return next((k, v) for k, v in carrier.raw_headers if k == b"set-cookie")
def set_user_session(response: Response, user_id: int, provider: str) -> None:
ttl = settings.jwt_user_ttl_minutes
token = create_token(user_id, AUDIENCE_USER, ttl, provider)
@@ -93,7 +108,7 @@ def set_user_session(response: Response, user_id: int, provider: str) -> None:
path=_USER_PATH,
domain=settings.cookie_domain_value,
)
_set_csrf_cookie(response, ttl * 60)
_set_csrf_cookie(response)
def set_admin_session(response: Response, admin_id: int) -> None:
@@ -109,7 +124,7 @@ def set_admin_session(response: Response, admin_id: int) -> None:
path=_ADMIN_PATH,
domain=settings.cookie_domain_value,
)
_set_csrf_cookie(response, ttl * 60)
_set_csrf_cookie(response)
def clear_user_session(response: Response) -> None: