Хардненинг по итогам пен-теста #25 (#56–#62) #63
@@ -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)
|
||||
except jwt.PyJWTError as exc: # noqa: F841
|
||||
raise AuthError("Сессия недействительна.")
|
||||
if security.is_session_revoked(payload):
|
||||
raise AuthError("Сессия недействительна.")
|
||||
user = session.get(User, int(payload["sub"]))
|
||||
if user is None or not user.is_active:
|
||||
raise AuthError("Сессия недействительна.")
|
||||
if int(payload.get("ver", 0)) != int(user.token_version or 0):
|
||||
# Пароль сменён/сброшен после выдачи токена — прежние сессии отозваны (#57).
|
||||
raise AuthError("Сессия недействительна.")
|
||||
return user
|
||||
|
||||
|
||||
@@ -37,7 +42,11 @@ def get_current_admin(
|
||||
payload = security.decode_token(token, security.AUDIENCE_ADMIN)
|
||||
except jwt.PyJWTError:
|
||||
raise AuthError("Сессия администратора недействительна.")
|
||||
if security.is_session_revoked(payload):
|
||||
raise AuthError("Сессия администратора недействительна.")
|
||||
user = session.get(User, int(payload["sub"]))
|
||||
if user is None or user.role != "admin" or not user.is_active:
|
||||
raise ForbiddenError("Доступ только для администратора.")
|
||||
if int(payload.get("ver", 0)) != int(user.token_version or 0):
|
||||
raise AuthError("Сессия администратора недействительна.")
|
||||
return user
|
||||
|
||||
@@ -22,7 +22,7 @@ def establish_session(
|
||||
"""Открыть сессию уже найденному/созданному пользователю (cookie + аудит)."""
|
||||
if not user.is_active:
|
||||
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(
|
||||
session,
|
||||
actor_id=user.id,
|
||||
|
||||
@@ -27,8 +27,15 @@ _BCRYPT_MAX_BYTES = 72
|
||||
# одного аккаунта; на IP — против перебора по многим логинам с одного адреса.
|
||||
_PAIR_LIMIT = 5
|
||||
_IP_LIMIT = 20
|
||||
# На сам аккаунт (IP-независимо, #60): распределённый перебор с ротацией IP всё равно
|
||||
# упирается в этот предел. Щедрее пары, чтобы поток ошибок с разных адресов не запирал
|
||||
# вход настоящему владельцу (лимит на аккаунт — вектор lockout-DoS, потому не строгий);
|
||||
# успешный вход его сбрасывает.
|
||||
_ACCOUNT_LIMIT = 50
|
||||
# Неверный текущий пароль при смене — на аккаунт.
|
||||
_CURRENT_PASSWORD_LIMIT = 5
|
||||
# Регистраций с одного IP за окно — против спама аккаунтов (#62).
|
||||
_REGISTER_IP_LIMIT = 10
|
||||
|
||||
|
||||
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()
|
||||
ip = ip or "unknown"
|
||||
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)
|
||||
try:
|
||||
user = authenticate_player(session, nickname, password)
|
||||
except InvalidCredentialsError:
|
||||
login_throttle.fail(limits)
|
||||
raise
|
||||
# Успех снимает счётчики этого аккаунта (пара IP+логин и лимит на аккаунт); лимит по IP
|
||||
# оставляем — он общий для всех логинов с адреса.
|
||||
login_throttle.reset(pair_key)
|
||||
login_throttle.reset(account_key)
|
||||
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:
|
||||
"""Сменить уже заданный пароль можно только зная текущий, и подбирать его нельзя:
|
||||
иначе оставленная открытой сессия позволила бы отобрать аккаунт насовсем."""
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково
|
||||
@@ -11,6 +12,12 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
# (исключён из образа) — там настройки приходят переменными от docker compose.
|
||||
_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):
|
||||
model_config = SettingsConfigDict(
|
||||
@@ -50,7 +57,7 @@ class Settings(BaseSettings):
|
||||
prod_achievements_dir: str = "/data/achievements"
|
||||
|
||||
# JWT / cookie
|
||||
secret_key: str = "change-me-dev-secret-not-for-production"
|
||||
secret_key: str = _DEFAULT_SECRET_KEY
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_user_ttl_minutes: int = 60 * 24 * 7
|
||||
jwt_admin_ttl_minutes: int = 60 * 8
|
||||
@@ -66,7 +73,7 @@ class Settings(BaseSettings):
|
||||
# Бутстрап администратора
|
||||
admin_bootstrap_enabled: bool = True
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = "change-me-admin-password"
|
||||
admin_password: str = _DEFAULT_ADMIN_PASSWORD
|
||||
admin_nickname: str = "Администратор"
|
||||
|
||||
# CORS (для раздельного dev-режима фронта). Строка из env, через запятую —
|
||||
@@ -115,6 +122,31 @@ class Settings(BaseSettings):
|
||||
def cookie_domain_value(self) -> str | 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
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
"""Ограничение неудачных попыток ввода пароля (защита от перебора).
|
||||
|
||||
Счётчики живут в памяти процесса — как и SSE-шина, это рассчитано на один воркер uvicorn.
|
||||
Перезапуск их обнуляет; для окна в 15 минут это приемлемо.
|
||||
Счётчики живут в памяти процесса — как и SSE-шина, это рассчитано на один воркер uvicorn
|
||||
(`--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
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import jwt
|
||||
from fastapi import Request, Response
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.token_revocation import revoked_tokens
|
||||
|
||||
USER_COOKIE = "fs_session"
|
||||
ADMIN_COOKIE = "fs_admin"
|
||||
@@ -37,7 +38,13 @@ def verify_password(password: str, password_hash: str) -> bool:
|
||||
|
||||
# ─── 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)
|
||||
payload = {
|
||||
"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()),
|
||||
"jti": secrets.token_hex(8),
|
||||
"provider": provider,
|
||||
# Версия сессий владельца: при её росте (смена/сброс пароля) старые токены
|
||||
# с меньшим `ver` отклоняются в auth/deps — отзыв всех прежних сессий (#57).
|
||||
"ver": token_version,
|
||||
}
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
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
|
||||
token = create_token(user_id, AUDIENCE_USER, ttl, provider)
|
||||
token = create_token(user_id, AUDIENCE_USER, ttl, provider, token_version)
|
||||
response.set_cookie(
|
||||
key=USER_COOKIE,
|
||||
value=token,
|
||||
@@ -111,9 +121,9 @@ def set_user_session(response: Response, user_id: int, provider: str) -> None:
|
||||
_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
|
||||
token = create_token(admin_id, AUDIENCE_ADMIN, ttl, "local")
|
||||
token = create_token(admin_id, AUDIENCE_ADMIN, ttl, "local", token_version)
|
||||
response.set_cookie(
|
||||
key=ADMIN_COOKIE,
|
||||
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)
|
||||
|
||||
|
||||
def revoke_session_token(request: Request, cookie_name: str, audience: str) -> None:
|
||||
"""Отзывает предъявленный в cookie токен (по `jti`) до его `exp` — точечный logout.
|
||||
|
||||
Убивает именно этот токен (украденный/оставленный), не трогая другие устройства.
|
||||
Некорректный/просроченный токен отзывать нечего — молча выходим."""
|
||||
token = request.cookies.get(cookie_name)
|
||||
if not token:
|
||||
return
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.secret_key,
|
||||
algorithms=[settings.jwt_algorithm],
|
||||
audience=audience,
|
||||
)
|
||||
except jwt.PyJWTError:
|
||||
return
|
||||
exp = payload.get("exp")
|
||||
if exp is not None:
|
||||
revoked_tokens.revoke(payload.get("jti"), float(exp))
|
||||
|
||||
|
||||
def is_session_revoked(payload: dict) -> bool:
|
||||
"""Отозван ли этот токен точечно (через logout)."""
|
||||
return revoked_tokens.is_revoked(payload.get("jti"))
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str | None:
|
||||
"""IP клиента для журнала аудита.
|
||||
|
||||
|
||||
@@ -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:
|
||||
# Схему API (openapi.json + Swagger/ReDoc) отдаём только в dev/test: она нужна для
|
||||
# `npm run gen:api` (генерация типов фронта) и удобной отладки. В production закрываем —
|
||||
# незачем облегчать разведку поверхности API анонимам (#61).
|
||||
docs_enabled = not settings.is_production
|
||||
app = FastAPI(
|
||||
title="Forbidden Stars API",
|
||||
version="0.1.0",
|
||||
openapi_url="/api/openapi.json",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
openapi_url="/api/openapi.json" if docs_enabled else None,
|
||||
docs_url="/api/docs" if docs_enabled else None,
|
||||
redoc_url="/api/redoc" if docs_enabled else None,
|
||||
lifespan=_lifespan,
|
||||
)
|
||||
|
||||
|
||||
@@ -93,6 +93,13 @@ class User(SQLModel, table=True):
|
||||
sa_column=Column(String(16), nullable=False, server_default="stub"),
|
||||
)
|
||||
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(
|
||||
sa_column=Column(
|
||||
Integer,
|
||||
|
||||
@@ -5,10 +5,11 @@ from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFi
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.admin_login import login_admin
|
||||
from app.auth.deps import get_current_admin
|
||||
from app.core import security
|
||||
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.db.session import get_session
|
||||
from app.models import User
|
||||
@@ -40,8 +41,23 @@ def admin_login(
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
) -> s.AdminMe:
|
||||
admin = admin_service.authenticate_admin(session, body.username, body.password)
|
||||
security.set_admin_session(response, admin.id) # type: ignore[arg-type]
|
||||
try:
|
||||
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(
|
||||
session,
|
||||
actor_id=admin.id,
|
||||
@@ -56,7 +72,8 @@ def admin_login(
|
||||
|
||||
|
||||
@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)
|
||||
return s.OkResponse()
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, Request, Response
|
||||
from sqlmodel import 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.telegram import TelegramProvider
|
||||
from app.core import security
|
||||
@@ -39,6 +39,7 @@ def password_register(
|
||||
session: Session = Depends(get_session),
|
||||
) -> s.MeRead:
|
||||
"""Регистрация по логину (нику) и паролю. Telegram привязывается позже в профиле."""
|
||||
throttle_register(client_ip(request)) # против спама аккаунтов с одного IP (#62)
|
||||
user = user_service.register_local(session, body.nickname, body.password)
|
||||
audit_service.record(
|
||||
session,
|
||||
@@ -116,6 +117,9 @@ def telegram_register(
|
||||
|
||||
|
||||
@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)
|
||||
return s.OkResponse()
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"""Роутер текущего пользователя."""
|
||||
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 sqlmodel import Session
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.auth.password import check_current_password
|
||||
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.db.session import get_session
|
||||
from app.models import User
|
||||
@@ -122,6 +122,7 @@ def update_my_profile(
|
||||
def change_my_password(
|
||||
body: s.PasswordChange,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.MeRead:
|
||||
@@ -130,6 +131,9 @@ def change_my_password(
|
||||
if had_password:
|
||||
check_current_password(user, body.current_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(
|
||||
session,
|
||||
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:
|
||||
"""Записать новый пароль. Проверку текущего делает вызывающий (игрок — да, админ — нет)."""
|
||||
"""Записать новый пароль. Проверку текущего делает вызывающий (игрок — да, админ — нет).
|
||||
|
||||
Инкремент token_version отзывает все ранее выданные токены (#57): при смене пароля
|
||||
игроком — все прочие сессии, при сбросе админом — все сессии игрока (в т.ч. злоумышленника).
|
||||
Своё устройство остаётся в сессии, только если вызывающий перевыдаст cookie со свежим ver."""
|
||||
validate_new_password(new_password)
|
||||
user.password_hash = hash_password(new_password)
|
||||
user.token_version = (user.token_version or 0) + 1
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
|
||||
+10
-2
@@ -9,8 +9,16 @@ python -m app.bootstrap
|
||||
|
||||
echo "[entrypoint] Запуск сервера…"
|
||||
# --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 сами не закрываются, и без лимита
|
||||
# остановка ждала бы их до 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 \
|
||||
--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 (регистрация моделей)
|
||||
from app.core.ratelimit import login_throttle # 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.main import app # 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
|
||||
login_throttle.clear() # счётчики неудачных входов глобальны для процесса
|
||||
revoked_tokens.clear() # denylist отозванных токенов тоже глобален для процесса
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
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
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.main import app
|
||||
from app.models import User
|
||||
from tests.conftest import csrf_headers
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
@@ -180,6 +203,26 @@ def test_throttle_per_ip_across_logins(client: TestClient):
|
||||
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):
|
||||
@@ -251,6 +294,64 @@ def test_set_password_requires_session(client: TestClient):
|
||||
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 ───────────────────────────────────────────────────────
|
||||
|
||||
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),
|
||||
)
|
||||
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
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# X-Forwarded-For ПЕРЕЗАПИСЫВАЕМ реальным пиром (header_up ... {remote_host}), а не
|
||||
# добавляем: иначе клиент мог бы подставить своё левое значение и подменить IP для
|
||||
# throttle и аудита (#58). Вместе с сужением forwarded-allow-ips в entrypoint.sh это
|
||||
# делает клиентский IP достоверным.
|
||||
# Сертификаты — см. deploy/vps/README.md (fullchain = leaf + промежуточные одним файлом).
|
||||
#
|
||||
# SSE (/api/events): отдельный handle БЕЗ encode и с flush_interval -1 — иначе сжатие/
|
||||
@@ -18,8 +23,27 @@
|
||||
# иначе браузер отдаёт старый index.html из кэша и до Caddy/заглушки запрос не доходит.
|
||||
# Файл заглушки — deploy/vps/maintenance.html.
|
||||
|
||||
# Edge-поведение, общее для сайтов: запрет кэша HTML-документа + заглушка при падении апстрима.
|
||||
# Edge-поведение, общее для сайтов: security-заголовки + запрет кэша HTML + заглушка при падении апстрима.
|
||||
(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) НЕ кэшируем. Иначе браузер отдаёт старый
|
||||
# SPA из кэша без сетевого запроса → запрос не доходит до Caddy и заглушку не видно.
|
||||
# Хэшированные ассеты (JS/CSS) под это не попадают (у них другой Accept) и кэшируются как обычно.
|
||||
@@ -46,11 +70,14 @@ forbiddenstars.ru {
|
||||
handle @sse {
|
||||
reverse_proxy 127.0.0.1:9000 {
|
||||
flush_interval -1
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
}
|
||||
}
|
||||
handle {
|
||||
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
|
||||
}
|
||||
@@ -61,11 +88,14 @@ forbidden-stars.ru {
|
||||
handle @sse {
|
||||
reverse_proxy 127.0.0.1:9001 {
|
||||
flush_interval -1
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
}
|
||||
}
|
||||
handle {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user