All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m50s
Deploy Trade-In / build-backend (push) Successful in 4m23s
Deploy Trade-In / deploy (push) Successful in 2m32s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
111 lines
5.8 KiB
Python
111 lines
5.8 KiB
Python
"""Подписанный анонимный session-cookie — B2C anti-abuse этап 2 (#b2c-antiabuse-2).
|
||
|
||
Продукт открывается для анонимных пользователей (без Caddy basic_auth /
|
||
X-Authenticated-User). Чтобы применить анонимную квоту оценок (см.
|
||
app.services.account_quota), нужен стабильный, но НЕ подделываемый идентификатор
|
||
анонимной сессии — отдельный от X-Authenticated-User (этот заголовок ставит
|
||
Caddy и валидируется внутренним секретом; смешивать схемы идентичности нельзя,
|
||
это сломало бы модель безопасности #2213).
|
||
|
||
Дизайн: session_id — случайный токен (secrets.token_urlsafe), подписанный
|
||
HMAC-SHA256 вместе с expiry в cookie-значении `mera_anon_sid`. Сервер верифицирует
|
||
подпись на каждом запросе; невалидная/просроченная/отсутствующая cookie → минтится
|
||
новая сессия (клиент просто теряет накопленную анонимную квоту — это ОЖИДАЕМО и
|
||
безопасно: анонимная квота и так обходится чисткой cookie/сменой IP, задача
|
||
поднять стоимость злоупотребления, а не сделать его невозможным).
|
||
|
||
Подпись предотвращает две вещи: (1) клиент не может подставить ЧУЖОЙ session_id
|
||
(например, скопированный у другого пользователя) без знания секрета — иначе он
|
||
мог бы попытаться "унаследовать" чужую квоту или испортить чужой учёт; (2) любая
|
||
порча/усечение cookie-значения детектируется явно (HMAC mismatch), а не тихо
|
||
парсится как валиден мусорный session_id.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import hmac
|
||
import logging
|
||
import secrets
|
||
import time
|
||
|
||
from fastapi import Request, Response
|
||
|
||
from app.core.config import settings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
ANON_COOKIE_NAME = "mera_anon_sid"
|
||
# 180 дней — грубо совпадает с "долгоживущий браузер, не чистящий cookies";
|
||
# не критично для безопасности (лимит всё равно per-период_month), только для
|
||
# TTL самой cookie в браузере/HMAC-подписи.
|
||
ANON_SESSION_TTL_S = 180 * 24 * 3600
|
||
|
||
# Пусто (default) → эфемерный per-process секрет: криптографически стойкий
|
||
# (клиент не знает его и не может подделать сессию), но сессии не переживают
|
||
# рестарт процесса и не общие между воркерами (см. anon_session_secret в
|
||
# core/config.py). Вычисляется один раз при импорте модуля.
|
||
if settings.anon_session_secret:
|
||
_SESSION_SECRET: bytes = settings.anon_session_secret.encode("utf-8")
|
||
else:
|
||
_SESSION_SECRET = secrets.token_bytes(32)
|
||
logger.warning(
|
||
"ANON_SESSION_SECRET не задан — используется process-local эфемерный "
|
||
"секрет анонимной сессии (сессии сбрасываются при рестарте / не общие "
|
||
"между воркерами). Задай ANON_SESSION_SECRET в .env.runtime для "
|
||
"стабильности между рестартами."
|
||
)
|
||
|
||
|
||
def _sign(session_id: str, expires_at: int) -> str:
|
||
msg = f"{session_id}.{expires_at}".encode()
|
||
return hmac.new(_SESSION_SECRET, msg, hashlib.sha256).hexdigest()
|
||
|
||
|
||
def _encode(session_id: str, expires_at: int) -> str:
|
||
return f"{session_id}.{expires_at}.{_sign(session_id, expires_at)}"
|
||
|
||
|
||
def _decode_and_verify(cookie_value: str) -> str | None:
|
||
"""Возвращает session_id если cookie валидна (подпись + не просрочена), иначе None."""
|
||
parts = cookie_value.split(".", 2)
|
||
if len(parts) != 3:
|
||
return None
|
||
session_id, expires_at_raw, sig = parts
|
||
try:
|
||
expires_at = int(expires_at_raw)
|
||
except ValueError:
|
||
return None
|
||
if time.time() > expires_at:
|
||
return None
|
||
expected = _sign(session_id, expires_at)
|
||
if not hmac.compare_digest(sig, expected):
|
||
return None
|
||
return session_id
|
||
|
||
|
||
def get_or_create_anon_session_id(request: Request, response: Response) -> str:
|
||
"""Возвращает стабильный анонимный session_id, выставляя cookie при необходимости.
|
||
|
||
Читает `mera_anon_sid` из запроса; если отсутствует, повреждена, просрочена
|
||
или не проходит проверку подписи — минтит НОВУЮ сессию и выставляет свежую
|
||
cookie на *response* (httponly, samesite=lax; secure вне dev-окружения).
|
||
"""
|
||
raw = request.cookies.get(ANON_COOKIE_NAME)
|
||
if raw:
|
||
verified = _decode_and_verify(raw)
|
||
if verified is not None:
|
||
return verified
|
||
logger.info("anon_session: invalid/tampered/expired cookie — minting new session")
|
||
|
||
session_id = secrets.token_urlsafe(16)
|
||
expires_at = int(time.time()) + ANON_SESSION_TTL_S
|
||
response.set_cookie(
|
||
ANON_COOKIE_NAME,
|
||
_encode(session_id, expires_at),
|
||
max_age=ANON_SESSION_TTL_S,
|
||
httponly=True,
|
||
samesite="lax",
|
||
secure=settings.environment != "dev",
|
||
)
|
||
return session_id
|