Persist runtime config in app db and seed from env

This commit is contained in:
2026-04-20 15:56:10 +02:00
parent 3f7c9e43d9
commit 179aae264e
21 changed files with 921 additions and 125 deletions
+28 -2
View File
@@ -58,6 +58,7 @@ Notion 在 Go 版本中仍然存在,但已被明确视为 legacy / removed sco
- 单个 admin 用户 - 单个 admin 用户
- server-side session - server-side session
- runtime config 持久化
这部分现在也使用 Alembic 管理: 这部分现在也使用 Alembic 管理:
@@ -169,8 +170,8 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- 认证模型:`username/password` - 认证模型:`username/password`
- 会话模型:server-side session + cookie - 会话模型:server-side session + cookie
- 当前受保护页面:`/admin` - 当前主要受保护页面:`/config`
- 当前公开页面:`/``/login` - 当前公开页面:`/login`
- 当前公开 API:现有业务 API 暂未在这一轮统一收口到 auth 下 - 当前公开 API:现有业务 API 暂未在这一轮统一收口到 auth 下
安全实现的当前边界: 安全实现的当前边界:
@@ -193,6 +194,31 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
首次登录后会被要求立即修改密码。这个 bootstrap 只用于首个用户落库,不是后续的完整配置管理方案。 首次登录后会被要求立即修改密码。这个 bootstrap 只用于首个用户落库,不是后续的完整配置管理方案。
当前前端已经收敛为两条主路径:
- `/login`
- `/config`
无论是本地 `host:port` 还是反向代理后的域名访问,登录成功后都使用相对路径跳转到 `/config`
## Config 持久化
当前 config 页面已经不再把修改写回 `.env`
当前原则是:
- `.env` 只负责 bootstrap / fallback
- app 启动先从 `.env` 读取数据库地址等基础配置
- 请求期读取配置时,优先使用 app DB 中的 `app_config`
- 如果数据库里没有对应值,再 fallback 到 `.env`
这意味着:
- location / poo / app DB 地址仍然属于 bootstrap 范畴
- 运行时可编辑配置主要通过 `app_config` 表持久化
- token / secret 这类运行时必须可取回的配置,目前允许明文存储在 config 表中
- 登录密码仍然单独使用 Argon2 哈希,不走 config 表明文存储
## 运行测试 ## 运行测试
```bash ```bash
+1
View File
@@ -5,6 +5,7 @@ from sqlalchemy import engine_from_config, pool
from app.auth_db import AuthBase from app.auth_db import AuthBase
from app.config import get_settings from app.config import get_settings
from app.models.config import AppConfigEntry # noqa: F401
from app.models.auth import AuthSession, AuthUser # noqa: F401 from app.models.auth import AuthSession, AuthUser # noqa: F401
config = context.config config = context.config
@@ -0,0 +1,34 @@
"""app config table
Revision ID: 20260420_04_app_config_table
Revises: 20260420_03_app_auth_baseline
Create Date: 2026-04-20 00:00:01.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260420_04_app_config_table"
down_revision: Union[str, None] = "20260420_03_app_auth_baseline"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"app_config",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("key", sa.String(length=255), nullable=False),
sa.Column("value", sa.String(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_app_config_key"), "app_config", ["key"], unique=True)
def downgrade() -> None:
op.drop_index(op.f("ix_app_config_key"), table_name="app_config")
op.drop_table("app_config")
+15 -8
View File
@@ -18,6 +18,7 @@ from app.services.auth import (
revoke_session, revoke_session,
validate_csrf_token, validate_csrf_token,
) )
from app.services.config_page import build_config_sections
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates")) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
@@ -33,7 +34,7 @@ def login_page(
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session), current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> Response: ) -> Response:
if current_auth is not None: if current_auth is not None:
return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER) return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
csrf_token = issue_login_csrf_token() csrf_token = issue_login_csrf_token()
response = templates.TemplateResponse( response = templates.TemplateResponse(
@@ -79,7 +80,7 @@ def login_submit(
) )
auth_session, raw_token = create_session(session, user=user, settings=settings) auth_session, raw_token = create_session(session, user=user, settings=settings)
response = RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER) response = RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(LOGIN_CSRF_COOKIE_NAME, path="/login") response.delete_cookie(LOGIN_CSRF_COOKIE_NAME, path="/login")
response.set_cookie( response.set_cookie(
key=settings.auth_session_cookie_name, key=settings.auth_session_cookie_name,
@@ -94,7 +95,7 @@ def login_submit(
return response return response
@router.post("/admin/change-password", response_class=HTMLResponse) @router.post("/config/change-password", response_class=HTMLResponse)
def change_password_submit( def change_password_submit(
request: Request, request: Request,
current_password: str = Form(), current_password: str = Form(),
@@ -110,9 +111,10 @@ def change_password_submit(
if not validate_csrf_token(expected=current_auth.session.csrf_token, actual=csrf_token): if not validate_csrf_token(expected=current_auth.session.csrf_token, actual=csrf_token):
logger.warning("Rejected password change attempt due to CSRF validation failure") logger.warning("Rejected password change attempt due to CSRF validation failure")
return _render_admin_page( return _render_config_page(
request, request,
settings=settings, settings=settings,
auth_db_session=session,
current_auth=current_auth, current_auth=current_auth,
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
password_change_error="invalid password change request", password_change_error="invalid password change request",
@@ -132,16 +134,17 @@ def change_password_submit(
current_auth.user.username, current_auth.user.username,
exc, exc,
) )
return _render_admin_page( return _render_config_page(
request, request,
settings=settings, settings=settings,
auth_db_session=session,
current_auth=current_auth, current_auth=current_auth,
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
password_change_error="password change failed", password_change_error="password change failed",
) )
logger.info("Password updated for user '%s'", current_auth.user.username) logger.info("Password updated for user '%s'", current_auth.user.username)
return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER) return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/logout") @router.post("/logout")
@@ -200,17 +203,18 @@ def _set_login_csrf_cookie(response: HTMLResponse, *, settings: Settings, token:
) )
def _render_admin_page( def _render_config_page(
request: Request, request: Request,
*, *,
settings: Settings, settings: Settings,
auth_db_session: Session,
current_auth: AuthenticatedSession, current_auth: AuthenticatedSession,
status_code: int, status_code: int,
password_change_error: str | None, password_change_error: str | None,
) -> HTMLResponse: ) -> HTMLResponse:
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"admin.html", "config.html",
{ {
"app_name": settings.app_name, "app_name": settings.app_name,
"app_env": settings.app_env, "app_env": settings.app_env,
@@ -218,6 +222,9 @@ def _render_admin_page(
"csrf_token": current_auth.session.csrf_token, "csrf_token": current_auth.session.csrf_token,
"force_password_change": current_auth.user.force_password_change, "force_password_change": current_auth.user.force_password_change,
"password_change_error": password_change_error, "password_change_error": password_change_error,
"config_error": None,
"config_saved": False,
"config_sections": build_config_sections(auth_db_session, settings),
}, },
status_code=status_code, status_code=status_code,
) )
+87 -11
View File
@@ -1,30 +1,45 @@
import logging
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, Request, status from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse, Response from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from app.config import Settings from app.config import Settings, get_settings
from app.dependencies import get_app_settings, get_current_auth_session from app.dependencies import get_app_settings, get_auth_db, get_current_auth_session
from app.services.auth import AuthenticatedSession from app.services.auth import AuthenticatedSession
from app.services.config_page import ConfigSaveError, build_config_sections, save_config_updates
from sqlalchemy.orm import Session
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates")) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
router = APIRouter(tags=["pages"]) router = APIRouter(tags=["pages"])
logger = logging.getLogger(__name__)
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
def home(request: Request, settings: Settings = Depends(get_app_settings)) -> HTMLResponse: def home(
context = { request: Request,
"app_name": settings.app_name, current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
"app_env": settings.app_env, ) -> RedirectResponse:
"notion_status": "Legacy scope, removed from the Python rewrite target.", if current_auth is None:
} return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
return templates.TemplateResponse(request, "home.html", context) return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/admin", response_class=HTMLResponse) @router.get("/admin", response_class=HTMLResponse)
def admin_page( def admin_redirect(
request: Request, request: Request,
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> RedirectResponse:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/config", response_class=HTMLResponse)
def config_page(
request: Request,
auth_db_session: Session = Depends(get_auth_db),
settings: Settings = Depends(get_app_settings), settings: Settings = Depends(get_app_settings),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session), current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> Response: ) -> Response:
@@ -38,5 +53,66 @@ def admin_page(
"csrf_token": current_auth.session.csrf_token, "csrf_token": current_auth.session.csrf_token,
"force_password_change": current_auth.user.force_password_change, "force_password_change": current_auth.user.force_password_change,
"password_change_error": None, "password_change_error": None,
"config_error": None,
"config_saved": request.query_params.get("saved") == "1",
"config_sections": build_config_sections(auth_db_session, settings),
} }
return templates.TemplateResponse(request, "admin.html", context) return templates.TemplateResponse(request, "config.html", context)
@router.post("/config", response_class=HTMLResponse)
async def config_submit(
request: Request,
auth_db_session: Session = Depends(get_auth_db),
settings: Settings = Depends(get_app_settings),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> Response:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
form = await request.form()
csrf_token = form.get("csrf_token")
if csrf_token != current_auth.session.csrf_token:
logger.warning("Rejected config update due to CSRF validation failure")
context = {
"app_name": settings.app_name,
"app_env": settings.app_env,
"current_username": current_auth.user.username,
"csrf_token": current_auth.session.csrf_token,
"force_password_change": current_auth.user.force_password_change,
"password_change_error": None,
"config_error": "invalid config update request",
"config_saved": False,
"config_sections": build_config_sections(auth_db_session, settings),
}
return templates.TemplateResponse(
request,
"config.html",
context,
status_code=status.HTTP_400_BAD_REQUEST,
)
try:
save_config_updates(auth_db_session, dict(form), settings)
except ConfigSaveError:
logger.warning("Rejected config update due to invalid submitted values")
refreshed_settings = build_runtime_settings(auth_db_session, get_settings())
context = {
"app_name": refreshed_settings.app_name,
"app_env": refreshed_settings.app_env,
"current_username": current_auth.user.username,
"csrf_token": current_auth.session.csrf_token,
"force_password_change": current_auth.user.force_password_change,
"password_change_error": None,
"config_error": "invalid config submission",
"config_saved": False,
"config_sections": build_config_sections(auth_db_session, refreshed_settings),
}
return templates.TemplateResponse(
request,
"config.html",
context,
status_code=status.HTTP_400_BAD_REQUEST,
)
return RedirectResponse(url="/config?saved=1", status_code=status.HTTP_303_SEE_OTHER)
+7 -6
View File
@@ -9,16 +9,17 @@ from app.db import get_db_session
from app.integrations.homeassistant import HomeAssistantClient from app.integrations.homeassistant import HomeAssistantClient
from app.poo_db import get_poo_db_session from app.poo_db import get_poo_db_session
from app.services.auth import AuthenticatedSession, get_authenticated_session from app.services.auth import AuthenticatedSession, get_authenticated_session
from app.services.config_page import build_runtime_settings
def get_app_settings() -> Settings:
return get_settings()
def get_auth_db() -> Generator[Session, None, None]: def get_auth_db() -> Generator[Session, None, None]:
yield from get_auth_db_session() yield from get_auth_db_session()
def get_app_settings(session: Session = Depends(get_auth_db)) -> Settings:
return build_runtime_settings(session, get_settings())
def get_db() -> Generator[Session, None, None]: def get_db() -> Generator[Session, None, None]:
yield from get_db_session() yield from get_db_session()
@@ -27,8 +28,8 @@ def get_poo_db() -> Generator[Session, None, None]:
yield from get_poo_db_session() yield from get_poo_db_session()
def get_homeassistant_client() -> HomeAssistantClient: def get_homeassistant_client(settings: Settings = Depends(get_app_settings)) -> HomeAssistantClient:
return HomeAssistantClient(get_settings()) return HomeAssistantClient(settings)
def get_current_auth_session( def get_current_auth_session(
+2
View File
@@ -14,6 +14,7 @@ from app.api.routes.location import router as location_router
from app.api.routes.poo import router as poo_router from app.api.routes.poo import router as poo_router
from app.config import get_settings from app.config import get_settings
from app.services.auth import AuthBootstrapError, initialize_auth_schema from app.services.auth import AuthBootstrapError, initialize_auth_schema
from app.services.config_page import seed_missing_config_from_bootstrap
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db
from scripts.poo_db_adopt import PooDatabaseAdoptionError, validate_poo_runtime_db from scripts.poo_db_adopt import PooDatabaseAdoptionError, validate_poo_runtime_db
@@ -25,6 +26,7 @@ def ensure_auth_db_ready() -> None:
try: try:
validate_app_runtime_db(get_settings().app_database_url) validate_app_runtime_db(get_settings().app_database_url)
initialize_auth_schema(session, get_settings()) initialize_auth_schema(session, get_settings())
seed_missing_config_from_bootstrap(session, get_settings())
except AppDatabaseAdoptionError as exc: except AppDatabaseAdoptionError as exc:
raise RuntimeError(str(exc)) from exc raise RuntimeError(str(exc)) from exc
except AuthBootstrapError as exc: except AuthBootstrapError as exc:
+2 -1
View File
@@ -1,6 +1,7 @@
"""SQLAlchemy models package.""" """SQLAlchemy models package."""
from app.models.auth import AuthSession, AuthUser from app.models.auth import AuthSession, AuthUser
from app.models.config import AppConfigEntry
from app.models.location import Location from app.models.location import Location
__all__ = ["AuthSession", "AuthUser", "Location"] __all__ = ["AppConfigEntry", "AuthSession", "AuthUser", "Location"]
+15
View File
@@ -0,0 +1,15 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.auth_db import AuthBase
class AppConfigEntry(AuthBase):
__tablename__ = "app_config"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
key: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
value: Mapped[str] = mapped_column(String, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+245
View File
@@ -0,0 +1,245 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth_db import reset_auth_db_caches
from app.config import Settings, get_settings
from app.models.config import AppConfigEntry
@dataclass(frozen=True, slots=True)
class ConfigField:
section: str
env_name: str
setting_attr: str
label: str
secret: bool = False
input_type: str = "text"
CONFIG_FIELDS: tuple[ConfigField, ...] = (
ConfigField("System", "APP_NAME", "app_name", "App Name"),
ConfigField("System", "APP_ENV", "app_env", "App Env"),
ConfigField("System", "APP_DEBUG", "app_debug", "App Debug"),
ConfigField("System", "APP_HOST", "app_host", "App Host"),
ConfigField("System", "APP_PORT", "app_port", "App Port"),
ConfigField(
"Authentication",
"AUTH_SESSION_COOKIE_NAME",
"auth_session_cookie_name",
"Session Cookie Name",
),
ConfigField("Authentication", "AUTH_SESSION_TTL_HOURS", "auth_session_ttl_hours", "Session TTL Hours"),
ConfigField(
"Authentication",
"AUTH_COOKIE_SECURE_OVERRIDE",
"auth_cookie_secure_override",
"Cookie Secure Override",
),
ConfigField("Poo", "POO_WEBHOOK_ID", "poo_webhook_id", "Poo Webhook ID", secret=True),
ConfigField(
"Poo",
"POO_SENSOR_ENTITY_NAME",
"poo_sensor_entity_name",
"Poo Sensor Entity Name",
),
ConfigField(
"Poo",
"POO_SENSOR_FRIENDLY_NAME",
"poo_sensor_friendly_name",
"Poo Sensor Friendly Name",
),
ConfigField("TickTick", "TICKTICK_CLIENT_ID", "ticktick_client_id", "TickTick Client ID"),
ConfigField(
"TickTick",
"TICKTICK_CLIENT_SECRET",
"ticktick_client_secret",
"TickTick Client Secret",
secret=True,
),
ConfigField(
"TickTick",
"TICKTICK_REDIRECT_URI",
"ticktick_redirect_uri",
"TickTick Redirect URI",
),
ConfigField("TickTick", "TICKTICK_TOKEN", "ticktick_token", "TickTick Token", secret=True),
ConfigField(
"Home Assistant",
"HOME_ASSISTANT_BASE_URL",
"home_assistant_base_url",
"Home Assistant Base URL",
),
ConfigField(
"Home Assistant",
"HOME_ASSISTANT_AUTH_TOKEN",
"home_assistant_auth_token",
"Home Assistant Auth Token",
secret=True,
),
ConfigField(
"Home Assistant",
"HOME_ASSISTANT_TIMEOUT_SECONDS",
"home_assistant_timeout_seconds",
"Home Assistant Timeout Seconds",
),
ConfigField(
"Home Assistant",
"HOME_ASSISTANT_ACTION_TASK_PROJECT_ID",
"home_assistant_action_task_project_id",
"Home Assistant Action Task Project ID",
),
)
class ConfigSaveError(ValueError):
"""Raised when the submitted config payload is invalid."""
def seed_missing_config_from_bootstrap(session: Session, bootstrap_settings: Settings) -> None:
current_values = _read_config_values(session)
missing_values: dict[str, str] = {}
for field in CONFIG_FIELDS:
if field.env_name in current_values:
continue
missing_values[field.env_name] = _stringify(getattr(bootstrap_settings, field.setting_attr))
if not missing_values:
return
_persist_config_values(session, {**current_values, **missing_values})
def build_runtime_settings(session: Session, bootstrap_settings: Settings) -> Settings:
overrides = _read_config_values(session)
if not overrides:
return bootstrap_settings
payload = _settings_payload(bootstrap_settings)
for field in CONFIG_FIELDS:
if field.env_name in overrides:
payload[field.setting_attr] = overrides[field.env_name]
return Settings(_env_file=None, **payload)
def build_config_sections(session: Session, bootstrap_settings: Settings) -> list[dict[str, Any]]:
runtime_settings = build_runtime_settings(session, bootstrap_settings)
persisted_values = _read_config_values(session)
sections: list[dict[str, Any]] = []
current_section: dict[str, Any] | None = None
for field in CONFIG_FIELDS:
if current_section is None or current_section["name"] != field.section:
current_section = {"name": field.section, "fields": []}
sections.append(current_section)
current_section["fields"].append(
{
"env_name": field.env_name,
"label": field.label,
"value": "" if field.secret else _stringify(getattr(runtime_settings, field.setting_attr)),
"secret": field.secret,
"input_type": "password" if field.secret else field.input_type,
"configured": field.env_name in persisted_values
or bool(_stringify(getattr(bootstrap_settings, field.setting_attr))),
}
)
return sections
def save_config_updates(session: Session, form_data: dict[str, str], bootstrap_settings: Settings) -> None:
current_values = _read_config_values(session)
merged_values = dict(current_values)
for field in CONFIG_FIELDS:
submitted_value = form_data.get(field.env_name, "")
if field.secret:
if submitted_value:
merged_values[field.env_name] = submitted_value
else:
merged_values[field.env_name] = submitted_value
_validate_config_values(merged_values, bootstrap_settings)
_persist_config_values(session, merged_values)
get_settings.cache_clear()
reset_auth_db_caches()
def _read_config_values(session: Session) -> dict[str, str]:
rows = session.execute(select(AppConfigEntry).order_by(AppConfigEntry.key)).scalars().all()
return {row.key: row.value for row in rows}
def _validate_config_values(config_values: dict[str, str], bootstrap_settings: Settings) -> None:
payload = _settings_payload(bootstrap_settings)
for field in CONFIG_FIELDS:
if field.env_name in config_values:
payload[field.setting_attr] = config_values[field.env_name]
try:
Settings(_env_file=None, **payload)
except Exception as exc:
raise ConfigSaveError("invalid config submission") from exc
def _persist_config_values(session: Session, config_values: dict[str, str]) -> None:
existing_entries = {
row.key: row
for row in session.execute(select(AppConfigEntry)).scalars().all()
}
now = datetime.now(UTC)
for env_name, value in config_values.items():
entry = existing_entries.get(env_name)
if entry is None:
session.add(AppConfigEntry(key=env_name, value=value, updated_at=now))
else:
entry.value = value
entry.updated_at = now
session.commit()
def _stringify(value: Any) -> str:
if value is None:
return ""
if isinstance(value, bool):
return str(value).lower()
return str(value)
def _settings_payload(settings: Settings) -> dict[str, Any]:
return {
"app_name": settings.app_name,
"app_env": settings.app_env,
"app_debug": settings.app_debug,
"app_host": settings.app_host,
"app_port": settings.app_port,
"app_database_url": settings.app_database_url,
"location_database_url": settings.location_database_url,
"poo_database_url": settings.poo_database_url,
"ticktick_client_id": settings.ticktick_client_id,
"ticktick_client_secret": settings.ticktick_client_secret,
"ticktick_redirect_uri": settings.ticktick_redirect_uri,
"ticktick_token": settings.ticktick_token,
"home_assistant_base_url": settings.home_assistant_base_url,
"home_assistant_auth_token": settings.home_assistant_auth_token,
"home_assistant_timeout_seconds": settings.home_assistant_timeout_seconds,
"home_assistant_action_task_project_id": settings.home_assistant_action_task_project_id,
"poo_webhook_id": settings.poo_webhook_id,
"poo_sensor_entity_name": settings.poo_sensor_entity_name,
"poo_sensor_friendly_name": settings.poo_sensor_friendly_name,
"auth_bootstrap_username": settings.auth_bootstrap_username,
"auth_bootstrap_password": settings.auth_bootstrap_password,
"auth_session_cookie_name": settings.auth_session_cookie_name,
"auth_session_ttl_hours": settings.auth_session_ttl_hours,
"auth_cookie_secure_override": settings.auth_cookie_secure_override,
}
+46
View File
@@ -61,6 +61,11 @@ h1 {
margin: 0; margin: 0;
} }
.single-column {
grid-template-columns: minmax(180px, 320px);
margin-bottom: 24px;
}
.meta div { .meta div {
padding: 16px; padding: 16px;
border-radius: 16px; border-radius: 16px;
@@ -136,6 +141,47 @@ button:hover {
color: #8b2a2a; color: #8b2a2a;
} }
.notice {
margin-bottom: 16px;
padding: 12px 14px;
border-radius: 12px;
background: rgba(45, 106, 79, 0.08);
border: 1px solid rgba(45, 106, 79, 0.14);
color: var(--accent);
}
.config-block + .config-block {
margin-top: 28px;
}
.config-block h2 {
margin: 0 0 16px;
font-size: 1.25rem;
}
.config-form {
display: grid;
gap: 20px;
}
.config-section {
margin: 0;
padding: 18px;
border: 1px solid rgba(31, 41, 51, 0.08);
border-radius: 16px;
display: grid;
gap: 14px;
}
.config-section legend {
padding: 0 8px;
color: var(--accent);
}
.config-form label small {
color: var(--muted);
}
@media (max-width: 640px) { @media (max-width: 640px) {
.shell { .shell {
margin: 24px auto; margin: 24px auto;
-64
View File
@@ -1,64 +0,0 @@
{% extends "base.html" %}
{% block title %}Admin · {{ app_name }}{% endblock %}
{% block content %}
<section class="panel">
<p class="eyebrow">Protected Area</p>
<h1>Admin</h1>
{% if force_password_change %}
<p class="lead">
首次登录后需要先修改密码。完成后,这里会继续作为未来配置页面的入口。
</p>
{% if password_change_error %}
<div class="alert">{{ password_change_error }}</div>
{% endif %}
<form class="auth-form" method="post" action="/admin/change-password">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>
<span>Current Password</span>
<input type="password" name="current_password" autocomplete="current-password" required>
</label>
<label>
<span>New Password</span>
<input type="password" name="new_password" autocomplete="new-password" required>
</label>
<label>
<span>Confirm New Password</span>
<input type="password" name="confirm_password" autocomplete="new-password" required>
</label>
<button type="submit">修改密码</button>
</form>
{% else %}
<p class="lead">
你已经登录。这个页面目前是一个受保护的空白配置占位页,后续会在这里接入配置的增删查改。
</p>
<dl class="meta">
<div>
<dt>当前用户</dt>
<dd>{{ current_username }}</dd>
</div>
<div>
<dt>运行环境</dt>
<dd>{{ app_env }}</dd>
</div>
<div>
<dt>下一步</dt>
<dd>在这里接入配置页面与更细的受保护操作。</dd>
</div>
</dl>
{% endif %}
<form class="logout-form" method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">登出</button>
</form>
</section>
{% endblock %}
+90
View File
@@ -0,0 +1,90 @@
{% extends "base.html" %}
{% block title %}Config · {{ app_name }}{% endblock %}
{% block content %}
<section class="panel">
<p class="eyebrow">Configuration</p>
<h1>Config</h1>
{% if force_password_change %}
<div class="alert">
首次登录后需要先修改密码。完成后再继续长期使用当前配置页面。
</div>
{% endif %}
{% if password_change_error %}
<div class="alert">{{ password_change_error }}</div>
{% endif %}
{% if config_error %}
<div class="alert">{{ config_error }}</div>
{% endif %}
{% if config_saved %}
<div class="notice">config saved to .env. Some changes may require an app restart.</div>
{% endif %}
<div class="meta single-column">
<div>
<dt>当前用户</dt>
<dd>admin</dd>
</div>
</div>
<section class="config-block">
<h2>Change Password</h2>
<form class="auth-form" method="post" action="/config/change-password">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>
<span>Current Password</span>
<input type="password" name="current_password" autocomplete="current-password" required>
</label>
<label>
<span>New Password</span>
<input type="password" name="new_password" autocomplete="new-password" required>
</label>
<label>
<span>Confirm New Password</span>
<input type="password" name="confirm_password" autocomplete="new-password" required>
</label>
<button type="submit">修改密码</button>
</form>
</section>
<section class="config-block">
<h2>Config</h2>
<form class="config-form" method="post" action="/config">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% for section in config_sections %}
<fieldset class="config-section">
<legend>{{ section.name }}</legend>
{% for field in section.fields %}
<label>
<span>{{ field.label }}</span>
{% if field.secret %}
<input type="{{ field.input_type }}" name="{{ field.env_name }}" value="" placeholder="leave blank to keep current value">
<small>{% if field.configured %}configured{% else %}not configured{% endif %}</small>
{% else %}
<input type="{{ field.input_type }}" name="{{ field.env_name }}" value="{{ field.value }}">
{% endif %}
</label>
{% endfor %}
</fieldset>
{% endfor %}
<button type="submit">Save Config</button>
</form>
</section>
<form class="logout-form" method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">登出</button>
</form>
</section>
{% endblock %}
+1 -1
View File
@@ -7,7 +7,7 @@
<p class="eyebrow">Authentication</p> <p class="eyebrow">Authentication</p>
<h1>登录</h1> <h1>登录</h1>
<p class="lead"> <p class="lead">
这个页面只负责当前 Python 重构项目的基础登录能力。配置管理等页面会在后续迭代中接入 登录成功后会进入受保护的 config 页面
</p> </p>
{% if error_message %} {% if error_message %}
+1
View File
@@ -41,6 +41,7 @@
- Pydantic schemas - Pydantic schemas
- `services/` - `services/`
- 业务服务层 - 业务服务层
- 当前已迁入 config page 的 DB 持久化逻辑
- `integrations/` - `integrations/`
- 外部系统适配层 - 外部系统适配层
- 当前已迁入 Home Assistant outbound adapter - 当前已迁入 Home Assistant outbound adapter
+13 -3
View File
@@ -34,6 +34,7 @@
- `auth_users` - `auth_users`
- `auth_sessions` - `auth_sessions`
- `app_config`
当前没有把 auth 数据和 `location` / `poo` DB 混放。 当前没有把 auth 数据和 `location` / `poo` DB 混放。
@@ -44,6 +45,14 @@
当前没有 legacy app DB,所以这一版脚本只负责初始化新库,不负责 legacy adoption。 当前没有 legacy app DB,所以这一版脚本只负责初始化新库,不负责 legacy adoption。
`app_config` 现在承接运行时配置持久化。
其中:
- `.env` 负责 bootstrap / fallback
- `app_config` 表负责运行时配置覆盖
- 登录密码仍然属于认证数据,使用 Argon2 哈希,不存进 `app_config`
## 首次启动与 bootstrap ## 首次启动与 bootstrap
如果 auth DB 中还没有任何用户,应用启动时会要求: 如果 auth DB 中还没有任何用户,应用启动时会要求:
@@ -89,8 +98,9 @@
当前这轮只保护了页面入口: 当前这轮只保护了页面入口:
- `GET /admin` - `GET /config`
- `POST /admin/change-password` - `POST /config`
- `POST /config/change-password`
- `POST /logout` - `POST /logout`
相关流程: 相关流程:
@@ -98,7 +108,7 @@
- `GET /login` - `GET /login`
- `POST /login` - `POST /login`
未登录访问 `/admin` 时会被重定向到 `/login` 未登录访问 `/config` 时会被重定向到 `/login`
## 下一步不在本轮内 ## 下一步不在本轮内
+111 -2
View File
@@ -87,6 +87,47 @@
} }
} }
}, },
"/config/change-password": {
"post": {
"tags": [
"auth"
],
"summary": "Change Password Submit",
"operationId": "change_password_submit_config_change_password_post",
"requestBody": {
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/Body_change_password_submit_config_change_password_post"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"text/html": {
"schema": {
"type": "string"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/logout": { "/logout": {
"post": { "post": {
"tags": [ "tags": [
@@ -152,8 +193,48 @@
"tags": [ "tags": [
"pages" "pages"
], ],
"summary": "Admin Page", "summary": "Admin Redirect",
"operationId": "admin_page_admin_get", "operationId": "admin_redirect_admin_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"text/html": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/config": {
"get": {
"tags": [
"pages"
],
"summary": "Config Page",
"operationId": "config_page_config_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"text/html": {
"schema": {
"type": "string"
}
}
}
}
}
},
"post": {
"tags": [
"pages"
],
"summary": "Config Submit",
"operationId": "config_submit_config_post",
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -247,6 +328,34 @@
}, },
"components": { "components": {
"schemas": { "schemas": {
"Body_change_password_submit_config_change_password_post": {
"properties": {
"current_password": {
"type": "string",
"title": "Current Password"
},
"new_password": {
"type": "string",
"title": "New Password"
},
"confirm_password": {
"type": "string",
"title": "Confirm Password"
},
"csrf_token": {
"type": "string",
"title": "Csrf Token"
}
},
"type": "object",
"required": [
"current_password",
"new_password",
"confirm_password",
"csrf_token"
],
"title": "Body_change_password_submit_config_change_password_post"
},
"Body_login_submit_login_post": { "Body_login_submit_login_post": {
"properties": { "properties": {
"username": { "username": {
+73 -2
View File
@@ -55,6 +55,31 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/HTTPValidationError' $ref: '#/components/schemas/HTTPValidationError'
/config/change-password:
post:
tags:
- auth
summary: Change Password Submit
operationId: change_password_submit_config_change_password_post
requestBody:
content:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/Body_change_password_submit_config_change_password_post'
required: true
responses:
'200':
description: Successful Response
content:
text/html:
schema:
type: string
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/logout: /logout:
post: post:
tags: tags:
@@ -96,8 +121,33 @@ paths:
get: get:
tags: tags:
- pages - pages
summary: Admin Page summary: Admin Redirect
operationId: admin_page_admin_get operationId: admin_redirect_admin_get
responses:
'200':
description: Successful Response
content:
text/html:
schema:
type: string
/config:
get:
tags:
- pages
summary: Config Page
operationId: config_page_config_get
responses:
'200':
description: Successful Response
content:
text/html:
schema:
type: string
post:
tags:
- pages
summary: Config Submit
operationId: config_submit_config_post
responses: responses:
'200': '200':
description: Successful Response description: Successful Response
@@ -155,6 +205,27 @@ paths:
schema: {} schema: {}
components: components:
schemas: schemas:
Body_change_password_submit_config_change_password_post:
properties:
current_password:
type: string
title: Current Password
new_password:
type: string
title: New Password
confirm_password:
type: string
title: Confirm Password
csrf_token:
type: string
title: Csrf Token
type: object
required:
- current_password
- new_password
- confirm_password
- csrf_token
title: Body_change_password_submit_config_change_password_post
Body_login_submit_login_post: Body_login_submit_login_post:
properties: properties:
username: username:
+7 -8
View File
@@ -13,7 +13,7 @@ if str(PROJECT_ROOT) not in sys.path:
from app.config import get_settings from app.config import get_settings
APP_BASELINE_REVISION = "20260420_03_app_auth_baseline" APP_BASELINE_REVISION = "20260420_04_app_config_table"
class AppDatabaseAdoptionError(RuntimeError): class AppDatabaseAdoptionError(RuntimeError):
@@ -102,13 +102,10 @@ def adopt_or_initialize_app_db(database_url: str) -> str:
if database_path.exists(): if database_path.exists():
if _alembic_version_table_exists(database_path): if _alembic_version_table_exists(database_path):
current_revision = _fetch_alembic_revision(database_path) current_revision = _fetch_alembic_revision(database_path)
if current_revision != APP_BASELINE_REVISION: if current_revision == APP_BASELINE_REVISION:
raise AppDatabaseAdoptionError( return "already_managed"
"App DB is already Alembic-managed but revision does not match " command.upgrade(alembic_config, "head")
f"the expected baseline: expected {APP_BASELINE_REVISION}, " return "upgraded"
f"got {current_revision}"
)
return "already_managed"
existing_tables = _list_user_tables(database_path) existing_tables = _list_user_tables(database_path)
if existing_tables: if existing_tables:
@@ -127,6 +124,8 @@ def main() -> None:
result = adopt_or_initialize_app_db(settings.app_database_url) result = adopt_or_initialize_app_db(settings.app_database_url)
if result == "initialized": if result == "initialized":
print("Initialized a new app DB via Alembic upgrade head.") print("Initialized a new app DB via Alembic upgrade head.")
elif result == "upgraded":
print("Upgraded existing app DB to the expected Alembic head revision.")
else: else:
print("App DB is already Alembic-managed at the expected baseline revision.") print("App DB is already Alembic-managed at the expected baseline revision.")
+49 -3
View File
@@ -25,8 +25,9 @@ def _prepare_app_db(tmp_path) -> str:
def test_app_starts(client: TestClient) -> None: def test_app_starts(client: TestClient) -> None:
response = client.get("/") response = client.get("/", follow_redirects=False)
assert response.status_code == 200 assert response.status_code == 303
assert response.headers["location"] == "/login"
def test_status_endpoint(client: TestClient) -> None: def test_status_endpoint(client: TestClient) -> None:
@@ -73,11 +74,56 @@ def test_app_db_adoption_initializes_new_database(tmp_path) -> None:
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'" "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'"
).fetchall() ).fetchall()
} }
assert {"auth_users", "auth_sessions", "alembic_version"} <= tables assert {"auth_users", "auth_sessions", "app_config", "alembic_version"} <= tables
finally: finally:
conn.close() conn.close()
def test_app_start_seeds_missing_config_from_env_without_overwriting_existing_values(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
app_database_url = _prepare_app_db(tmp_path)
location_database_path = tmp_path / "location_ready.db"
poo_database_path = tmp_path / "poo_ready.db"
command.upgrade(_make_alembic_config(f"sqlite:///{location_database_path}"), "head")
command.upgrade(_make_poo_alembic_config(f"sqlite:///{poo_database_path}"), "head")
app_database_path = tmp_path / "app_ready.db"
conn = sqlite3.connect(app_database_path)
conn.execute(
"INSERT INTO app_config (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
("APP_NAME", "Database Owned Name"),
)
conn.commit()
conn.close()
monkeypatch.setenv("APP_DATABASE_URL", app_database_url)
monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin")
monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password")
monkeypatch.setenv("APP_NAME", "Bootstrap Name")
monkeypatch.setenv("HOME_ASSISTANT_BASE_URL", "http://bootstrap-ha.local:8123")
monkeypatch.setenv("LOCATION_DATABASE_URL", f"sqlite:///{location_database_path}")
monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{poo_database_path}")
get_settings.cache_clear()
reset_auth_db_caches()
app = create_app()
anyio.run(_run_lifespan, app)
conn = sqlite3.connect(app_database_path)
try:
rows = dict(conn.execute("SELECT key, value FROM app_config").fetchall())
finally:
conn.close()
assert rows["APP_NAME"] == "Database Owned Name"
assert rows["HOME_ASSISTANT_BASE_URL"] == "http://bootstrap-ha.local:8123"
assert rows["AUTH_SESSION_COOKIE_NAME"] == "home_automation_session"
get_settings.cache_clear()
reset_auth_db_caches()
def test_app_start_fails_when_location_db_missing( def test_app_start_fails_when_location_db_missing(
tmp_path, monkeypatch: pytest.MonkeyPatch tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
+94 -14
View File
@@ -1,7 +1,11 @@
import re import re
import sqlite3
from pathlib import Path
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.config import get_settings
def _extract_csrf_token(html: str) -> str: def _extract_csrf_token(html: str) -> str:
match = re.search(r'name="csrf_token" value="([^"]+)"', html) match = re.search(r'name="csrf_token" value="([^"]+)"', html)
@@ -9,8 +13,16 @@ def _extract_csrf_token(html: str) -> str:
return match.group(1) return match.group(1)
def test_unauthenticated_admin_redirects_to_login(client: TestClient) -> None: def _stringify_for_form(value) -> str:
response = client.get("/admin", follow_redirects=False) if value is None:
return ""
if isinstance(value, bool):
return str(value).lower()
return str(value)
def test_unauthenticated_config_redirects_to_login(client: TestClient) -> None:
response = client.get("/config", follow_redirects=False)
assert response.status_code == 303 assert response.status_code == 303
assert response.headers["location"] == "/login" assert response.headers["location"] == "/login"
@@ -31,18 +43,19 @@ def test_login_success_sets_session_cookie_and_allows_admin_access(client: TestC
) )
assert response.status_code == 303 assert response.status_code == 303
assert response.headers["location"] == "/admin" assert response.headers["location"] == "/config"
set_cookie_header = response.headers["set-cookie"].lower() set_cookie_header = response.headers["set-cookie"].lower()
assert "home_automation_session=" in set_cookie_header assert "home_automation_session=" in set_cookie_header
assert "httponly" in set_cookie_header assert "httponly" in set_cookie_header
assert "samesite=lax" in set_cookie_header assert "samesite=lax" in set_cookie_header
admin_response = client.get("/admin") config_response = client.get("/config")
assert admin_response.status_code == 200 assert config_response.status_code == 200
assert "首次登录后需要先修改密码" in admin_response.text assert "首次登录后需要先修改密码" in config_response.text
assert "Current Password" in admin_response.text assert "Current Password" in config_response.text
assert "New Password" in admin_response.text assert "New Password" in config_response.text
assert "当前用户" not in admin_response.text assert "Save Config" in config_response.text
assert "当前用户" in config_response.text
def test_login_failure_returns_generic_error(client: TestClient) -> None: def test_login_failure_returns_generic_error(client: TestClient) -> None:
@@ -76,8 +89,8 @@ def test_logout_revokes_session(client: TestClient) -> None:
}, },
) )
admin_page = client.get("/admin") config_page = client.get("/config")
logout_csrf_token = _extract_csrf_token(admin_page.text) logout_csrf_token = _extract_csrf_token(config_page.text)
logout_response = client.post( logout_response = client.post(
"/logout", "/logout",
@@ -88,9 +101,9 @@ def test_logout_revokes_session(client: TestClient) -> None:
assert logout_response.status_code == 303 assert logout_response.status_code == 303
assert logout_response.headers["location"] == "/login" assert logout_response.headers["location"] == "/login"
admin_after_logout = client.get("/admin", follow_redirects=False) config_after_logout = client.get("/config", follow_redirects=False)
assert admin_after_logout.status_code == 303 assert config_after_logout.status_code == 303
assert admin_after_logout.headers["location"] == "/login" assert config_after_logout.headers["location"] == "/login"
def test_login_rejects_invalid_csrf(client: TestClient) -> None: def test_login_rejects_invalid_csrf(client: TestClient) -> None:
@@ -107,3 +120,70 @@ def test_login_rejects_invalid_csrf(client: TestClient) -> None:
assert response.status_code == 400 assert response.status_code == 400
assert "invalid login request" in response.text assert "invalid login request" in response.text
def test_legacy_admin_route_redirects_to_config_when_authenticated(client: TestClient) -> None:
login_page = client.get("/login")
csrf_token = _extract_csrf_token(login_page.text)
client.post(
"/login",
data={
"username": "admin",
"password": "test-password",
"csrf_token": csrf_token,
},
follow_redirects=False,
)
response = client.get("/admin", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/config"
def test_config_page_update_persists_to_database(
client: TestClient, test_database_urls
) -> None:
login_page = client.get("/login")
csrf_token = _extract_csrf_token(login_page.text)
client.post(
"/login",
data={
"username": "admin",
"password": "test-password",
"csrf_token": csrf_token,
},
follow_redirects=False,
)
config_page = client.get("/config")
config_csrf_token = _extract_csrf_token(config_page.text)
settings = get_settings()
form_data = {"csrf_token": config_csrf_token}
from app.services.config_page import CONFIG_FIELDS
for field in CONFIG_FIELDS:
if field.secret:
form_data[field.env_name] = ""
else:
form_data[field.env_name] = _stringify_for_form(getattr(settings, field.setting_attr))
form_data["APP_NAME"] = "Updated Home Automation"
form_data["HOME_ASSISTANT_AUTH_TOKEN"] = "new-token"
response = client.post("/config", data=form_data, follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/config?saved=1"
conn = sqlite3.connect(test_database_urls["app_path"])
try:
rows = dict(conn.execute("SELECT key, value FROM app_config").fetchall())
finally:
conn.close()
assert rows["APP_NAME"] == "Updated Home Automation"
assert rows["HOME_ASSISTANT_AUTH_TOKEN"] == "new-token"
assert "AUTH_BOOTSTRAP_USERNAME" not in rows