Merge pull request 'Хардненинг по итогам пен-теста #25 (#56–#62)' (#63) from issue-56-62-hardening into dev
Reviewed-on: #63
This commit was merged in pull request #63.
This commit is contained in:
@@ -0,0 +1,44 @@
|
|||||||
|
"""Пользователь: версия сессий (token_version) для отзыва JWT.
|
||||||
|
|
||||||
|
Идемпотентна: на свежей БД столбец создаёт 0001 (create_all из актуальных моделей) -> no-op;
|
||||||
|
на существующей БД добавляет столбец. render_as_batch включён в env.py (для SQLite).
|
||||||
|
|
||||||
|
Инкремент token_version отзывает все ранее выданные токены пользователя (см. auth/deps, #57).
|
||||||
|
|
||||||
|
Revision ID: 0013_user_token_version
|
||||||
|
Revises: 0012_match_finish_draft
|
||||||
|
Create Date: 2026-09-13
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0013_user_token_version"
|
||||||
|
down_revision: Union[str, None] = "0012_match_finish_draft"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
cols = {c["name"] for c in insp.get_columns("users")}
|
||||||
|
with op.batch_alter_table("users") as b:
|
||||||
|
if "token_version" not in cols:
|
||||||
|
b.add_column(
|
||||||
|
sa.Column(
|
||||||
|
"token_version", sa.Integer(), nullable=False, server_default="0"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
cols = {c["name"] for c in insp.get_columns("users")}
|
||||||
|
with op.batch_alter_table("users") as b:
|
||||||
|
if "token_version" in cols:
|
||||||
|
b.drop_column("token_version")
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Вход администратора под защитой от перебора (#56).
|
||||||
|
|
||||||
|
Тонкий слой поверх `admin_service.authenticate_admin`: throttle по IP, по паре «IP + логин»
|
||||||
|
и по самому аккаунту через тот же `LoginThrottle`, что и вход игрока (`core/ratelimit`).
|
||||||
|
Сервис остаётся чистым от инфраструктуры лимитов. Пароль администратора — единственный
|
||||||
|
барьер к полному контролю приложения, поэтому перебор здесь ограничиваем строже игроцкого.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.core.errors import InvalidCredentialsError
|
||||||
|
from app.core.ratelimit import login_throttle
|
||||||
|
from app.models import User
|
||||||
|
from app.services import admin_service
|
||||||
|
|
||||||
|
# Неудач за окно LoginThrottle (15 минут):
|
||||||
|
_PAIR_LIMIT = 5 # на пару «IP + логин» — против перебора пароля с одного адреса
|
||||||
|
_IP_LIMIT = 20 # на IP — против перебора по разным логинам с одного адреса
|
||||||
|
# На сам аккаунт (IP-независимо, #60): распределённый перебор с ротацией IP всё равно
|
||||||
|
# упирается в этот предел. Щедрее пары, чтобы случайный поток ошибок не запирал вход
|
||||||
|
# админа насовсем (лимит на аккаунт — вектор lockout-DoS, поэтому не слишком строгий).
|
||||||
|
_ACCOUNT_LIMIT = 50
|
||||||
|
|
||||||
|
|
||||||
|
def _keys(ip: str, username: str) -> dict[str, int]:
|
||||||
|
pair_key = f"admin-login:{ip}:{username.casefold()}"
|
||||||
|
return {
|
||||||
|
pair_key: _PAIR_LIMIT,
|
||||||
|
f"admin-login-ip:{ip}": _IP_LIMIT,
|
||||||
|
f"admin-login-user:{username.casefold()}": _ACCOUNT_LIMIT,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def login_admin(session: Session, username: str, password: str, ip: str | None) -> User:
|
||||||
|
"""authenticate_admin под защитой от перебора. Сессию открывает вызывающий."""
|
||||||
|
username = (username or "").strip()
|
||||||
|
ip = ip or "unknown"
|
||||||
|
limits = _keys(ip, username)
|
||||||
|
login_throttle.check(limits)
|
||||||
|
try:
|
||||||
|
admin = admin_service.authenticate_admin(session, username, password)
|
||||||
|
except InvalidCredentialsError:
|
||||||
|
login_throttle.fail(limits)
|
||||||
|
raise
|
||||||
|
# Успех: снимаем счётчики этого аккаунта, чтобы законный вход не копил лимит.
|
||||||
|
for key in limits:
|
||||||
|
login_throttle.reset(key)
|
||||||
|
return admin
|
||||||
@@ -21,9 +21,14 @@ def get_current_user(
|
|||||||
payload = security.decode_token(token, security.AUDIENCE_USER)
|
payload = security.decode_token(token, security.AUDIENCE_USER)
|
||||||
except jwt.PyJWTError as exc: # noqa: F841
|
except jwt.PyJWTError as exc: # noqa: F841
|
||||||
raise AuthError("Сессия недействительна.")
|
raise AuthError("Сессия недействительна.")
|
||||||
|
if security.is_session_revoked(payload):
|
||||||
|
raise AuthError("Сессия недействительна.")
|
||||||
user = session.get(User, int(payload["sub"]))
|
user = session.get(User, int(payload["sub"]))
|
||||||
if user is None or not user.is_active:
|
if user is None or not user.is_active:
|
||||||
raise AuthError("Сессия недействительна.")
|
raise AuthError("Сессия недействительна.")
|
||||||
|
if int(payload.get("ver", 0)) != int(user.token_version or 0):
|
||||||
|
# Пароль сменён/сброшен после выдачи токена — прежние сессии отозваны (#57).
|
||||||
|
raise AuthError("Сессия недействительна.")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@@ -37,7 +42,11 @@ def get_current_admin(
|
|||||||
payload = security.decode_token(token, security.AUDIENCE_ADMIN)
|
payload = security.decode_token(token, security.AUDIENCE_ADMIN)
|
||||||
except jwt.PyJWTError:
|
except jwt.PyJWTError:
|
||||||
raise AuthError("Сессия администратора недействительна.")
|
raise AuthError("Сессия администратора недействительна.")
|
||||||
|
if security.is_session_revoked(payload):
|
||||||
|
raise AuthError("Сессия администратора недействительна.")
|
||||||
user = session.get(User, int(payload["sub"]))
|
user = session.get(User, int(payload["sub"]))
|
||||||
if user is None or user.role != "admin" or not user.is_active:
|
if user is None or user.role != "admin" or not user.is_active:
|
||||||
raise ForbiddenError("Доступ только для администратора.")
|
raise ForbiddenError("Доступ только для администратора.")
|
||||||
|
if int(payload.get("ver", 0)) != int(user.token_version or 0):
|
||||||
|
raise AuthError("Сессия администратора недействительна.")
|
||||||
return user
|
return user
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ def establish_session(
|
|||||||
"""Открыть сессию уже найденному/созданному пользователю (cookie + аудит)."""
|
"""Открыть сессию уже найденному/созданному пользователю (cookie + аудит)."""
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
raise ForbiddenError("Аккаунт отключён администратором.", code="ACCOUNT_DISABLED")
|
raise ForbiddenError("Аккаунт отключён администратором.", code="ACCOUNT_DISABLED")
|
||||||
security.set_user_session(response, user.id, provider) # type: ignore[arg-type]
|
security.set_user_session(response, user.id, provider, user.token_version) # type: ignore[arg-type]
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
session,
|
session,
|
||||||
actor_id=user.id,
|
actor_id=user.id,
|
||||||
|
|||||||
@@ -27,8 +27,15 @@ _BCRYPT_MAX_BYTES = 72
|
|||||||
# одного аккаунта; на IP — против перебора по многим логинам с одного адреса.
|
# одного аккаунта; на IP — против перебора по многим логинам с одного адреса.
|
||||||
_PAIR_LIMIT = 5
|
_PAIR_LIMIT = 5
|
||||||
_IP_LIMIT = 20
|
_IP_LIMIT = 20
|
||||||
|
# На сам аккаунт (IP-независимо, #60): распределённый перебор с ротацией IP всё равно
|
||||||
|
# упирается в этот предел. Щедрее пары, чтобы поток ошибок с разных адресов не запирал
|
||||||
|
# вход настоящему владельцу (лимит на аккаунт — вектор lockout-DoS, потому не строгий);
|
||||||
|
# успешный вход его сбрасывает.
|
||||||
|
_ACCOUNT_LIMIT = 50
|
||||||
# Неверный текущий пароль при смене — на аккаунт.
|
# Неверный текущий пароль при смене — на аккаунт.
|
||||||
_CURRENT_PASSWORD_LIMIT = 5
|
_CURRENT_PASSWORD_LIMIT = 5
|
||||||
|
# Регистраций с одного IP за окно — против спама аккаунтов (#62).
|
||||||
|
_REGISTER_IP_LIMIT = 10
|
||||||
|
|
||||||
|
|
||||||
def validate_new_password(password: str) -> None:
|
def validate_new_password(password: str) -> None:
|
||||||
@@ -67,17 +74,33 @@ def login_player(session: Session, nickname: str, password: str, ip: str | None)
|
|||||||
nickname = (nickname or "").strip()
|
nickname = (nickname or "").strip()
|
||||||
ip = ip or "unknown"
|
ip = ip or "unknown"
|
||||||
pair_key = f"login:{ip}:{nickname.casefold()}"
|
pair_key = f"login:{ip}:{nickname.casefold()}"
|
||||||
limits = {pair_key: _PAIR_LIMIT, f"login-ip:{ip}": _IP_LIMIT}
|
account_key = f"login-user:{nickname.casefold()}"
|
||||||
|
limits = {pair_key: _PAIR_LIMIT, f"login-ip:{ip}": _IP_LIMIT, account_key: _ACCOUNT_LIMIT}
|
||||||
login_throttle.check(limits)
|
login_throttle.check(limits)
|
||||||
try:
|
try:
|
||||||
user = authenticate_player(session, nickname, password)
|
user = authenticate_player(session, nickname, password)
|
||||||
except InvalidCredentialsError:
|
except InvalidCredentialsError:
|
||||||
login_throttle.fail(limits)
|
login_throttle.fail(limits)
|
||||||
raise
|
raise
|
||||||
|
# Успех снимает счётчики этого аккаунта (пара IP+логин и лимит на аккаунт); лимит по IP
|
||||||
|
# оставляем — он общий для всех логинов с адреса.
|
||||||
login_throttle.reset(pair_key)
|
login_throttle.reset(pair_key)
|
||||||
|
login_throttle.reset(account_key)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def throttle_register(ip: str | None) -> None:
|
||||||
|
"""Ограничивает частоту регистраций с одного IP (спам аккаунтов, #62).
|
||||||
|
|
||||||
|
Считаем каждую попытку (и успешную, и нет), поэтому массовое создание аккаунтов
|
||||||
|
с уникальными никами упирается в предел так же, как повторы по занятому нику.
|
||||||
|
Enumeration ников через 409 NICKNAME_TAKEN не закрываем: ники и так публичны в топе."""
|
||||||
|
ip = ip or "unknown"
|
||||||
|
limits = {f"register-ip:{ip}": _REGISTER_IP_LIMIT}
|
||||||
|
login_throttle.check(limits)
|
||||||
|
login_throttle.fail(limits)
|
||||||
|
|
||||||
|
|
||||||
def check_current_password(user: User, current_password: str | None) -> None:
|
def check_current_password(user: User, current_password: str | None) -> None:
|
||||||
"""Сменить уже заданный пароль можно только зная текущий, и подбирать его нельзя:
|
"""Сменить уже заданный пароль можно только зная текущий, и подбирать его нельзя:
|
||||||
иначе оставленная открытой сессия позволила бы отобрать аккаунт насовсем."""
|
иначе оставленная открытой сессия позволила бы отобрать аккаунт насовсем."""
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
# Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково
|
# Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково
|
||||||
@@ -11,6 +12,12 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
# (исключён из образа) — там настройки приходят переменными от docker compose.
|
# (исключён из образа) — там настройки приходят переменными от docker compose.
|
||||||
_ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env")
|
_ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env")
|
||||||
|
|
||||||
|
# Небезопасные значения по умолчанию (годятся только для dev). В production приложение
|
||||||
|
# с ними не стартует — см. валидатор _forbid_default_secrets_in_prod (#59).
|
||||||
|
_DEFAULT_SECRET_KEY = "change-me-dev-secret-not-for-production"
|
||||||
|
_DEFAULT_ADMIN_PASSWORD = "change-me-admin-password"
|
||||||
|
_MIN_SECRET_KEY_LENGTH = 32
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
@@ -50,7 +57,7 @@ class Settings(BaseSettings):
|
|||||||
prod_achievements_dir: str = "/data/achievements"
|
prod_achievements_dir: str = "/data/achievements"
|
||||||
|
|
||||||
# JWT / cookie
|
# JWT / cookie
|
||||||
secret_key: str = "change-me-dev-secret-not-for-production"
|
secret_key: str = _DEFAULT_SECRET_KEY
|
||||||
jwt_algorithm: str = "HS256"
|
jwt_algorithm: str = "HS256"
|
||||||
jwt_user_ttl_minutes: int = 60 * 24 * 7
|
jwt_user_ttl_minutes: int = 60 * 24 * 7
|
||||||
jwt_admin_ttl_minutes: int = 60 * 8
|
jwt_admin_ttl_minutes: int = 60 * 8
|
||||||
@@ -66,7 +73,7 @@ class Settings(BaseSettings):
|
|||||||
# Бутстрап администратора
|
# Бутстрап администратора
|
||||||
admin_bootstrap_enabled: bool = True
|
admin_bootstrap_enabled: bool = True
|
||||||
admin_username: str = "admin"
|
admin_username: str = "admin"
|
||||||
admin_password: str = "change-me-admin-password"
|
admin_password: str = _DEFAULT_ADMIN_PASSWORD
|
||||||
admin_nickname: str = "Администратор"
|
admin_nickname: str = "Администратор"
|
||||||
|
|
||||||
# CORS (для раздельного dev-режима фронта). Строка из env, через запятую —
|
# CORS (для раздельного dev-режима фронта). Строка из env, через запятую —
|
||||||
@@ -115,6 +122,31 @@ class Settings(BaseSettings):
|
|||||||
def cookie_domain_value(self) -> str | None:
|
def cookie_domain_value(self) -> str | None:
|
||||||
return self.cookie_domain or None
|
return self.cookie_domain or None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _forbid_default_secrets_in_prod(self) -> "Settings":
|
||||||
|
"""Fail-fast: в production не стартуем с дефолтными/слабыми секретами (#59).
|
||||||
|
|
||||||
|
Деплой, скопировавший .env.example дословно (или забывший поле), иначе поднялся бы
|
||||||
|
с общеизвестным ключом подписи JWT (подделка любого токена, включая админский) и
|
||||||
|
известным паролем администратора. В dev/test проверка не мешает — там дефолты норма."""
|
||||||
|
if self.app_env.lower() != "production":
|
||||||
|
return self
|
||||||
|
problems: list[str] = []
|
||||||
|
if self.secret_key == _DEFAULT_SECRET_KEY or len(self.secret_key) < _MIN_SECRET_KEY_LENGTH:
|
||||||
|
problems.append(
|
||||||
|
f"SECRET_KEY не задан, дефолтный или короче {_MIN_SECRET_KEY_LENGTH} символов"
|
||||||
|
)
|
||||||
|
if self.admin_bootstrap_enabled:
|
||||||
|
password = (self.admin_password or "").strip()
|
||||||
|
if not password or password == _DEFAULT_ADMIN_PASSWORD:
|
||||||
|
problems.append("ADMIN_PASSWORD не задан или дефолтный")
|
||||||
|
if problems:
|
||||||
|
raise ValueError(
|
||||||
|
"Небезопасная конфигурация production — задайте секреты в .env: "
|
||||||
|
+ "; ".join(problems)
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
"""Ограничение неудачных попыток ввода пароля (защита от перебора).
|
"""Ограничение неудачных попыток ввода пароля (защита от перебора).
|
||||||
|
|
||||||
Счётчики живут в памяти процесса — как и SSE-шина, это рассчитано на один воркер uvicorn.
|
Счётчики живут в памяти процесса — как и SSE-шина, это рассчитано на один воркер uvicorn
|
||||||
Перезапуск их обнуляет; для окна в 15 минут это приемлемо.
|
(`--workers 1`, см. entrypoint.sh/run.*). Перезапуск их обнуляет; для окна в 15 минут это
|
||||||
|
приемлемо. Ограничения устойчивости (#60):
|
||||||
|
* рестарт (в т.ч. деплой) сбрасывает окно — злоумышленник получает новую квоту после
|
||||||
|
перезапуска, но окно короткое, а рестарты редки;
|
||||||
|
* при уходе от одного воркера лимит делится между процессами (каждый считает своё) —
|
||||||
|
тогда счётчики нужно вынести во внешний стор (Redis pub/sub, как отмечено в CLAUDE.md
|
||||||
|
про SSE-шину), общий для всех воркеров.
|
||||||
|
Помимо пары «IP + логин» и лимита по IP есть IP-независимый лимит на аккаунт
|
||||||
|
(`login-user:*` / `admin-login-user:*`), чтобы ротация X-Forwarded-For / многих адресов
|
||||||
|
(#58) не снимала защиту полностью.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import jwt
|
|||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.token_revocation import revoked_tokens
|
||||||
|
|
||||||
USER_COOKIE = "fs_session"
|
USER_COOKIE = "fs_session"
|
||||||
ADMIN_COOKIE = "fs_admin"
|
ADMIN_COOKIE = "fs_admin"
|
||||||
@@ -37,7 +38,13 @@ def verify_password(password: str, password_hash: str) -> bool:
|
|||||||
|
|
||||||
# ─── JWT ─────────────────────────────────────────────────────────────────────
|
# ─── JWT ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def create_token(subject: str | int, audience: str, ttl_minutes: int, provider: str = "") -> str:
|
def create_token(
|
||||||
|
subject: str | int,
|
||||||
|
audience: str,
|
||||||
|
ttl_minutes: int,
|
||||||
|
provider: str = "",
|
||||||
|
token_version: int = 0,
|
||||||
|
) -> str:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
payload = {
|
payload = {
|
||||||
"sub": str(subject),
|
"sub": str(subject),
|
||||||
@@ -46,6 +53,9 @@ def create_token(subject: str | int, audience: str, ttl_minutes: int, provider:
|
|||||||
"exp": int((now + timedelta(minutes=ttl_minutes)).timestamp()),
|
"exp": int((now + timedelta(minutes=ttl_minutes)).timestamp()),
|
||||||
"jti": secrets.token_hex(8),
|
"jti": secrets.token_hex(8),
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
|
# Версия сессий владельца: при её росте (смена/сброс пароля) старые токены
|
||||||
|
# с меньшим `ver` отклоняются в auth/deps — отзыв всех прежних сессий (#57).
|
||||||
|
"ver": token_version,
|
||||||
}
|
}
|
||||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)
|
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)
|
||||||
|
|
||||||
@@ -95,9 +105,9 @@ def fresh_csrf_set_cookie() -> tuple[bytes, bytes]:
|
|||||||
return next((k, v) for k, v in carrier.raw_headers if k == b"set-cookie")
|
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) -> None:
|
def set_user_session(response: Response, user_id: int, provider: str, token_version: int = 0) -> None:
|
||||||
ttl = settings.jwt_user_ttl_minutes
|
ttl = settings.jwt_user_ttl_minutes
|
||||||
token = create_token(user_id, AUDIENCE_USER, ttl, provider)
|
token = create_token(user_id, AUDIENCE_USER, ttl, provider, token_version)
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key=USER_COOKIE,
|
key=USER_COOKIE,
|
||||||
value=token,
|
value=token,
|
||||||
@@ -111,9 +121,9 @@ def set_user_session(response: Response, user_id: int, provider: str) -> None:
|
|||||||
_set_csrf_cookie(response)
|
_set_csrf_cookie(response)
|
||||||
|
|
||||||
|
|
||||||
def set_admin_session(response: Response, admin_id: int) -> None:
|
def set_admin_session(response: Response, admin_id: int, token_version: int = 0) -> None:
|
||||||
ttl = settings.jwt_admin_ttl_minutes
|
ttl = settings.jwt_admin_ttl_minutes
|
||||||
token = create_token(admin_id, AUDIENCE_ADMIN, ttl, "local")
|
token = create_token(admin_id, AUDIENCE_ADMIN, ttl, "local", token_version)
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key=ADMIN_COOKIE,
|
key=ADMIN_COOKIE,
|
||||||
value=token,
|
value=token,
|
||||||
@@ -135,6 +145,33 @@ def clear_admin_session(response: Response) -> None:
|
|||||||
response.delete_cookie(ADMIN_COOKIE, path=_ADMIN_PATH, domain=settings.cookie_domain_value)
|
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:
|
def client_ip(request: Request) -> str | None:
|
||||||
"""IP клиента для журнала аудита.
|
"""IP клиента для журнала аудита.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Точечный отзыв отдельных JWT по `jti` — для logout (выход именно этого токена).
|
||||||
|
|
||||||
|
Список живёт в памяти процесса, как throttle и SSE-шина: рассчитан на один воркер uvicorn.
|
||||||
|
Рестарт очищает список — это приемлемо: записи и так живут лишь до `exp` токена, а на новый
|
||||||
|
процесс приходят уже свежие cookie. Для «выйти со всех устройств» и отзыва при смене пароля
|
||||||
|
используется `token_version` у пользователя (см. models.User, auth/deps), а не этот список.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Выше этого числа записей при отзыве вычищаем протухшие, чтобы поток logout'ов не копил память.
|
||||||
|
_PRUNE_ABOVE = 10_000
|
||||||
|
|
||||||
|
|
||||||
|
class RevokedTokens:
|
||||||
|
"""Множество отозванных `jti` с временем истечения (unix-время, как `exp` в JWT)."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._revoked: dict[str, float] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def revoke(self, jti: str | None, expires_at: float) -> None:
|
||||||
|
if not jti:
|
||||||
|
return
|
||||||
|
now = time.time()
|
||||||
|
with self._lock:
|
||||||
|
if len(self._revoked) > _PRUNE_ABOVE:
|
||||||
|
for key, exp in list(self._revoked.items()):
|
||||||
|
if exp <= now:
|
||||||
|
del self._revoked[key]
|
||||||
|
self._revoked[jti] = expires_at
|
||||||
|
|
||||||
|
def is_revoked(self, jti: str | None) -> bool:
|
||||||
|
if not jti:
|
||||||
|
return False
|
||||||
|
now = time.time()
|
||||||
|
with self._lock:
|
||||||
|
exp = self._revoked.get(jti)
|
||||||
|
if exp is None:
|
||||||
|
return False
|
||||||
|
if exp <= now:
|
||||||
|
del self._revoked[jti] # протухла — заодно вычищаем
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._revoked.clear()
|
||||||
|
|
||||||
|
|
||||||
|
revoked_tokens = RevokedTokens()
|
||||||
+7
-3
@@ -151,12 +151,16 @@ async def _lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
|
# Схему API (openapi.json + Swagger/ReDoc) отдаём только в dev/test: она нужна для
|
||||||
|
# `npm run gen:api` (генерация типов фронта) и удобной отладки. В production закрываем —
|
||||||
|
# незачем облегчать разведку поверхности API анонимам (#61).
|
||||||
|
docs_enabled = not settings.is_production
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Forbidden Stars API",
|
title="Forbidden Stars API",
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
openapi_url="/api/openapi.json",
|
openapi_url="/api/openapi.json" if docs_enabled else None,
|
||||||
docs_url="/api/docs",
|
docs_url="/api/docs" if docs_enabled else None,
|
||||||
redoc_url="/api/redoc",
|
redoc_url="/api/redoc" if docs_enabled else None,
|
||||||
lifespan=_lifespan,
|
lifespan=_lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,13 @@ class User(SQLModel, table=True):
|
|||||||
sa_column=Column(String(16), nullable=False, server_default="stub"),
|
sa_column=Column(String(16), nullable=False, server_default="stub"),
|
||||||
)
|
)
|
||||||
password_hash: str | None = Field(sa_column=Column(String(255), nullable=True))
|
password_hash: str | None = Field(sa_column=Column(String(255), nullable=True))
|
||||||
|
# Версия сессий: инкремент отзывает все ранее выданные JWT этого пользователя
|
||||||
|
# (claim `ver` в токене сверяется с этим полем в auth/deps). Растёт при смене пароля
|
||||||
|
# и сбросе пароля админом — компрометация или утечка токена так прекращается (#57).
|
||||||
|
token_version: int = Field(
|
||||||
|
default=0,
|
||||||
|
sa_column=Column(Integer, nullable=False, server_default="0"),
|
||||||
|
)
|
||||||
active_group_id: int | None = Field(
|
active_group_id: int | None = Field(
|
||||||
sa_column=Column(
|
sa_column=Column(
|
||||||
Integer,
|
Integer,
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFi
|
|||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.auth.admin_login import login_admin
|
||||||
from app.auth.deps import get_current_admin
|
from app.auth.deps import get_current_admin
|
||||||
from app.core import security
|
from app.core import security
|
||||||
from app.core.security import client_ip
|
from app.core.security import client_ip
|
||||||
from app.core.errors import NotFoundError
|
from app.core.errors import InvalidCredentialsError, NotFoundError
|
||||||
from app.core.timeutil import iso_utc
|
from app.core.timeutil import iso_utc
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models import User
|
from app.models import User
|
||||||
@@ -40,8 +41,23 @@ def admin_login(
|
|||||||
response: Response,
|
response: Response,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> s.AdminMe:
|
) -> s.AdminMe:
|
||||||
admin = admin_service.authenticate_admin(session, body.username, body.password)
|
try:
|
||||||
security.set_admin_session(response, admin.id) # type: ignore[arg-type]
|
admin = login_admin(session, body.username, body.password, client_ip(request))
|
||||||
|
except InvalidCredentialsError:
|
||||||
|
# Неудачную попытку фиксируем в аудите (перебор пароля админа — прямой путь к
|
||||||
|
# полному контролю). Серию таких попыток ограничивает throttle в login_admin (#56).
|
||||||
|
audit_service.record(
|
||||||
|
session,
|
||||||
|
actor_id=None,
|
||||||
|
action="login_failed",
|
||||||
|
entity_type="admin",
|
||||||
|
payload={"username": (body.username or "")[:64]},
|
||||||
|
ip=client_ip(request),
|
||||||
|
user_agent=request.headers.get("user-agent"),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
raise
|
||||||
|
security.set_admin_session(response, admin.id, admin.token_version) # type: ignore[arg-type]
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
session,
|
session,
|
||||||
actor_id=admin.id,
|
actor_id=admin.id,
|
||||||
@@ -56,7 +72,8 @@ def admin_login(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/auth/logout", response_model=s.OkResponse)
|
@router.post("/auth/logout", response_model=s.OkResponse)
|
||||||
def admin_logout(response: Response) -> s.OkResponse:
|
def admin_logout(request: Request, response: Response) -> s.OkResponse:
|
||||||
|
security.revoke_session_token(request, security.ADMIN_COOKIE, security.AUDIENCE_ADMIN)
|
||||||
security.clear_admin_session(response)
|
security.clear_admin_session(response)
|
||||||
return s.OkResponse()
|
return s.OkResponse()
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, Request, Response
|
|||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.auth.login import establish_session
|
from app.auth.login import establish_session
|
||||||
from app.auth.password import login_player
|
from app.auth.password import login_player, throttle_register
|
||||||
from app.auth.registry import enabled_methods
|
from app.auth.registry import enabled_methods
|
||||||
from app.auth.telegram import TelegramProvider
|
from app.auth.telegram import TelegramProvider
|
||||||
from app.core import security
|
from app.core import security
|
||||||
@@ -39,6 +39,7 @@ def password_register(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> s.MeRead:
|
) -> s.MeRead:
|
||||||
"""Регистрация по логину (нику) и паролю. Telegram привязывается позже в профиле."""
|
"""Регистрация по логину (нику) и паролю. Telegram привязывается позже в профиле."""
|
||||||
|
throttle_register(client_ip(request)) # против спама аккаунтов с одного IP (#62)
|
||||||
user = user_service.register_local(session, body.nickname, body.password)
|
user = user_service.register_local(session, body.nickname, body.password)
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
session,
|
session,
|
||||||
@@ -116,6 +117,9 @@ def telegram_register(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/logout", response_model=s.OkResponse)
|
@router.post("/logout", response_model=s.OkResponse)
|
||||||
def logout(response: Response) -> s.OkResponse:
|
def logout(request: Request, response: Response) -> s.OkResponse:
|
||||||
|
# Отзываем именно предъявленный токен (по jti) до его exp: украденная/оставленная
|
||||||
|
# cookie перестаёт работать сразу, а не живёт до конца TTL (#57).
|
||||||
|
security.revoke_session_token(request, security.USER_COOKIE, security.AUDIENCE_USER)
|
||||||
security.clear_user_session(response)
|
security.clear_user_session(response)
|
||||||
return s.OkResponse()
|
return s.OkResponse()
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
"""Роутер текущего пользователя."""
|
"""Роутер текущего пользователя."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
|
from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFile
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.auth.deps import get_current_user
|
from app.auth.deps import get_current_user
|
||||||
from app.auth.password import check_current_password
|
from app.auth.password import check_current_password
|
||||||
from app.auth.telegram import TelegramProvider
|
from app.auth.telegram import TelegramProvider
|
||||||
from app.core.security import client_ip
|
from app.core.security import client_ip, set_user_session
|
||||||
from app.core.errors import NotFoundError
|
from app.core.errors import NotFoundError
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models import User
|
from app.models import User
|
||||||
@@ -122,6 +122,7 @@ def update_my_profile(
|
|||||||
def change_my_password(
|
def change_my_password(
|
||||||
body: s.PasswordChange,
|
body: s.PasswordChange,
|
||||||
request: Request,
|
request: Request,
|
||||||
|
response: Response,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
) -> s.MeRead:
|
) -> s.MeRead:
|
||||||
@@ -130,6 +131,9 @@ def change_my_password(
|
|||||||
if had_password:
|
if had_password:
|
||||||
check_current_password(user, body.current_password)
|
check_current_password(user, body.current_password)
|
||||||
user_service.set_password(session, user, body.new_password)
|
user_service.set_password(session, user, body.new_password)
|
||||||
|
# set_password инкрементит token_version → все ранее выданные токены отозваны (#57).
|
||||||
|
# Перевыдаём cookie этому устройству со свежим ver, чтобы разлогинить только остальные.
|
||||||
|
set_user_session(response, user.id, user.auth_provider, user.token_version)
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
session,
|
session,
|
||||||
actor_id=user.id,
|
actor_id=user.id,
|
||||||
|
|||||||
@@ -161,9 +161,14 @@ def register_local(session: Session, nickname: str, password: str) -> User:
|
|||||||
|
|
||||||
|
|
||||||
def set_password(session: Session, user: User, new_password: str) -> User:
|
def set_password(session: Session, user: User, new_password: str) -> User:
|
||||||
"""Записать новый пароль. Проверку текущего делает вызывающий (игрок — да, админ — нет)."""
|
"""Записать новый пароль. Проверку текущего делает вызывающий (игрок — да, админ — нет).
|
||||||
|
|
||||||
|
Инкремент token_version отзывает все ранее выданные токены (#57): при смене пароля
|
||||||
|
игроком — все прочие сессии, при сбросе админом — все сессии игрока (в т.ч. злоумышленника).
|
||||||
|
Своё устройство остаётся в сессии, только если вызывающий перевыдаст cookie со свежим ver."""
|
||||||
validate_new_password(new_password)
|
validate_new_password(new_password)
|
||||||
user.password_hash = hash_password(new_password)
|
user.password_hash = hash_password(new_password)
|
||||||
|
user.token_version = (user.token_version or 0) + 1
|
||||||
session.add(user)
|
session.add(user)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(user)
|
session.refresh(user)
|
||||||
|
|||||||
+10
-2
@@ -9,8 +9,16 @@ python -m app.bootstrap
|
|||||||
|
|
||||||
echo "[entrypoint] Запуск сервера…"
|
echo "[entrypoint] Запуск сервера…"
|
||||||
# --proxy-headers + доверие к X-Forwarded-* от реверс-прокси (Caddy на VPS):
|
# --proxy-headers + доверие к X-Forwarded-* от реверс-прокси (Caddy на VPS):
|
||||||
# чтобы приложение знало, что снаружи запрос пришёл по HTTPS.
|
# чтобы приложение знало, что снаружи запрос пришёл по HTTPS и кто реальный клиент.
|
||||||
|
#
|
||||||
|
# forwarded-allow-ips НЕ "*" (#58): при "*" uvicorn брал ЛЕВОЕ значение X-Forwarded-For,
|
||||||
|
# и клиент мог подставить произвольный IP (снятие throttle, порча аудита). Доверяем только
|
||||||
|
# апстримам из приватной сети compose (туннель к Caddy ходит на app:8000) и loopback
|
||||||
|
# (healthcheck) — тогда uvicorn сканирует XFF справа и берёт первый недоверенный адрес,
|
||||||
|
# т.е. реальный, добавленный Caddy. Портов на хост нет, снаружи к :8000 никто не ходит.
|
||||||
|
# Переопределяемо через FORWARDED_ALLOW_IPS, если сеть отличается.
|
||||||
# --timeout-graceful-shutdown: SSE-потоки /api/events сами не закрываются, и без лимита
|
# --timeout-graceful-shutdown: SSE-потоки /api/events сами не закрываются, и без лимита
|
||||||
# остановка ждала бы их до SIGKILL по stop_grace_period (30 с) — без lifespan-shutdown.
|
# остановка ждала бы их до SIGKILL по stop_grace_period (30 с) — без lifespan-shutdown.
|
||||||
|
FORWARDED_ALLOW_IPS="${FORWARDED_ALLOW_IPS:-127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}"
|
||||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1 \
|
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1 \
|
||||||
--proxy-headers --forwarded-allow-ips="*" --timeout-graceful-shutdown 10
|
--proxy-headers --forwarded-allow-ips="$FORWARDED_ALLOW_IPS" --timeout-graceful-shutdown 10
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from sqlmodel import Session, SQLModel, create_engine, select # noqa: E402
|
|||||||
import app.models # noqa: F401,E402 (регистрация моделей)
|
import app.models # noqa: F401,E402 (регистрация моделей)
|
||||||
from app.core.ratelimit import login_throttle # noqa: E402
|
from app.core.ratelimit import login_throttle # noqa: E402
|
||||||
from app.core.security import hash_password # noqa: E402
|
from app.core.security import hash_password # noqa: E402
|
||||||
|
from app.core.token_revocation import revoked_tokens # noqa: E402
|
||||||
from app.db.session import get_session # noqa: E402
|
from app.db.session import get_session # noqa: E402
|
||||||
from app.main import app # noqa: E402
|
from app.main import app # noqa: E402
|
||||||
from app.models import AuthIdentity, GroupMember, User # noqa: E402
|
from app.models import AuthIdentity, GroupMember, User # noqa: E402
|
||||||
@@ -50,6 +51,7 @@ def client(engine):
|
|||||||
|
|
||||||
app.dependency_overrides[get_session] = _get_session
|
app.dependency_overrides[get_session] = _get_session
|
||||||
login_throttle.clear() # счётчики неудачных входов глобальны для процесса
|
login_throttle.clear() # счётчики неудачных входов глобальны для процесса
|
||||||
|
revoked_tokens.clear() # denylist отозванных токенов тоже глобален для процесса
|
||||||
with TestClient(app) as c:
|
with TestClient(app) as c:
|
||||||
yield c
|
yield c
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Хардненинг API: раскрытие схемы закрыто в production, открыто в dev/test (#61, F6)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.core import config
|
||||||
|
from app.main import create_app
|
||||||
|
|
||||||
|
|
||||||
|
def test_openapi_open_in_development(client: TestClient):
|
||||||
|
# Тесты идут в development (conftest) — схема доступна: нужна для `npm run gen:api`.
|
||||||
|
assert client.get("/api/openapi.json").status_code == 200
|
||||||
|
assert client.get("/api/docs").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_openapi_closed_in_production(monkeypatch):
|
||||||
|
monkeypatch.setattr(config.settings, "app_env", "production")
|
||||||
|
prod_app = create_app()
|
||||||
|
c = TestClient(prod_app)
|
||||||
|
assert c.get("/api/openapi.json").status_code == 404
|
||||||
|
assert c.get("/api/docs").status_code == 404
|
||||||
|
assert c.get("/api/redoc").status_code == 404
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Fail-fast конфигурации: production не стартует с дефолтными секретами (#59, F4)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.core import config
|
||||||
|
|
||||||
|
_STRONG_SECRET = "k" * 40
|
||||||
|
_STRONG_ADMIN_PW = "a-strong-admin-password"
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_rejects_default_secret_key():
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
config.Settings(
|
||||||
|
app_env="production",
|
||||||
|
secret_key=config._DEFAULT_SECRET_KEY,
|
||||||
|
admin_password=_STRONG_ADMIN_PW,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_rejects_short_secret_key():
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
config.Settings(
|
||||||
|
app_env="production",
|
||||||
|
secret_key="too-short",
|
||||||
|
admin_password=_STRONG_ADMIN_PW,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_rejects_default_admin_password():
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
config.Settings(
|
||||||
|
app_env="production",
|
||||||
|
secret_key=_STRONG_SECRET,
|
||||||
|
admin_bootstrap_enabled=True,
|
||||||
|
admin_password=config._DEFAULT_ADMIN_PASSWORD,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_accepts_strong_secrets():
|
||||||
|
s = config.Settings(
|
||||||
|
app_env="production",
|
||||||
|
secret_key=_STRONG_SECRET,
|
||||||
|
admin_password=_STRONG_ADMIN_PW,
|
||||||
|
)
|
||||||
|
assert s.is_production
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_skips_admin_check_when_bootstrap_disabled():
|
||||||
|
# Админ управляется вручную (bootstrap off) — дефолтный ADMIN_PASSWORD не блокирует старт.
|
||||||
|
s = config.Settings(
|
||||||
|
app_env="production",
|
||||||
|
secret_key=_STRONG_SECRET,
|
||||||
|
admin_bootstrap_enabled=False,
|
||||||
|
admin_password=config._DEFAULT_ADMIN_PASSWORD,
|
||||||
|
)
|
||||||
|
assert s.is_production
|
||||||
|
|
||||||
|
|
||||||
|
def test_development_allows_defaults():
|
||||||
|
s = config.Settings(
|
||||||
|
app_env="development",
|
||||||
|
secret_key=config._DEFAULT_SECRET_KEY,
|
||||||
|
admin_password=config._DEFAULT_ADMIN_PASSWORD,
|
||||||
|
)
|
||||||
|
assert s.is_development
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
"""Вход по логину (нику) и паролю: регистрация, вход, защита от перебора."""
|
"""Вход по логину (нику) и паролю: регистрация, вход, защита от перебора."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from tests.conftest import csrf_headers
|
from tests.conftest import csrf_headers
|
||||||
from tests.test_auth import _telegram_payload
|
from tests.test_auth import _telegram_payload
|
||||||
@@ -63,6 +65,27 @@ def test_register_rejects_bad_nickname(client: TestClient):
|
|||||||
assert _register(client, nickname="x").status_code == 422
|
assert _register(client, nickname="x").status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_is_throttled_per_ip(client: TestClient, monkeypatch):
|
||||||
|
"""Спам регистраций с одного IP упирается в лимит (#62)."""
|
||||||
|
import app.core.ratelimit as ratelimit
|
||||||
|
from app.auth.password import _REGISTER_IP_LIMIT
|
||||||
|
|
||||||
|
now = [4000.0]
|
||||||
|
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0])
|
||||||
|
|
||||||
|
for i in range(_REGISTER_IP_LIMIT):
|
||||||
|
client.cookies.clear()
|
||||||
|
assert _register(client, nickname=f"Ник{i}").status_code == 200
|
||||||
|
client.cookies.clear()
|
||||||
|
blocked = _register(client, nickname="Лишний")
|
||||||
|
assert blocked.status_code == 429
|
||||||
|
assert blocked.json()["error"]["code"] == "TOO_MANY_ATTEMPTS"
|
||||||
|
|
||||||
|
now[0] += 15 * 60 # окно истекло
|
||||||
|
client.cookies.clear()
|
||||||
|
assert _register(client, nickname="ПослеОкна").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
# ─── Вход ────────────────────────────────────────────────────────────────────
|
# ─── Вход ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_login_after_logout(client: TestClient):
|
def test_login_after_logout(client: TestClient):
|
||||||
@@ -180,6 +203,26 @@ def test_throttle_per_ip_across_logins(client: TestClient):
|
|||||||
assert _login(client, nickname="Ещё один", password="wrong-password").status_code == 429
|
assert _login(client, nickname="Ещё один", password="wrong-password").status_code == 429
|
||||||
|
|
||||||
|
|
||||||
|
def test_account_scoped_throttle_survives_ip_rotation(client: TestClient, engine, monkeypatch):
|
||||||
|
"""Перебор одного логина с РАЗНЫХ адресов (ротация X-Forwarded-For, #58) упирается в
|
||||||
|
IP-независимый лимит на аккаунт (#60): пара IP+логин и лимит по IP так не копятся."""
|
||||||
|
import app.core.ratelimit as ratelimit
|
||||||
|
from app.auth.password import _ACCOUNT_LIMIT, login_player
|
||||||
|
from app.core.errors import InvalidCredentialsError, TooManyAttemptsError
|
||||||
|
|
||||||
|
now = [3000.0]
|
||||||
|
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0])
|
||||||
|
_register(client, nickname="Жертва", password=PASSWORD)
|
||||||
|
|
||||||
|
with Session(engine) as s:
|
||||||
|
for i in range(_ACCOUNT_LIMIT): # каждый раз новый адрес
|
||||||
|
with pytest.raises(InvalidCredentialsError):
|
||||||
|
login_player(s, "Жертва", "wrong-password", ip=f"10.0.{i // 256}.{i % 256}")
|
||||||
|
# ещё одна попытка с совершенно нового адреса — уже блок по лимиту на аккаунт
|
||||||
|
with pytest.raises(TooManyAttemptsError):
|
||||||
|
login_player(s, "Жертва", "wrong-password", ip="203.0.113.7")
|
||||||
|
|
||||||
|
|
||||||
# ─── Установка и смена пароля ────────────────────────────────────────────────
|
# ─── Установка и смена пароля ────────────────────────────────────────────────
|
||||||
|
|
||||||
def _set_password(client: TestClient, new: str, current: str | None = None):
|
def _set_password(client: TestClient, new: str, current: str | None = None):
|
||||||
@@ -251,6 +294,64 @@ def test_set_password_requires_session(client: TestClient):
|
|||||||
assert _set_password(client, PASSWORD).status_code == 401
|
assert _set_password(client, PASSWORD).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Отзыв токена: logout и смена пароля (#57, F2) ────────────────────────────
|
||||||
|
|
||||||
|
def _me_with_token(cookie_name: str, token: str):
|
||||||
|
"""Предъявить конкретный токен вручную (эмуляция «другого устройства»/украденной cookie)."""
|
||||||
|
return TestClient(app).get("/api/users/me", headers={"Cookie": f"{cookie_name}={token}"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_revokes_presented_token(client: TestClient):
|
||||||
|
_register(client)
|
||||||
|
tok = client.cookies.get("fs_session")
|
||||||
|
assert _me_with_token("fs_session", tok).status_code == 200 # пока жив
|
||||||
|
|
||||||
|
assert client.post("/api/auth/logout", headers=csrf_headers(client)).status_code == 200
|
||||||
|
# тот же токен, предъявленный после выхода, больше не принимается
|
||||||
|
assert _me_with_token("fs_session", tok).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_does_not_revoke_other_devices(client: TestClient):
|
||||||
|
_register(client) # устройство A
|
||||||
|
tok_a = client.cookies.get("fs_session")
|
||||||
|
# устройство B: независимый вход тем же аккаунтом (свой jti)
|
||||||
|
b = TestClient(app)
|
||||||
|
assert b.post("/api/auth/login", json={"nickname": "Игрок", "password": PASSWORD}).status_code == 200
|
||||||
|
tok_b = b.cookies.get("fs_session")
|
||||||
|
assert tok_a and tok_b and tok_a != tok_b
|
||||||
|
|
||||||
|
assert client.post("/api/auth/logout", headers=csrf_headers(client)).status_code == 200
|
||||||
|
assert _me_with_token("fs_session", tok_a).status_code == 401 # A вышел
|
||||||
|
assert _me_with_token("fs_session", tok_b).status_code == 200 # B не тронут
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_change_revokes_old_sessions_keeps_current(client: TestClient):
|
||||||
|
_register(client)
|
||||||
|
old = client.cookies.get("fs_session")
|
||||||
|
assert _set_password(client, "new-password-1", current=PASSWORD).status_code == 200
|
||||||
|
# это устройство осталось в сессии (cookie перевыдан со свежим ver)
|
||||||
|
assert client.get("/api/users/me").status_code == 200
|
||||||
|
# старый токен (другое устройство/утёкший) отозван инкрементом token_version
|
||||||
|
assert _me_with_token("fs_session", old).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_password_reset_revokes_player_sessions(client: TestClient, monkeypatch, make_admin):
|
||||||
|
player = _telegram_login(client, monkeypatch).json()
|
||||||
|
stolen = client.cookies.get("fs_session") # действующая сессия игрока
|
||||||
|
assert _me_with_token("fs_session", stolen).status_code == 200
|
||||||
|
|
||||||
|
client.cookies.clear()
|
||||||
|
_admin_login(client, make_admin)
|
||||||
|
r = client.put(
|
||||||
|
f"/api/admin/users/{player['id']}/password",
|
||||||
|
json={"new_password": "from-admin-1"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
# сброс пароля админом обрывает прежние сессии игрока (в т.ч. злоумышленника)
|
||||||
|
assert _me_with_token("fs_session", stolen).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
# ─── Привязка Telegram ───────────────────────────────────────────────────────
|
# ─── Привязка Telegram ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _link_telegram(client: TestClient, monkeypatch, **fields):
|
def _link_telegram(client: TestClient, monkeypatch, **fields):
|
||||||
@@ -358,3 +459,41 @@ def test_player_cannot_set_passwords_via_admin(client: TestClient):
|
|||||||
headers=csrf_headers(client),
|
headers=csrf_headers(client),
|
||||||
)
|
)
|
||||||
assert r.status_code == 401
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Защита от перебора пароля администратора (#56, F1) ───────────────────────
|
||||||
|
|
||||||
|
def test_admin_login_throttled_after_failures(client: TestClient, make_admin, monkeypatch):
|
||||||
|
import app.core.ratelimit as ratelimit
|
||||||
|
|
||||||
|
now = [2000.0]
|
||||||
|
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0])
|
||||||
|
make_admin("boss", "secret123")
|
||||||
|
|
||||||
|
for _ in range(5):
|
||||||
|
r = client.post("/api/admin/auth/login", json={"username": "boss", "password": "nope"})
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
blocked = client.post("/api/admin/auth/login", json={"username": "boss", "password": "secret123"})
|
||||||
|
assert blocked.status_code == 429 # даже верный пароль не проверяется
|
||||||
|
assert blocked.json()["error"]["code"] == "TOO_MANY_ATTEMPTS"
|
||||||
|
|
||||||
|
now[0] += 15 * 60 # окно истекло
|
||||||
|
ok = client.post("/api/admin/auth/login", json={"username": "boss", "password": "secret123"})
|
||||||
|
assert ok.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_admin_login_is_audited_without_password(client: TestClient, make_admin, engine):
|
||||||
|
from app.models import AuditLog
|
||||||
|
|
||||||
|
make_admin("boss", "secret123")
|
||||||
|
assert client.post(
|
||||||
|
"/api/admin/auth/login", json={"username": "boss", "password": "nope-secret-guess"}
|
||||||
|
).status_code == 401
|
||||||
|
|
||||||
|
with Session(engine) as s:
|
||||||
|
logs = s.exec(select(AuditLog).where(AuditLog.action == "login_failed")).all()
|
||||||
|
assert any(
|
||||||
|
log.entity_type == "admin" and (log.payload or {}).get("username") == "boss" for log in logs
|
||||||
|
)
|
||||||
|
assert all("nope-secret-guess" not in str(log.payload) for log in logs)
|
||||||
|
|||||||
+34
-4
@@ -5,8 +5,13 @@
|
|||||||
# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV/TEST
|
# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV/TEST
|
||||||
#
|
#
|
||||||
# Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к
|
# Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к
|
||||||
# приложению добавляет X-Forwarded-Proto=https / X-Forwarded-For / Host —
|
# приложению передаёт X-Forwarded-Proto=https / X-Forwarded-For / Host —
|
||||||
# приложение это учитывает (uvicorn --proxy-headers). Положи файл в /etc/caddy/Caddyfile.
|
# приложение это учитывает (uvicorn --proxy-headers). Положи файл в /etc/caddy/Caddyfile.
|
||||||
|
#
|
||||||
|
# X-Forwarded-For ПЕРЕЗАПИСЫВАЕМ реальным пиром (header_up ... {remote_host}), а не
|
||||||
|
# добавляем: иначе клиент мог бы подставить своё левое значение и подменить IP для
|
||||||
|
# throttle и аудита (#58). Вместе с сужением forwarded-allow-ips в entrypoint.sh это
|
||||||
|
# делает клиентский IP достоверным.
|
||||||
# Сертификаты — см. deploy/vps/README.md (fullchain = leaf + промежуточные одним файлом).
|
# Сертификаты — см. deploy/vps/README.md (fullchain = leaf + промежуточные одним файлом).
|
||||||
#
|
#
|
||||||
# SSE (/api/events): отдельный handle БЕЗ encode и с flush_interval -1 — иначе сжатие/
|
# SSE (/api/events): отдельный handle БЕЗ encode и с flush_interval -1 — иначе сжатие/
|
||||||
@@ -18,8 +23,27 @@
|
|||||||
# иначе браузер отдаёт старый index.html из кэша и до Caddy/заглушки запрос не доходит.
|
# иначе браузер отдаёт старый index.html из кэша и до Caddy/заглушки запрос не доходит.
|
||||||
# Файл заглушки — deploy/vps/maintenance.html.
|
# Файл заглушки — deploy/vps/maintenance.html.
|
||||||
|
|
||||||
# Edge-поведение, общее для сайтов: запрет кэша HTML-документа + заглушка при падении апстрима.
|
# Edge-поведение, общее для сайтов: security-заголовки + запрет кэша HTML + заглушка при падении апстрима.
|
||||||
(edge) {
|
(edge) {
|
||||||
|
# Security-заголовки (#61). HSTS — принудительный HTTPS на год с поддоменами; nosniff —
|
||||||
|
# запрет MIME-sniffing; frame DENY — защита от кликджекинга (наши страницы нельзя встроить
|
||||||
|
# в чужой iframe); Referrer/Permissions — минимизация утечек. Server скрываем, чтобы не
|
||||||
|
# светить используемый прокси.
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "DENY"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
||||||
|
-Server
|
||||||
|
}
|
||||||
|
|
||||||
|
# Content-Security-Policy подготовлена, но ВЫКЛЮЧЕНА до проверки на test-клоне: строгая
|
||||||
|
# политика легко ломает SPA (инлайновые стили Vite), Telegram-виджет входа (скрипт с
|
||||||
|
# telegram.org + iframe oauth.telegram.org) и EventSource (/api/events). Раскомментировать
|
||||||
|
# после проверки на forbidden-stars.ru, что вход и реал-тайм работают (#61).
|
||||||
|
# header Content-Security-Policy "default-src 'self'; script-src 'self' https://telegram.org https://oauth.telegram.org; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-src https://oauth.telegram.org; font-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"
|
||||||
|
|
||||||
# HTML-документ (навигации, Accept: text/html) НЕ кэшируем. Иначе браузер отдаёт старый
|
# HTML-документ (навигации, Accept: text/html) НЕ кэшируем. Иначе браузер отдаёт старый
|
||||||
# SPA из кэша без сетевого запроса → запрос не доходит до Caddy и заглушку не видно.
|
# SPA из кэша без сетевого запроса → запрос не доходит до Caddy и заглушку не видно.
|
||||||
# Хэшированные ассеты (JS/CSS) под это не попадают (у них другой Accept) и кэшируются как обычно.
|
# Хэшированные ассеты (JS/CSS) под это не попадают (у них другой Accept) и кэшируются как обычно.
|
||||||
@@ -46,11 +70,14 @@ forbiddenstars.ru {
|
|||||||
handle @sse {
|
handle @sse {
|
||||||
reverse_proxy 127.0.0.1:9000 {
|
reverse_proxy 127.0.0.1:9000 {
|
||||||
flush_interval -1
|
flush_interval -1
|
||||||
|
header_up X-Forwarded-For {remote_host}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
handle {
|
handle {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
reverse_proxy 127.0.0.1:9000
|
reverse_proxy 127.0.0.1:9000 {
|
||||||
|
header_up X-Forwarded-For {remote_host}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
import edge
|
import edge
|
||||||
}
|
}
|
||||||
@@ -61,11 +88,14 @@ forbidden-stars.ru {
|
|||||||
handle @sse {
|
handle @sse {
|
||||||
reverse_proxy 127.0.0.1:9001 {
|
reverse_proxy 127.0.0.1:9001 {
|
||||||
flush_interval -1
|
flush_interval -1
|
||||||
|
header_up X-Forwarded-For {remote_host}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
handle {
|
handle {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
reverse_proxy 127.0.0.1:9001
|
reverse_proxy 127.0.0.1:9001 {
|
||||||
|
header_up X-Forwarded-For {remote_host}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
import edge
|
import edge
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user