"""Безопасность: 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 from app.core.token_revocation import revoked_tokens 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 = "", token_version: int = 0, ) -> 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, # Версия сессий владельца: при её росте (смена/сброс пароля) старые токены # с меньшим `ver` отклоняются в auth/deps — отзыв всех прежних сессий (#57). "ver": token_version, } 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, token_version: int = 0) -> None: ttl = settings.jwt_user_ttl_minutes token = create_token(user_id, AUDIENCE_USER, ttl, provider, token_version) 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, token_version: int = 0) -> None: ttl = settings.jwt_admin_ttl_minutes token = create_token(admin_id, AUDIENCE_ADMIN, ttl, "local", token_version) 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 revoke_session_token(request: Request, cookie_name: str, audience: str) -> None: """Отзывает предъявленный в cookie токен (по `jti`) до его `exp` — точечный logout. Убивает именно этот токен (украденный/оставленный), не трогая другие устройства. Некорректный/просроченный токен отзывать нечего — молча выходим.""" token = request.cookies.get(cookie_name) if not token: return try: payload = jwt.decode( token, settings.secret_key, algorithms=[settings.jwt_algorithm], audience=audience, ) except jwt.PyJWTError: return exp = payload.get("exp") if exp is not None: revoked_tokens.revoke(payload.get("jti"), float(exp)) def is_session_revoked(payload: dict) -> bool: """Отозван ли этот токен точечно (через logout).""" return revoked_tokens.is_revoked(payload.get("jti")) def client_ip(request: Request) -> str | None: """IP клиента для журнала аудита. Одна точка на всё приложение: за VPS-привратником адрес придётся брать из X-Forwarded-For, и менять это в двух десятках роутеров — не вариант.""" return request.client.host if request.client else None