Files
yunke/yunke-server/app/api/routers/login.py
T
浅念 6880ff1b97 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>
2026-09-03 15:03:20 +08:00

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}