feat(auth): 登录页(用户名/密码换 API token)+ 前端全链路登录态

后端:
- POST /api/auth/login:admin 账密(Settings 默认 admin/admin123,
  ADMIN_USER/ADMIN_PASSWORD 可覆盖)换取共享 api_token,免 token 中间件
- 防爆破:同 IP 15min 窗口内 5 次失败锁 15min(429),成功登录清零
- 错误信息统一「用户名或密码错误」(防用户名枚举)
- 测试 7 个新增(TDD),全量 500 绿

前端:
- LoginView + 路由守卫(未登录跳 /login,回跳 redirect)
- axios 请求拦截器自动带 Bearer;401 清登录态回登录页
- 媒体直链统一 mediaUrl() 带 ?token=(img/audio 标签发不了 header),
  替换 4 处裸拼;downloadBlob 手动带头
- isLoggedIn 按 key 存在判定 — 后端鉴权关闭时空 token 不锁死登录页
- MainLayout 侧栏底部退出登录

compose:两份编排透传 ADMIN_USER/ADMIN_PASSWORD(默认 admin/admin123)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-03 15:03:20 +08:00
parent 0a5eb49359
commit 6880ff1b97
16 changed files with 460 additions and 9 deletions
+35 -3
View File
@@ -19,15 +19,47 @@ reach it without knowing the token — that exemption is encoded here in
from __future__ import annotations
import secrets
import time
from fastapi import HTTPException, Request, status
from app.config import get_settings
# Request paths that bypass auth even when a token is configured. Keep this
# minimal so the protected surface is easy to reason about — currently just the
# liveness probe.
_PUBLIC_PATHS = frozenset({"/api/health"})
# minimal so the protected surface is easy to reason about — the liveness
# probe and the login endpoint itself (the caller has no token yet).
_PUBLIC_PATHS = frozenset({"/api/health", "/api/auth/login"})
# ---- Brute-force guard for POST /api/auth/login ------------------------- #
# In-memory per-IP failure tracker: MAX failures within WINDOW seconds locks
# the IP out for LOCKOUT seconds. Process-local by design (single-backend
# deployment; a restart simply clears the slate).
_LOGIN_MAX_FAILURES = 5
_LOGIN_FAILURE_WINDOW_S = 15 * 60
_LOGIN_LOCKOUT_S = 15 * 60
# ip -> list of monotonic timestamps of recent failed logins
_login_failures: dict[str, list[float]] = {}
def _client_ip(request: Request) -> str:
"""Best-effort client IP for the lockout key (test transport has none)."""
return request.client.host if request.client else "unknown"
def _login_locked_out(ip: str) -> bool:
"""True when ``ip`` burned through MAX failures inside the window."""
now = time.monotonic()
stamps = [t for t in _login_failures.get(ip, []) if now - t < _LOGIN_FAILURE_WINDOW_S]
_login_failures[ip] = stamps
return len(stamps) >= _LOGIN_MAX_FAILURES
def _record_login_failure(ip: str) -> None:
_login_failures.setdefault(ip, []).append(time.monotonic())
def _reset_login_failures(ip: str) -> None:
_login_failures.pop(ip, None)
async def require_auth(request: Request) -> None: