Безопасность: команда ротации пароля администратора из .env
В prod пароль админа задавался только при первом создании: обычный bootstrap его из .env не берёт, панель не меняет — комментарии отсылали друг к другу. Теперь `python -m app.bootstrap --reset-admin-password` применяет ADMIN_PASSWORD к существующему админу через user_service.set_password: пароль проверяется, token_version растёт, и все админские сессии (в том числе чужие) отзываются. Обычный старт по-прежнему пароль не трогает. Процедура на Pi — в deploy/pi/README.md (правка .env → docker compose up -d → exec команды), кратко — в README. #73 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
This commit is contained in:
@@ -125,8 +125,10 @@ docker compose up -d # pull_policy: always — тянет свежие
|
|||||||
туннель-контейнер. FastAPI отдаёт собранный SPA и API с одного origin. Миграции, сидинг
|
туннель-контейнер. FastAPI отдаёт собранный SPA и API с одного origin. Миграции, сидинг
|
||||||
справочников и создание админа выполняются автоматически при старте (`entrypoint.sh`).
|
справочников и создание админа выполняются автоматически при старте (`entrypoint.sh`).
|
||||||
В production закрыты OpenAPI/Swagger, а с дефолтным или коротким `SECRET_KEY` либо
|
В production закрыты OpenAPI/Swagger, а с дефолтным или коротким `SECRET_KEY` либо
|
||||||
дефолтным `ADMIN_PASSWORD` приложение не стартует. Учтите: `ADMIN_PASSWORD` применяется только
|
дефолтным `ADMIN_PASSWORD` приложение не стартует. `ADMIN_PASSWORD` применяется сам только
|
||||||
при первом создании админа, дальше его смена в `.env` ни на что не влияет (задача #73).
|
при первом создании админа; сменить его потом — правкой `.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).
|
Пошагово — [`deploy/pi/README.md`](deploy/pi/README.md), бэкапы — [`deploy/backup/README.md`](deploy/backup/README.md).
|
||||||
|
|
||||||
- **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно),
|
- **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно),
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
"""Идемпотентный бутстрап: справочники + учётная запись администратора.
|
"""Идемпотентный бутстрап: справочники + учётная запись администратора.
|
||||||
|
|
||||||
Запуск: `python -m app.bootstrap` (вызывается из entrypoint.sh после миграций).
|
Запуск: `python -m app.bootstrap` (вызывается из entrypoint.sh после миграций).
|
||||||
|
Ротация пароля админа из .env: `python -m app.bootstrap --reset-admin-password` (#73).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.core.config import settings
|
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.db.session import engine
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.seed.reference_data import seed_reference_data
|
from app.seed.reference_data import seed_reference_data
|
||||||
|
from app.services import user_service
|
||||||
|
|
||||||
|
|
||||||
def _ensure_admin(session: Session) -> None:
|
def _ensure_admin(session: Session) -> None:
|
||||||
@@ -38,7 +42,9 @@ def _ensure_admin(session: Session) -> None:
|
|||||||
|
|
||||||
# Администратор уже существует.
|
# Администратор уже существует.
|
||||||
if not settings.is_development:
|
if not settings.is_development:
|
||||||
# В prod пароль НЕ перезаписываем (мог быть изменён через панель).
|
# В prod обычный старт пароль НЕ перезаписывает: смена ADMIN_PASSWORD в .env сама
|
||||||
|
# по себе ничего не делает — применить её можно только осознанно, командой
|
||||||
|
# ротации (reset_admin_password), которая заодно отзывает админские сессии.
|
||||||
return
|
return
|
||||||
|
|
||||||
# DEV: подтягиваем логин/пароль из .env (env — источник истины в деве).
|
# DEV: подтягиваем логин/пароль из .env (env — источник истины в деве).
|
||||||
@@ -55,11 +61,48 @@ def _ensure_admin(session: Session) -> None:
|
|||||||
print(f"[bootstrap] DEV: администратор обновлён из .env ({', '.join(changed)})")
|
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:
|
def bootstrap() -> None:
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
seed_reference_data(session) # идемпотентно
|
seed_reference_data(session) # идемпотентно
|
||||||
_ensure_admin(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__":
|
if __name__ == "__main__":
|
||||||
bootstrap()
|
main()
|
||||||
|
|||||||
@@ -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:
|
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)
|
user = session.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise NotFoundError("Пользователь не найден.")
|
raise NotFoundError("Пользователь не найден.")
|
||||||
|
|||||||
@@ -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)
|
||||||
+12
-3
@@ -92,9 +92,18 @@ IMAGE_TAG=latest
|
|||||||
|
|
||||||
> В production приложение **не стартует**, если `SECRET_KEY` дефолтный или короче 32
|
> В production приложение **не стартует**, если `SECRET_KEY` дефолтный или короче 32
|
||||||
> символов, а `ADMIN_PASSWORD` пустой или дефолтный (при `ADMIN_BOOTSTRAP_ENABLED=true`).
|
> символов, а `ADMIN_PASSWORD` пустой или дефолтный (при `ADMIN_BOOTSTRAP_ENABLED=true`).
|
||||||
> `ADMIN_USERNAME`/`ADMIN_PASSWORD` применяются **только при первом создании** админа: если
|
> `ADMIN_USERNAME`/`ADMIN_PASSWORD` применяются автоматически **только при первом создании**
|
||||||
> потом поменять их в `.env`, у существующего админа ничего не изменится, а штатной смены
|
> админа: если потом поменять их в `.env`, у существующего админа само ничего не изменится.
|
||||||
> пароля админа на проде пока нет (задача #73).
|
>
|
||||||
|
> **Смена пароля админа** (плановая или при утечке) — осознанной командой, через панель
|
||||||
|
> его не сменить:
|
||||||
|
> 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. Запуск
|
## 4. Запуск
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Reference in New Issue
Block a user