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:
@@ -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}
|
||||
Reference in New Issue
Block a user