gendesign/tradein-mvp/backend/app/core/anon_session.py
lekss361 d409e31f00
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
feat(mera/b2c): анти-абуз для анонимного трафика — этап 2 из 8 (#2546)
2026-08-25 16:27:02 +00:00

111 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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