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:
2026-09-13 18:38:11 +03:00
21 changed files with 594 additions and 29 deletions
@@ -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")
+49
View File
@@ -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
+9
View File
@@ -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
+1 -1
View File
@@ -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,
+24 -1
View File
@@ -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:
"""Сменить уже заданный пароль можно только зная текущий, и подбирать его нельзя: """Сменить уже заданный пароль можно только зная текущий, и подбирать его нельзя:
иначе оставленная открытой сессия позволила бы отобрать аккаунт насовсем.""" иначе оставленная открытой сессия позволила бы отобрать аккаунт насовсем."""
+34 -2
View File
@@ -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:
+11 -2
View File
@@ -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
+42 -5
View File
@@ -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 клиента для журнала аудита.
+53
View File
@@ -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
View File
@@ -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,
) )
+7
View File
@@ -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,
+21 -4
View File
@@ -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()
+6 -2
View File
@@ -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()
+6 -2
View File
@@ -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,
+6 -1
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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()
+22
View File
@@ -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
+67
View File
@@ -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
+139
View File
@@ -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
View File
@@ -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
} }