"""Подписанный анонимный 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