"""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}