diff --git a/.env.example b/.env.example
index dd04a49..99100da 100644
--- a/.env.example
+++ b/.env.example
@@ -3,6 +3,12 @@ APP_ENV=development
APP_DEBUG=true
APP_HOST=0.0.0.0
APP_PORT=8000
+APP_DATABASE_URL=sqlite:///./data/app.db
+AUTH_BOOTSTRAP_USERNAME=admin
+AUTH_BOOTSTRAP_PASSWORD=admin
+AUTH_SESSION_COOKIE_NAME=home_automation_session
+AUTH_SESSION_TTL_HOURS=12
+AUTH_COOKIE_SECURE_OVERRIDE=false
LOCATION_DATABASE_URL=sqlite:///./data/locationRecorder.db
POO_DATABASE_URL=sqlite:///./data/pooRecorder.db
POO_WEBHOOK_ID=
diff --git a/Dockerfile b/Dockerfile
index 121d2ac..bc82a1f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -9,6 +9,8 @@ COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
+COPY alembic_app ./alembic_app
+COPY alembic_app.ini ./
COPY alembic_location ./alembic_location
COPY alembic_location.ini ./
COPY alembic_poo ./alembic_poo
diff --git a/README.md b/README.md
index 31f5fa0..7fa1592 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@
- FastAPI 基础应用骨架
- 环境变量配置体系
- SQLite + SQLAlchemy + Alembic 基础设施
+- username/password + server-side session 基础鉴权
- 极简 server-side templates
- location recorder 第一版迁移
- poo recorder 第一版迁移
@@ -39,23 +40,37 @@ Notion 在 Go 版本中仍然存在,但已被明确视为 legacy / removed sco
## 当前配置现实
-当前系统仍然是两个独立的 SQLite 数据库文件,而不是单一数据库:
+当前系统仍然是三个独立的 SQLite 数据库文件,而不是单一数据库:
+- `app` 级共享数据使用自己的 DB 文件
- `location` 模块使用自己的 DB 文件
- `poo` 模块使用自己的 DB 文件
-当前阶段明确不借这次重构把两个 DB 合并。配置层已经显式反映这一点:
+当前阶段明确不借这次重构把这些 DB 合并。配置层已经显式反映这一点:
+- `APP_DATABASE_URL`
- `LOCATION_DATABASE_URL`
- `POO_DATABASE_URL`
-目前 `location` 和 `poo` 都已经接到各自独立的数据库文件。
+目前 auth、`location` 和 `poo` 都已经接到各自独立的数据库文件。
+
+其中 `app` 级共享 DB 当前主要用于:
+
+- 单个 admin 用户
+- server-side session
+
+这部分现在也使用 Alembic 管理:
+
+- `app db` 不会在应用启动时自动创建
+- 需要先运行 `python scripts/app_db_adopt.py`
+- 这个脚本会创建新 DB 并建好 schema
## 当前目录
Python 骨架的主要目录如下:
- `app/`: FastAPI 应用代码
+- `alembic_app/`: App DB 的 Alembic migration 环境
- `alembic_location/`: Location DB 的 Alembic migration 环境
- `alembic_poo/`: Poo DB 的 Alembic migration 环境
- `tests/`: pytest 测试
@@ -107,7 +122,15 @@ pip install -r dev-requirements.txt
cp .env.example .env
```
-3. 启动服务
+3. 初始化数据库
+
+```bash
+python scripts/app_db_adopt.py
+python scripts/location_db_adopt.py
+python scripts/poo_db_adopt.py
+```
+
+4. 启动服务
```bash
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
@@ -122,21 +145,54 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
## 数据库与 Alembic
-当前默认仍使用 SQLite,但要明确区分两个数据库文件:
+当前默认仍使用 SQLite,但要明确区分三个数据库文件:
+- App DB:`sqlite:///./data/app.db`
- Location DB:`sqlite:///./data/locationRecorder.db`
- Poo DB:`sqlite:///./data/pooRecorder.db`
- 数据目录:`./data/`
初始化 migration 环境后,可继续添加模型并生成迁移:
-当前 `location` 和 `poo` 都已经有各自独立的 Alembic baseline / 接管链路。
+当前 `app`、`location` 和 `poo` 都已经有各自独立的 Alembic 链路。
+- App Alembic 环境:`alembic_app.ini` + `alembic_app/`
- Location Alembic 环境:`alembic_location.ini` + `alembic_location/`
- Poo Alembic 环境:`alembic_poo.ini` + `alembic_poo/`
+- App DB 初始化:`python scripts/app_db_adopt.py`
- Location DB 接管 / 初始化:`python scripts/location_db_adopt.py`
- Poo DB 接管 / 初始化:`python scripts/poo_db_adopt.py`
+## 基础鉴权
+
+当前项目已经有一层小范围的基础鉴权,目标是先保护后续配置页面,而不是现在就做完整 admin system。
+
+- 认证模型:`username/password`
+- 会话模型:server-side session + cookie
+- 当前受保护页面:`/admin`
+- 当前公开页面:`/`、`/login`
+- 当前公开 API:现有业务 API 暂未在这一轮统一收口到 auth 下
+
+安全实现的当前边界:
+
+- 密码使用 scrypt 做哈希存储
+- session cookie 使用 `HttpOnly`
+- `Secure` 默认随 `APP_ENV` 切换:非 development 时默认开启
+- `SameSite=Lax`
+- 登录表单和登出表单都有基础 CSRF 防护
+
+首次启动时,如果 `APP_DATABASE_URL` 对应的 auth DB 里还没有用户,应用会使用:
+
+- `AUTH_BOOTSTRAP_USERNAME`
+- `AUTH_BOOTSTRAP_PASSWORD`
+
+创建初始 admin 用户。当前默认就是:
+
+- username: `admin`
+- password: `admin`
+
+首次登录后会被要求立即修改密码。这个 bootstrap 只用于首个用户落库,不是后续的完整配置管理方案。
+
## 运行测试
```bash
@@ -147,6 +203,7 @@ pytest
- app 基本启动测试
- `/status` endpoint 测试
+- 登录 / session 基础流程测试
## OpenAPI 导出
@@ -200,3 +257,4 @@ SQLite 持久化目录:
- [Python 重构方案](docs/python-rewrite-plan.md)
- [迁移风险清单](docs/migration-risks.md)
- [Location Recorder 接管说明](docs/location-recorder.md)
+- [基础鉴权说明](docs/auth.md)
diff --git a/alembic_app.ini b/alembic_app.ini
new file mode 100644
index 0000000..f6ae3f6
--- /dev/null
+++ b/alembic_app.ini
@@ -0,0 +1,37 @@
+[alembic]
+script_location = alembic_app
+prepend_sys_path = .
+path_separator = os
+sqlalchemy.url = sqlite:///./data/app.db
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
diff --git a/alembic_app/env.py b/alembic_app/env.py
new file mode 100644
index 0000000..66c93f3
--- /dev/null
+++ b/alembic_app/env.py
@@ -0,0 +1,48 @@
+from logging.config import fileConfig
+
+from alembic import context
+from sqlalchemy import engine_from_config, pool
+
+from app.auth_db import AuthBase
+from app.config import get_settings
+from app.models.auth import AuthSession, AuthUser # noqa: F401
+
+config = context.config
+
+if config.config_file_name is not None:
+ fileConfig(config.config_file_name)
+
+settings = get_settings()
+configured_url = config.get_main_option("sqlalchemy.url")
+if not configured_url or configured_url == "sqlite:///./data/app.db":
+ config.set_main_option("sqlalchemy.url", settings.app_database_url)
+
+target_metadata = AuthBase.metadata
+
+
+def run_migrations_offline() -> None:
+ url = config.get_main_option("sqlalchemy.url")
+ context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online() -> None:
+ connectable = engine_from_config(
+ config.get_section(config.config_ini_section, {}),
+ prefix="sqlalchemy.",
+ poolclass=pool.NullPool,
+ )
+
+ with connectable.connect() as connection:
+ context.configure(connection=connection, target_metadata=target_metadata)
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/alembic_app/script.py.mako b/alembic_app/script.py.mako
new file mode 100644
index 0000000..a9941d2
--- /dev/null
+++ b/alembic_app/script.py.mako
@@ -0,0 +1,25 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+ ${downgrades if downgrades else "pass"}
diff --git a/alembic_app/versions/20260420_03_app_auth_baseline.py b/alembic_app/versions/20260420_03_app_auth_baseline.py
new file mode 100644
index 0000000..200e497
--- /dev/null
+++ b/alembic_app/versions/20260420_03_app_auth_baseline.py
@@ -0,0 +1,56 @@
+"""app auth baseline
+
+Revision ID: 20260420_03_app_auth_baseline
+Revises:
+Create Date: 2026-04-20 00:00:00.000000
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = "20260420_03_app_auth_baseline"
+down_revision: Union[str, None] = None
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "auth_users",
+ sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column("username", sa.String(length=255), nullable=False),
+ sa.Column("password_hash", sa.String(length=255), nullable=False),
+ sa.Column("is_active", sa.Boolean(), nullable=False),
+ sa.Column("force_password_change", sa.Boolean(), nullable=False),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index(op.f("ix_auth_users_username"), "auth_users", ["username"], unique=True)
+
+ op.create_table(
+ "auth_sessions",
+ sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column("user_id", sa.Integer(), nullable=False),
+ sa.Column("token_hash", sa.String(length=64), nullable=False),
+ sa.Column("csrf_token", sa.String(length=128), nullable=False),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
+ sa.ForeignKeyConstraint(["user_id"], ["auth_users.id"]),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index(op.f("ix_auth_sessions_expires_at"), "auth_sessions", ["expires_at"], unique=False)
+ op.create_index(op.f("ix_auth_sessions_token_hash"), "auth_sessions", ["token_hash"], unique=True)
+ op.create_index(op.f("ix_auth_sessions_user_id"), "auth_sessions", ["user_id"], unique=False)
+
+
+def downgrade() -> None:
+ op.drop_index(op.f("ix_auth_sessions_user_id"), table_name="auth_sessions")
+ op.drop_index(op.f("ix_auth_sessions_token_hash"), table_name="auth_sessions")
+ op.drop_index(op.f("ix_auth_sessions_expires_at"), table_name="auth_sessions")
+ op.drop_table("auth_sessions")
+ op.drop_index(op.f("ix_auth_users_username"), table_name="auth_users")
+ op.drop_table("auth_users")
diff --git a/app/api/routes/auth.py b/app/api/routes/auth.py
new file mode 100644
index 0000000..b1280f3
--- /dev/null
+++ b/app/api/routes/auth.py
@@ -0,0 +1,223 @@
+import logging
+from pathlib import Path
+
+from fastapi import APIRouter, Depends, Form, Request, status
+from fastapi.responses import HTMLResponse, RedirectResponse, Response
+from fastapi.templating import Jinja2Templates
+from sqlalchemy.orm import Session
+
+from app.config import Settings
+from app.dependencies import get_app_settings, get_auth_db, get_current_auth_session
+from app.services.auth import (
+ AuthenticatedSession,
+ authenticate_user,
+ change_password,
+ create_session,
+ AuthPasswordChangeError,
+ issue_login_csrf_token,
+ revoke_session,
+ validate_csrf_token,
+)
+
+logger = logging.getLogger(__name__)
+templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
+router = APIRouter(tags=["auth"])
+
+LOGIN_CSRF_COOKIE_NAME = "login_csrf"
+
+
+@router.get("/login", response_class=HTMLResponse)
+def login_page(
+ request: Request,
+ settings: Settings = Depends(get_app_settings),
+ current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
+) -> Response:
+ if current_auth is not None:
+ return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
+
+ csrf_token = issue_login_csrf_token()
+ response = templates.TemplateResponse(
+ request,
+ "login.html",
+ {
+ "app_name": settings.app_name,
+ "app_env": settings.app_env,
+ "csrf_token": csrf_token,
+ "error_message": None,
+ },
+ )
+ _set_login_csrf_cookie(response, settings=settings, token=csrf_token)
+ return response
+
+
+@router.post("/login", response_class=HTMLResponse)
+def login_submit(
+ request: Request,
+ username: str = Form(),
+ password: str = Form(),
+ csrf_token: str = Form(),
+ session: Session = Depends(get_auth_db),
+ settings: Settings = Depends(get_app_settings),
+) -> Response:
+ cookie_csrf_token = request.cookies.get(LOGIN_CSRF_COOKIE_NAME)
+ if not validate_csrf_token(expected=cookie_csrf_token, actual=csrf_token):
+ logger.warning("Rejected login attempt due to CSRF validation failure")
+ return _render_login_error(
+ request,
+ settings=settings,
+ status_code=status.HTTP_400_BAD_REQUEST,
+ error_message="invalid login request",
+ )
+
+ user = authenticate_user(session, username=username, password=password)
+ if user is None:
+ return _render_login_error(
+ request,
+ settings=settings,
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ error_message="invalid username or password",
+ )
+
+ auth_session, raw_token = create_session(session, user=user, settings=settings)
+ response = RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
+ response.delete_cookie(LOGIN_CSRF_COOKIE_NAME, path="/login")
+ response.set_cookie(
+ key=settings.auth_session_cookie_name,
+ value=raw_token,
+ max_age=settings.auth_session_ttl_hours * 3600,
+ httponly=True,
+ secure=settings.auth_cookie_secure,
+ samesite="lax",
+ path="/",
+ )
+ logger.info("Created authenticated session for user '%s'", user.username)
+ return response
+
+
+@router.post("/admin/change-password", response_class=HTMLResponse)
+def change_password_submit(
+ request: Request,
+ current_password: str = Form(),
+ new_password: str = Form(),
+ confirm_password: str = Form(),
+ csrf_token: str = Form(),
+ 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)
+
+ 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")
+ return _render_admin_page(
+ request,
+ settings=settings,
+ current_auth=current_auth,
+ status_code=status.HTTP_400_BAD_REQUEST,
+ password_change_error="invalid password change request",
+ )
+
+ try:
+ change_password(
+ session,
+ user=current_auth.user,
+ current_password=current_password,
+ new_password=new_password,
+ confirm_password=confirm_password,
+ )
+ except AuthPasswordChangeError as exc:
+ logger.info(
+ "Rejected password change for user '%s': %s",
+ current_auth.user.username,
+ exc,
+ )
+ return _render_admin_page(
+ request,
+ settings=settings,
+ current_auth=current_auth,
+ status_code=status.HTTP_400_BAD_REQUEST,
+ password_change_error="password change failed",
+ )
+
+ logger.info("Password updated for user '%s'", current_auth.user.username)
+ return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
+
+
+@router.post("/logout")
+def logout(
+ request: Request,
+ csrf_token: str = Form(),
+ session: Session = Depends(get_auth_db),
+ settings: Settings = Depends(get_app_settings),
+ current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
+) -> RedirectResponse:
+ if current_auth is not None and validate_csrf_token(
+ expected=current_auth.session.csrf_token, actual=csrf_token
+ ):
+ revoke_session(session, auth_session=current_auth.session)
+ logger.info("Revoked authenticated session for user '%s'", current_auth.user.username)
+ else:
+ logger.warning("Rejected logout request due to missing session or invalid CSRF token")
+
+ response = RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
+ response.delete_cookie(settings.auth_session_cookie_name, path="/")
+ return response
+
+
+def _render_login_error(
+ request: Request,
+ *,
+ settings: Settings,
+ status_code: int,
+ error_message: str,
+) -> HTMLResponse:
+ csrf_token = issue_login_csrf_token()
+ response = templates.TemplateResponse(
+ request,
+ "login.html",
+ {
+ "app_name": settings.app_name,
+ "app_env": settings.app_env,
+ "csrf_token": csrf_token,
+ "error_message": error_message,
+ },
+ status_code=status_code,
+ )
+ _set_login_csrf_cookie(response, settings=settings, token=csrf_token)
+ return response
+
+
+def _set_login_csrf_cookie(response: HTMLResponse, *, settings: Settings, token: str) -> None:
+ response.set_cookie(
+ key=LOGIN_CSRF_COOKIE_NAME,
+ value=token,
+ max_age=1800,
+ httponly=True,
+ secure=settings.auth_cookie_secure,
+ samesite="lax",
+ path="/login",
+ )
+
+
+def _render_admin_page(
+ request: Request,
+ *,
+ settings: Settings,
+ current_auth: AuthenticatedSession,
+ status_code: int,
+ password_change_error: str | None,
+) -> HTMLResponse:
+ return templates.TemplateResponse(
+ request,
+ "admin.html",
+ {
+ "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": password_change_error,
+ },
+ status_code=status_code,
+ )
diff --git a/app/api/routes/pages.py b/app/api/routes/pages.py
index 2bca83b..9fc4c25 100644
--- a/app/api/routes/pages.py
+++ b/app/api/routes/pages.py
@@ -1,11 +1,12 @@
from pathlib import Path
-from fastapi import APIRouter, Depends, Request
-from fastapi.responses import HTMLResponse
+from fastapi import APIRouter, Depends, Request, status
+from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from app.config import Settings
-from app.dependencies import get_app_settings
+from app.dependencies import get_app_settings, get_current_auth_session
+from app.services.auth import AuthenticatedSession
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
router = APIRouter(tags=["pages"])
@@ -19,3 +20,23 @@ def home(request: Request, settings: Settings = Depends(get_app_settings)) -> HT
"notion_status": "Legacy scope, removed from the Python rewrite target.",
}
return templates.TemplateResponse(request, "home.html", context)
+
+
+@router.get("/admin", response_class=HTMLResponse)
+def admin_page(
+ request: Request,
+ 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)
+
+ 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,
+ }
+ return templates.TemplateResponse(request, "admin.html", context)
diff --git a/app/auth_db.py b/app/auth_db.py
new file mode 100644
index 0000000..41dcd1f
--- /dev/null
+++ b/app/auth_db.py
@@ -0,0 +1,53 @@
+from collections.abc import Generator
+from functools import lru_cache
+
+from sqlalchemy import create_engine
+from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
+
+from app.config import get_settings
+
+
+class AuthBase(DeclarativeBase):
+ pass
+
+
+def _build_connect_args(database_url: str) -> dict[str, object]:
+ connect_args: dict[str, object] = {}
+ if database_url.startswith("sqlite"):
+ connect_args["check_same_thread"] = False
+ return connect_args
+
+
+@lru_cache
+def _get_auth_engine(database_url: str):
+ return create_engine(database_url, connect_args=_build_connect_args(database_url))
+
+
+@lru_cache
+def _get_auth_session_local(database_url: str):
+ engine = _get_auth_engine(database_url)
+ return sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session)
+
+
+def get_auth_engine():
+ settings = get_settings()
+ return _get_auth_engine(settings.app_database_url)
+
+
+def get_auth_session_local():
+ settings = get_settings()
+ return _get_auth_session_local(settings.app_database_url)
+
+
+def reset_auth_db_caches() -> None:
+ _get_auth_session_local.cache_clear()
+ _get_auth_engine.cache_clear()
+
+
+def get_auth_db_session() -> Generator[Session, None, None]:
+ session_local = get_auth_session_local()
+ session = session_local()
+ try:
+ yield session
+ finally:
+ session.close()
diff --git a/app/config.py b/app/config.py
index 2bc78f4..7c5aa78 100644
--- a/app/config.py
+++ b/app/config.py
@@ -11,6 +11,7 @@ class Settings(BaseSettings):
app_debug: bool = False
app_host: str = "0.0.0.0"
app_port: int = 8000
+ app_database_url: str = "sqlite:///./data/app.db"
location_database_url: str = "sqlite:///./data/locationRecorder.db"
poo_database_url: str = "sqlite:///./data/pooRecorder.db"
@@ -27,6 +28,11 @@ class Settings(BaseSettings):
poo_webhook_id: str = ""
poo_sensor_entity_name: str = "sensor.test_poo_status"
poo_sensor_friendly_name: str = "Poo Status"
+ auth_bootstrap_username: str = "admin"
+ auth_bootstrap_password: str = "admin"
+ auth_session_cookie_name: str = "home_automation_session"
+ auth_session_ttl_hours: int = 12
+ auth_cookie_secure_override: bool | None = None
model_config = SettingsConfigDict(
env_file=".env",
@@ -52,11 +58,23 @@ class Settings(BaseSettings):
def location_sqlite_path(self) -> Path | None:
return self._sqlite_path_from_url(self.location_database_url)
+ @computed_field
+ @property
+ def app_sqlite_path(self) -> Path | None:
+ return self._sqlite_path_from_url(self.app_database_url)
+
@computed_field
@property
def poo_sqlite_path(self) -> Path | None:
return self._sqlite_path_from_url(self.poo_database_url)
+ @computed_field
+ @property
+ def auth_cookie_secure(self) -> bool:
+ if self.auth_cookie_secure_override is not None:
+ return self.auth_cookie_secure_override
+ return not self.is_development
+
@lru_cache
def get_settings() -> Settings:
diff --git a/app/dependencies.py b/app/dependencies.py
index fd6e490..e035990 100644
--- a/app/dependencies.py
+++ b/app/dependencies.py
@@ -1,17 +1,24 @@
from collections.abc import Generator
+from fastapi import Depends, Request
from sqlalchemy.orm import Session
+from app.auth_db import get_auth_db_session
from app.config import Settings, get_settings
from app.db import get_db_session
from app.integrations.homeassistant import HomeAssistantClient
from app.poo_db import get_poo_db_session
+from app.services.auth import AuthenticatedSession, get_authenticated_session
def get_app_settings() -> Settings:
return get_settings()
+def get_auth_db() -> Generator[Session, None, None]:
+ yield from get_auth_db_session()
+
+
def get_db() -> Generator[Session, None, None]:
yield from get_db_session()
@@ -22,3 +29,12 @@ def get_poo_db() -> Generator[Session, None, None]:
def get_homeassistant_client() -> HomeAssistantClient:
return HomeAssistantClient(get_settings())
+
+
+def get_current_auth_session(
+ request: Request,
+ session: Session = Depends(get_auth_db),
+ settings: Settings = Depends(get_app_settings),
+) -> AuthenticatedSession | None:
+ raw_token = request.cookies.get(settings.auth_session_cookie_name)
+ return get_authenticated_session(session, raw_token=raw_token)
diff --git a/app/main.py b/app/main.py
index 50e48b2..60af9be 100644
--- a/app/main.py
+++ b/app/main.py
@@ -3,17 +3,36 @@ from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
+from sqlalchemy.orm import Session
from app import models # noqa: F401
+from app.api.routes.auth import router as auth_router
from app.api.routes import pages, status
+import app.auth_db as auth_db
from app.api.routes.homeassistant import router as homeassistant_router
from app.api.routes.location import router as location_router
from app.api.routes.poo import router as poo_router
from app.config import get_settings
+from app.services.auth import AuthBootstrapError, initialize_auth_schema
+from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db
from scripts.poo_db_adopt import PooDatabaseAdoptionError, validate_poo_runtime_db
+def ensure_auth_db_ready() -> None:
+ session_local = auth_db.get_auth_session_local()
+ session: Session = session_local()
+ try:
+ validate_app_runtime_db(get_settings().app_database_url)
+ initialize_auth_schema(session, get_settings())
+ except AppDatabaseAdoptionError as exc:
+ raise RuntimeError(str(exc)) from exc
+ except AuthBootstrapError as exc:
+ raise RuntimeError(str(exc)) from exc
+ finally:
+ session.close()
+
+
def ensure_location_db_ready() -> None:
settings = get_settings()
if settings.location_sqlite_path is None:
@@ -38,7 +57,7 @@ def ensure_poo_db_ready() -> None:
def ensure_runtime_dirs() -> None:
settings = get_settings()
- for path in (settings.location_sqlite_path, settings.poo_sqlite_path):
+ for path in (settings.app_sqlite_path, settings.location_sqlite_path, settings.poo_sqlite_path):
if path is not None:
path.parent.mkdir(parents=True, exist_ok=True)
@@ -46,6 +65,7 @@ def ensure_runtime_dirs() -> None:
@asynccontextmanager
async def lifespan(_: FastAPI):
ensure_runtime_dirs()
+ ensure_auth_db_ready()
ensure_location_db_ready()
ensure_poo_db_ready()
yield
@@ -68,6 +88,7 @@ def create_app() -> FastAPI:
app.mount("/static", StaticFiles(directory=static_dir), name="static")
app.include_router(status.router)
+ app.include_router(auth_router)
app.include_router(pages.router)
app.include_router(homeassistant_router)
app.include_router(location_router)
diff --git a/app/models/__init__.py b/app/models/__init__.py
index ae09e84..76f3041 100644
--- a/app/models/__init__.py
+++ b/app/models/__init__.py
@@ -1,5 +1,6 @@
"""SQLAlchemy models package."""
+from app.models.auth import AuthSession, AuthUser
from app.models.location import Location
-__all__ = ["Location"]
+__all__ = ["AuthSession", "AuthUser", "Location"]
diff --git a/app/models/auth.py b/app/models/auth.py
new file mode 100644
index 0000000..3284913
--- /dev/null
+++ b/app/models/auth.py
@@ -0,0 +1,33 @@
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.auth_db import AuthBase
+
+
+class AuthUser(AuthBase):
+ __tablename__ = "auth_users"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ username: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
+ password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+ force_password_change: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+
+ sessions: Mapped[list["AuthSession"]] = relationship(back_populates="user")
+
+
+class AuthSession(AuthBase):
+ __tablename__ = "auth_sessions"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ user_id: Mapped[int] = mapped_column(ForeignKey("auth_users.id"), nullable=False, index=True)
+ token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
+ csrf_token: Mapped[str] = mapped_column(String(128), nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+ expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
+ revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+
+ user: Mapped[AuthUser] = relationship(back_populates="sessions")
diff --git a/app/services/auth.py b/app/services/auth.py
new file mode 100644
index 0000000..feaca82
--- /dev/null
+++ b/app/services/auth.py
@@ -0,0 +1,226 @@
+from __future__ import annotations
+
+import base64
+import hashlib
+import logging
+import secrets
+from dataclasses import dataclass
+from datetime import UTC, datetime, timedelta
+
+from sqlalchemy import Select, select
+from sqlalchemy.orm import Session
+
+from app.config import Settings
+from app.models.auth import AuthSession, AuthUser
+
+logger = logging.getLogger(__name__)
+
+SCRYPT_N = 2**14
+SCRYPT_R = 8
+SCRYPT_P = 1
+SCRYPT_DKLEN = 64
+
+
+class AuthBootstrapError(RuntimeError):
+ """Raised when the auth system cannot be safely initialized."""
+
+
+class AuthPasswordChangeError(ValueError):
+ """Raised when a password change request is invalid."""
+
+
+@dataclass(slots=True)
+class AuthenticatedSession:
+ user: AuthUser
+ session: AuthSession
+
+
+def initialize_auth_schema(session: Session, settings: Settings) -> None:
+ has_any_user = session.scalar(select(AuthUser.id).limit(1)) is not None
+ if has_any_user:
+ return
+
+ if not settings.auth_bootstrap_username or not settings.auth_bootstrap_password:
+ raise AuthBootstrapError(
+ "Auth DB has no users. Set AUTH_BOOTSTRAP_USERNAME and "
+ "AUTH_BOOTSTRAP_PASSWORD before starting the app."
+ )
+
+ bootstrap_user = AuthUser(
+ username=settings.auth_bootstrap_username,
+ password_hash=hash_password(settings.auth_bootstrap_password),
+ is_active=True,
+ force_password_change=True,
+ created_at=_utc_now(),
+ )
+ session.add(bootstrap_user)
+ session.commit()
+ logger.warning(
+ "Bootstrapped initial auth user '%s'. Rotate AUTH_BOOTSTRAP_PASSWORD after first setup.",
+ bootstrap_user.username,
+ )
+
+
+def hash_password(password: str) -> str:
+ salt = secrets.token_bytes(16)
+ derived_key = hashlib.scrypt(
+ password.encode("utf-8"),
+ salt=salt,
+ n=SCRYPT_N,
+ r=SCRYPT_R,
+ p=SCRYPT_P,
+ dklen=SCRYPT_DKLEN,
+ )
+ return "$".join(
+ [
+ "scrypt",
+ str(SCRYPT_N),
+ str(SCRYPT_R),
+ str(SCRYPT_P),
+ base64.b64encode(salt).decode("ascii"),
+ base64.b64encode(derived_key).decode("ascii"),
+ ]
+ )
+
+
+def verify_password(password: str, stored_hash: str) -> bool:
+ try:
+ algorithm, n, r, p, encoded_salt, encoded_key = stored_hash.split("$")
+ except ValueError:
+ return False
+
+ if algorithm != "scrypt":
+ return False
+
+ try:
+ salt = base64.b64decode(encoded_salt.encode("ascii"))
+ expected_key = base64.b64decode(encoded_key.encode("ascii"))
+ derived_key = hashlib.scrypt(
+ password.encode("utf-8"),
+ salt=salt,
+ n=int(n),
+ r=int(r),
+ p=int(p),
+ dklen=len(expected_key),
+ )
+ except (ValueError, TypeError):
+ return False
+
+ return secrets.compare_digest(derived_key, expected_key)
+
+
+def authenticate_user(session: Session, *, username: str, password: str) -> AuthUser | None:
+ user = session.scalar(select(AuthUser).where(AuthUser.username == username).limit(1))
+ if user is None or not user.is_active:
+ logger.info("Failed login for unknown or inactive user '%s'", username)
+ return None
+
+ if not verify_password(password, user.password_hash):
+ logger.info("Failed login due to invalid password for user '%s'", username)
+ return None
+
+ return user
+
+
+def create_session(session: Session, *, user: AuthUser, settings: Settings) -> tuple[AuthSession, str]:
+ raw_token = secrets.token_urlsafe(32)
+ auth_session = AuthSession(
+ user_id=user.id,
+ token_hash=_hash_token(raw_token),
+ csrf_token=secrets.token_urlsafe(24),
+ created_at=_utc_now(),
+ expires_at=_utc_now() + timedelta(hours=settings.auth_session_ttl_hours),
+ revoked_at=None,
+ )
+ session.add(auth_session)
+ session.commit()
+ session.refresh(auth_session)
+ return auth_session, raw_token
+
+
+def get_authenticated_session(session: Session, *, raw_token: str | None) -> AuthenticatedSession | None:
+ if not raw_token:
+ return None
+
+ stmt: Select[tuple[AuthSession, AuthUser]] = (
+ select(AuthSession, AuthUser)
+ .join(AuthUser, AuthSession.user_id == AuthUser.id)
+ .where(AuthSession.token_hash == _hash_token(raw_token))
+ .limit(1)
+ )
+ result = session.execute(stmt).first()
+ if result is None:
+ return None
+
+ auth_session, user = result
+ now = _utc_now()
+ expires_at = _as_utc(auth_session.expires_at)
+ revoked_at = _as_utc(auth_session.revoked_at)
+ if revoked_at is not None or expires_at <= now or not user.is_active:
+ if revoked_at is None and expires_at <= now:
+ auth_session.revoked_at = now
+ session.commit()
+ return None
+
+ return AuthenticatedSession(user=user, session=auth_session)
+
+
+def revoke_session(session: Session, *, auth_session: AuthSession) -> None:
+ if auth_session.revoked_at is not None:
+ return
+ auth_session.revoked_at = _utc_now()
+ session.commit()
+
+
+def change_password(
+ session: Session,
+ *,
+ user: AuthUser,
+ current_password: str,
+ new_password: str,
+ confirm_password: str,
+) -> None:
+ if not verify_password(current_password, user.password_hash):
+ raise AuthPasswordChangeError("current password is invalid")
+
+ if not new_password:
+ raise AuthPasswordChangeError("new password must not be empty")
+
+ if new_password != confirm_password:
+ raise AuthPasswordChangeError("new password confirmation does not match")
+
+ if len(new_password) < 8:
+ raise AuthPasswordChangeError("new password must be at least 8 characters long")
+
+ if verify_password(new_password, user.password_hash):
+ raise AuthPasswordChangeError("new password must be different from the current password")
+
+ user.password_hash = hash_password(new_password)
+ user.force_password_change = False
+ session.commit()
+
+
+def issue_login_csrf_token() -> str:
+ return secrets.token_urlsafe(24)
+
+
+def validate_csrf_token(*, expected: str | None, actual: str | None) -> bool:
+ if not expected or not actual:
+ return False
+ return secrets.compare_digest(expected, actual)
+
+
+def _hash_token(raw_token: str) -> str:
+ return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
+
+
+def _utc_now() -> datetime:
+ return datetime.now(UTC)
+
+
+def _as_utc(value: datetime | None) -> datetime | None:
+ if value is None:
+ return None
+ if value.tzinfo is None:
+ return value.replace(tzinfo=UTC)
+ return value.astimezone(UTC)
diff --git a/app/static/styles.css b/app/static/styles.css
index eddcec4..986181b 100644
--- a/app/static/styles.css
+++ b/app/static/styles.css
@@ -83,6 +83,59 @@ a {
color: var(--accent);
}
+.auth-panel {
+ max-width: 520px;
+ margin-inline: auto;
+}
+
+.auth-form,
+.logout-form {
+ display: grid;
+ gap: 16px;
+}
+
+.auth-form label {
+ display: grid;
+ gap: 8px;
+ font-size: 0.95rem;
+ color: var(--muted);
+}
+
+.auth-form input {
+ width: 100%;
+ padding: 12px 14px;
+ border: 1px solid rgba(31, 41, 51, 0.14);
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.92);
+ color: var(--text);
+ font: inherit;
+}
+
+button {
+ width: fit-content;
+ min-width: 120px;
+ padding: 12px 18px;
+ border: none;
+ border-radius: 999px;
+ background: var(--accent);
+ color: white;
+ font: inherit;
+ cursor: pointer;
+}
+
+button:hover {
+ filter: brightness(1.04);
+}
+
+.alert {
+ margin-bottom: 16px;
+ padding: 12px 14px;
+ border-radius: 12px;
+ background: rgba(157, 37, 37, 0.08);
+ border: 1px solid rgba(157, 37, 37, 0.14);
+ color: #8b2a2a;
+}
+
@media (max-width: 640px) {
.shell {
margin: 24px auto;
@@ -92,4 +145,3 @@ a {
padding: 24px;
}
}
-
diff --git a/app/templates/admin.html b/app/templates/admin.html
new file mode 100644
index 0000000..0f82101
--- /dev/null
+++ b/app/templates/admin.html
@@ -0,0 +1,64 @@
+{% extends "base.html" %}
+
+{% block title %}Admin · {{ app_name }}{% endblock %}
+
+{% block content %}
+ Protected Area
+ 首次登录后需要先修改密码。完成后,这里会继续作为未来配置页面的入口。
+
+ 你已经登录。这个页面目前是一个受保护的空白配置占位页,后续会在这里接入配置的增删查改。
+ Admin
+ {% if force_password_change %}
+
Authentication
++ 这个页面只负责当前 Python 重构项目的基础登录能力。配置管理等页面会在后续迭代中接入。 +
+ + {% if error_message %} +