Files
NotBigGhostandClaude Opus 5 ec1e94119c Чистка: устаревшие комментарии и подсказки, export-скрипт, мусорный файл
Комментарии про методы входа приведены к факту (логин/пароль и Telegram везде,
stub только в development): config.py, .env.example, telegram.py, dev_stub.py,
dev_auth.py, login.py. admin_login.py больше не обещает «строже игроцкого» —
описаны реальные лимиты; client_ip — адрес за прокси уже даёт uvicorn.

Подсказки деплоя: run.ps1/run.sh (build-push на ПК, `up -d` на Pi), compose,
.env.example (build-push.ps1), Caddyfile (туннель-контейнер вместо autossh);
ratelimit.py ссылается на core/events.py вместо CLAUDE.md.

Удалены неиспользуемые scripts/export-prod.sh (с блоком export-ignore в
.gitattributes и упоминаниями в README), db/init_db.py и случайно закоммиченный
файл «h -u origin dev…». #70

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-19 03:12:16 +03:00

181 lines
6.8 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
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-привратником уже подставляет
uvicorn (--proxy-headers + --forwarded-allow-ips в entrypoint.sh) — отсюда он и берётся."""
return request.client.host if request.client else None