diff --git a/README.md b/README.md index cb6d42a..95cd797 100644 --- a/README.md +++ b/README.md @@ -125,8 +125,10 @@ docker compose up -d # pull_policy: always — тянет свежие туннель-контейнер. FastAPI отдаёт собранный SPA и API с одного origin. Миграции, сидинг справочников и создание админа выполняются автоматически при старте (`entrypoint.sh`). В production закрыты OpenAPI/Swagger, а с дефолтным или коротким `SECRET_KEY` либо -дефолтным `ADMIN_PASSWORD` приложение не стартует. Учтите: `ADMIN_PASSWORD` применяется только -при первом создании админа, дальше его смена в `.env` ни на что не влияет (задача #73). +дефолтным `ADMIN_PASSWORD` приложение не стартует. `ADMIN_PASSWORD` применяется сам только +при первом создании админа; сменить его потом — правкой `.env`, `docker compose up -d` и +`docker compose exec app python -m app.bootstrap --reset-admin-password` (все админские +сессии завершатся; подробно — в `deploy/pi/README.md`). Пошагово — [`deploy/pi/README.md`](deploy/pi/README.md), бэкапы — [`deploy/backup/README.md`](deploy/backup/README.md). - **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно), diff --git a/backend/app/bootstrap.py b/backend/app/bootstrap.py index 62bf9bd..5ca8ae0 100644 --- a/backend/app/bootstrap.py +++ b/backend/app/bootstrap.py @@ -1,9 +1,12 @@ """Идемпотентный бутстрап: справочники + учётная запись администратора. Запуск: `python -m app.bootstrap` (вызывается из entrypoint.sh после миграций). +Ротация пароля админа из .env: `python -m app.bootstrap --reset-admin-password` (#73). """ from __future__ import annotations +import argparse + from sqlmodel import Session, select from app.core.config import settings @@ -11,6 +14,7 @@ from app.core.security import hash_password, verify_password from app.db.session import engine from app.models import User from app.seed.reference_data import seed_reference_data +from app.services import user_service def _ensure_admin(session: Session) -> None: @@ -38,7 +42,9 @@ def _ensure_admin(session: Session) -> None: # Администратор уже существует. if not settings.is_development: - # В prod пароль НЕ перезаписываем (мог быть изменён через панель). + # В prod обычный старт пароль НЕ перезаписывает: смена ADMIN_PASSWORD в .env сама + # по себе ничего не делает — применить её можно только осознанно, командой + # ротации (reset_admin_password), которая заодно отзывает админские сессии. return # DEV: подтягиваем логин/пароль из .env (env — источник истины в деве). @@ -55,11 +61,48 @@ def _ensure_admin(session: Session) -> None: print(f"[bootstrap] DEV: администратор обновлён из .env ({', '.join(changed)})") +def reset_admin_password(session: Session) -> User: + """Записать существующему админу пароль из ADMIN_PASSWORD (#73) — в любом окружении. + + Через панель пароль админа не меняется, а обычный старт в prod его из .env не берёт: + это единственный штатный способ, и он требует доступа к серверу. set_password + проверяет пароль и увеличивает token_version — все выданные админские сессии + (в том числе чужая, если пароль утёк) перестают действовать. Логин не трогаем.""" + if not settings.admin_bootstrap_enabled: + raise RuntimeError( + "ADMIN_BOOTSTRAP_ENABLED=false — администратором управляют вручную, ротация отключена." + ) + password = (settings.admin_password or "").strip() + if not password: + raise RuntimeError("ADMIN_PASSWORD пуст — нечего применять.") + admin = session.exec(select(User).where(User.role == "admin")).first() + if admin is None: + raise RuntimeError("Администратора ещё нет — запустите обычный bootstrap, он его создаст.") + return user_service.set_password(session, admin, password) + + def bootstrap() -> None: with Session(engine) as session: seed_reference_data(session) # идемпотентно _ensure_admin(session) +def main(argv: list[str] | None = None) -> None: + ap = argparse.ArgumentParser(prog="python -m app.bootstrap", description=__doc__) + ap.add_argument( + "--reset-admin-password", + action="store_true", + help="применить ADMIN_PASSWORD из .env к существующему админу и завершить его сессии", + ) + args = ap.parse_args(argv) + if not args.reset_admin_password: + bootstrap() + return + with Session(engine) as session: + admin = reset_admin_password(session) + print(f"[bootstrap] Пароль администратора {admin.nickname} обновлён из .env; " + "все его сессии завершены.") + + if __name__ == "__main__": - bootstrap() + main() diff --git a/backend/app/services/admin_service.py b/backend/app/services/admin_service.py index c445df2..59b5bc9 100644 --- a/backend/app/services/admin_service.py +++ b/backend/app/services/admin_service.py @@ -63,8 +63,9 @@ def update_user(session: Session, user_id: int, *, nickname: str | None = None, def set_player_password(session: Session, user_id: int, new_password: str) -> User: - """Новый пароль игроку (восстановление забытого). Пароль админа так не меняется — - он задаётся ADMIN_PASSWORD в .env.""" + """Новый пароль игроку (восстановление забытого). Пароль админа через панель не + меняется: он задаётся ADMIN_PASSWORD в .env и применяется к существующему админу + командой `python -m app.bootstrap --reset-admin-password` (#73).""" user = session.get(User, user_id) if user is None: raise NotFoundError("Пользователь не найден.") diff --git a/backend/tests/test_admin_password_reset.py b/backend/tests/test_admin_password_reset.py new file mode 100644 index 0000000..e36e186 --- /dev/null +++ b/backend/tests/test_admin_password_reset.py @@ -0,0 +1,77 @@ +"""Ротация пароля администратора из .env (#73): `python -m app.bootstrap --reset-admin-password`.""" +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session, select + +from app import bootstrap +from app.core.security import verify_password +from app.models import User +from tests.conftest import csrf_headers + +OLD, NEW = "secret123", "brand-new-admin-pw" + + +def _admin(engine) -> User: + with Session(engine) as s: + return s.exec(select(User).where(User.role == "admin")).one() + + +def _login(client: TestClient, password: str): + return client.post( + "/api/admin/auth/login", + json={"username": "admin", "password": password}, + headers=csrf_headers(client), + ) + + +def test_reset_applies_env_password_and_revokes_sessions( + client: TestClient, engine, make_admin, monkeypatch +): + make_admin("admin", OLD) + assert _login(client, OLD).status_code == 200 + assert client.get("/api/admin/me").status_code == 200 + version = _admin(engine).token_version + + monkeypatch.setattr(bootstrap.settings, "admin_password", NEW) + with Session(engine) as s: + bootstrap.reset_admin_password(s) + + admin = _admin(engine) + assert verify_password(NEW, admin.password_hash) + assert admin.token_version == version + 1 + # Прежняя админская cookie больше не действует (в т.ч. у того, кто украл пароль). + assert client.get("/api/admin/me").status_code == 401 + assert _login(client, OLD).status_code == 401 + assert _login(client, NEW).status_code == 200 + + +def test_regular_bootstrap_keeps_admin_password_outside_dev(engine, make_admin, monkeypatch): + """Обычный старт в prod по-прежнему не берёт пароль из .env — только ротация.""" + make_admin("admin", OLD) + monkeypatch.setattr(bootstrap.settings, "app_env", "production") + monkeypatch.setattr(bootstrap.settings, "admin_password", NEW) + with Session(engine) as s: + bootstrap._ensure_admin(s) + assert verify_password(OLD, _admin(engine).password_hash) + + +def test_reset_refuses_without_admin_or_password(engine, make_admin, monkeypatch): + monkeypatch.setattr(bootstrap.settings, "admin_password", NEW) + with Session(engine) as s, pytest.raises(RuntimeError, match="Администратора ещё нет"): + bootstrap.reset_admin_password(s) + + make_admin("admin", OLD) + monkeypatch.setattr(bootstrap.settings, "admin_password", " ") + with Session(engine) as s, pytest.raises(RuntimeError, match="ADMIN_PASSWORD пуст"): + bootstrap.reset_admin_password(s) + assert verify_password(OLD, _admin(engine).password_hash) + + +def test_reset_refuses_when_bootstrap_disabled(engine, make_admin, monkeypatch): + make_admin("admin", OLD) + monkeypatch.setattr(bootstrap.settings, "admin_bootstrap_enabled", False) + monkeypatch.setattr(bootstrap.settings, "admin_password", NEW) + with Session(engine) as s, pytest.raises(RuntimeError, match="ADMIN_BOOTSTRAP_ENABLED"): + bootstrap.reset_admin_password(s) diff --git a/deploy/pi/README.md b/deploy/pi/README.md index 3b97139..7184e2b 100644 --- a/deploy/pi/README.md +++ b/deploy/pi/README.md @@ -92,9 +92,18 @@ IMAGE_TAG=latest > В production приложение **не стартует**, если `SECRET_KEY` дефолтный или короче 32 > символов, а `ADMIN_PASSWORD` пустой или дефолтный (при `ADMIN_BOOTSTRAP_ENABLED=true`). -> `ADMIN_USERNAME`/`ADMIN_PASSWORD` применяются **только при первом создании** админа: если -> потом поменять их в `.env`, у существующего админа ничего не изменится, а штатной смены -> пароля админа на проде пока нет (задача #73). +> `ADMIN_USERNAME`/`ADMIN_PASSWORD` применяются автоматически **только при первом создании** +> админа: если потом поменять их в `.env`, у существующего админа само ничего не изменится. +> +> **Смена пароля админа** (плановая или при утечке) — осознанной командой, через панель +> его не сменить: +> 1. поменять `ADMIN_PASSWORD` в `.env` на Pi; +> 2. `docker compose up -d` — пересоздаст `app` с новым `.env` (контейнер читает `.env` +> только при создании; без этого шага команда ниже увидит старый пароль); +> 3. `docker compose exec app python -m app.bootstrap --reset-admin-password`. +> +> Все админские сессии, в том числе чужие, если пароль утёк, после этого завершаются. +> Логин (`ADMIN_USERNAME`) команда не меняет. ## 4. Запуск ```bash