6880ff1b97
后端: - 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>
62 lines
1.9 KiB
Python
62 lines
1.9 KiB
Python
"""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}
|