Add auth foundation and app DB management

This commit is contained in:
2026-04-20 15:16:47 +02:00
parent 044b47c573
commit e1aad408ab
30 changed files with 1834 additions and 20 deletions
+223
View File
@@ -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,
)
+24 -3
View File
@@ -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)
+53
View File
@@ -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()
+18
View File
@@ -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:
+16
View File
@@ -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)
+22 -1
View File
@@ -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)
+2 -1
View File
@@ -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"]
+33
View File
@@ -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")
+226
View File
@@ -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)
+53 -1
View File
@@ -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;
}
}
+64
View File
@@ -0,0 +1,64 @@
{% 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 %}
+4 -1
View File
@@ -23,6 +23,10 @@
<dt>OpenAPI</dt>
<dd><a href="/docs">/docs</a></dd>
</div>
<div>
<dt>登录</dt>
<dd><a href="/login">/login</a></dd>
</div>
<div>
<dt>Notion</dt>
<dd>{{ notion_status }}</dd>
@@ -30,4 +34,3 @@
</dl>
</section>
{% endblock %}
+33
View File
@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}登录 · {{ app_name }}{% endblock %}
{% block content %}
<section class="panel auth-panel">
<p class="eyebrow">Authentication</p>
<h1>登录</h1>
<p class="lead">
这个页面只负责当前 Python 重构项目的基础登录能力。配置管理等页面会在后续迭代中接入。
</p>
{% if error_message %}
<div class="alert">{{ error_message }}</div>
{% endif %}
<form class="auth-form" method="post" action="/login">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>
<span>Username</span>
<input type="text" name="username" autocomplete="username" required>
</label>
<label>
<span>Password</span>
<input type="password" name="password" autocomplete="current-password" required>
</label>
<button type="submit">登录</button>
</form>
</section>
{% endblock %}