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:
@@ -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:
|
||||
|
||||
@@ -46,6 +46,7 @@ from app.api.routers import (
|
||||
customer,
|
||||
export,
|
||||
groups,
|
||||
login,
|
||||
media,
|
||||
messages,
|
||||
moments,
|
||||
@@ -82,6 +83,7 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
app.include_router(customer.router)
|
||||
app.include_router(login.router)
|
||||
app.include_router(media.router)
|
||||
app.include_router(organization.router)
|
||||
app.include_router(messages.router)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""POST /api/auth/login — username/password sign-in for the SPA.
|
||||
|
||||
Exchanges admin credentials (``Settings.admin_user`` / ``admin_password``,
|
||||
env-overridable) for the shared ``api_token`` so browser users never have to
|
||||
handle the raw token string. The endpoint is exempt from the shared-token
|
||||
middleware (see ``app.api.auth._PUBLIC_PATHS``) and rate-limited per IP by the
|
||||
brute-force guard in ``app.api.auth``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.api.auth import (
|
||||
_client_ip,
|
||||
_login_locked_out,
|
||||
_record_login_failure,
|
||||
_reset_login_failures,
|
||||
)
|
||||
from app.config import get_settings
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
class LoginBody(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(body: LoginBody, request: Request) -> dict[str, str]:
|
||||
settings = get_settings()
|
||||
ip = _client_ip(request)
|
||||
|
||||
if _login_locked_out(ip):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="尝试次数过多,请 15 分钟后再试",
|
||||
)
|
||||
|
||||
credentials_ok = secrets.compare_digest(
|
||||
body.username.encode("utf-8"), settings.admin_user.encode("utf-8")
|
||||
) and secrets.compare_digest(
|
||||
body.password.encode("utf-8"), settings.admin_password.encode("utf-8")
|
||||
)
|
||||
if not credentials_ok:
|
||||
# Same generic message for unknown user / wrong password (no
|
||||
# user enumeration).
|
||||
_record_login_failure(ip)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户名或密码错误",
|
||||
)
|
||||
|
||||
_reset_login_failures(ip)
|
||||
# Empty token when auth is disabled — the SPA stores it and the request
|
||||
# interceptor simply skips the empty value.
|
||||
return {"token": settings.api_token}
|
||||
@@ -34,6 +34,13 @@ class Settings(BaseSettings):
|
||||
# <token>`` or ``?token=<token>``. See app/api/auth.py.
|
||||
api_token: str = ""
|
||||
|
||||
# Username/password for the SPA login page (``POST /api/auth/login``).
|
||||
# On success the login hands out the ``api_token`` above so the SPA can
|
||||
# act as a Bearer client; defaults keep local dev zero-config. Change both
|
||||
# in prod via ADMIN_USER / ADMIN_PASSWORD.
|
||||
admin_user: str = "admin"
|
||||
admin_password: str = "admin123"
|
||||
|
||||
# Comma-separated list of allowed CORS origins for the viewing API. Empty
|
||||
# (default) -> only the dev fallback (``http://localhost:5173``) is allowed;
|
||||
# set ``CORS_ORIGINS=https://app.example.com`` in prod to lock cross-origin
|
||||
|
||||
Reference in New Issue
Block a user