На проде любые изменения данных, включая выход из аккаунта, отвечали 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
144 lines
5.3 KiB
Python
144 lines
5.3 KiB
Python
"""Безопасность: bcrypt, JWT, cookie сессии и CSRF (double-submit)."""
|
|
from __future__ import annotations
|
|
|
|
import secrets
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import bcrypt
|
|
import jwt
|
|
from fastapi import Request, Response
|
|
|
|
from app.core.config import settings
|
|
|
|
USER_COOKIE = "fs_session"
|
|
ADMIN_COOKIE = "fs_admin"
|
|
CSRF_COOKIE = "csrf_token"
|
|
CSRF_HEADER = "x-csrf-token"
|
|
|
|
AUDIENCE_USER = "user"
|
|
AUDIENCE_ADMIN = "admin"
|
|
|
|
_ADMIN_PATH = "/api/admin"
|
|
_USER_PATH = "/"
|
|
|
|
|
|
# ─── Пароли ──────────────────────────────────────────────────────────────────
|
|
|
|
def hash_password(password: str) -> str:
|
|
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
|
|
|
|
|
def verify_password(password: str, password_hash: str) -> bool:
|
|
try:
|
|
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
|
|
except (ValueError, TypeError):
|
|
return False
|
|
|
|
|
|
# ─── JWT ─────────────────────────────────────────────────────────────────────
|
|
|
|
def create_token(subject: str | int, audience: str, ttl_minutes: int, provider: str = "") -> str:
|
|
now = datetime.now(timezone.utc)
|
|
payload = {
|
|
"sub": str(subject),
|
|
"aud": audience,
|
|
"iat": int(now.timestamp()),
|
|
"exp": int((now + timedelta(minutes=ttl_minutes)).timestamp()),
|
|
"jti": secrets.token_hex(8),
|
|
"provider": provider,
|
|
}
|
|
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)
|
|
|
|
|
|
def decode_token(token: str, audience: str) -> dict:
|
|
return jwt.decode(
|
|
token,
|
|
settings.secret_key,
|
|
algorithms=[settings.jwt_algorithm],
|
|
audience=audience,
|
|
)
|
|
|
|
|
|
# ─── Cookie сессии + CSRF ────────────────────────────────────────────────────
|
|
|
|
def generate_csrf_token() -> str:
|
|
return secrets.token_urlsafe(24)
|
|
|
|
|
|
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=_csrf_max_age(),
|
|
httponly=False, # должен читаться JS, чтобы продублировать в заголовок
|
|
secure=settings.cookie_secure,
|
|
samesite="lax",
|
|
path="/",
|
|
domain=settings.cookie_domain_value,
|
|
)
|
|
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)
|
|
response.set_cookie(
|
|
key=USER_COOKIE,
|
|
value=token,
|
|
max_age=ttl * 60,
|
|
httponly=True,
|
|
secure=settings.cookie_secure,
|
|
samesite="lax",
|
|
path=_USER_PATH,
|
|
domain=settings.cookie_domain_value,
|
|
)
|
|
_set_csrf_cookie(response)
|
|
|
|
|
|
def set_admin_session(response: Response, admin_id: int) -> None:
|
|
ttl = settings.jwt_admin_ttl_minutes
|
|
token = create_token(admin_id, AUDIENCE_ADMIN, ttl, "local")
|
|
response.set_cookie(
|
|
key=ADMIN_COOKIE,
|
|
value=token,
|
|
max_age=ttl * 60,
|
|
httponly=True,
|
|
secure=settings.cookie_secure,
|
|
samesite="lax",
|
|
path=_ADMIN_PATH,
|
|
domain=settings.cookie_domain_value,
|
|
)
|
|
_set_csrf_cookie(response)
|
|
|
|
|
|
def clear_user_session(response: Response) -> None:
|
|
response.delete_cookie(USER_COOKIE, path=_USER_PATH, domain=settings.cookie_domain_value)
|
|
|
|
|
|
def clear_admin_session(response: Response) -> None:
|
|
response.delete_cookie(ADMIN_COOKIE, path=_ADMIN_PATH, domain=settings.cookie_domain_value)
|
|
|
|
|
|
def client_ip(request: Request) -> str | None:
|
|
"""IP клиента для журнала аудита.
|
|
|
|
Одна точка на всё приложение: за VPS-привратником адрес придётся брать из
|
|
X-Forwarded-For, и менять это в двух десятках роутеров — не вариант."""
|
|
return request.client.host if request.client else None
|