16efa44ffb
Also fix flat-side reconcile to check both long and short residuals; document in 更新说明. Co-authored-by: Cursor <cursoragent@cursor.com>
142 lines
4.4 KiB
Python
142 lines
4.4 KiB
Python
from __future__ import annotations
|
|
|
|
import hmac
|
|
import time
|
|
from collections import defaultdict
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
from pydantic import BaseModel, Field
|
|
|
|
from ..config import Settings, get_settings
|
|
from ..credentials import get_credentials, update_credentials, upsert_env_file
|
|
from .auth import LoginRequest, LoginResponse, issue_token, require_user
|
|
|
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
|
|
_login_hits: dict[str, list[float]] = defaultdict(list)
|
|
|
|
|
|
class ChangeCredentialsRequest(BaseModel):
|
|
current_password: str = Field(min_length=1)
|
|
new_username: str = Field(min_length=1, max_length=64)
|
|
new_password: str = Field(min_length=8, max_length=128)
|
|
|
|
|
|
def _client_ip(request: Request) -> str:
|
|
xff = request.headers.get("x-forwarded-for") or ""
|
|
if xff.strip():
|
|
return xff.split(",")[0].strip()
|
|
if request.client:
|
|
return request.client.host or "unknown"
|
|
return "unknown"
|
|
|
|
|
|
def _rate_limit_login(ip: str, settings: Settings) -> None:
|
|
now = time.time()
|
|
window = float(settings.login_window_sec)
|
|
max_n = int(settings.login_max_attempts)
|
|
hits = [t for t in _login_hits[ip] if now - t < window]
|
|
_login_hits[ip] = hits
|
|
if len(hits) >= max_n:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
detail=f"登录过于频繁,请 {int(window)} 秒后再试",
|
|
)
|
|
|
|
|
|
@router.post("/login", response_model=LoginResponse)
|
|
async def login(
|
|
body: LoginRequest,
|
|
request: Request,
|
|
settings: Annotated[Settings, Depends(get_settings)],
|
|
) -> LoginResponse:
|
|
ip = _client_ip(request)
|
|
_rate_limit_login(ip, settings)
|
|
user, pwd = get_credentials()
|
|
user_ok = hmac.compare_digest(
|
|
body.username.encode("utf-8"), user.encode("utf-8")
|
|
)
|
|
pwd_ok = hmac.compare_digest(
|
|
body.password.encode("utf-8"), pwd.encode("utf-8")
|
|
)
|
|
if not (user_ok and pwd_ok):
|
|
_login_hits[ip].append(time.time())
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误"
|
|
)
|
|
_login_hits.pop(ip, None)
|
|
token, ttl = issue_token(body.username, settings)
|
|
return LoginResponse(
|
|
token=token,
|
|
username=body.username,
|
|
expires_in=ttl,
|
|
env_name=settings.env_name,
|
|
mode=settings.mode,
|
|
)
|
|
|
|
|
|
@router.get("/me")
|
|
async def me(
|
|
username: Annotated[str, Depends(require_user)],
|
|
settings: Annotated[Settings, Depends(get_settings)],
|
|
) -> dict:
|
|
return {
|
|
"username": username,
|
|
"env_name": settings.env_name,
|
|
"mode": settings.mode,
|
|
"sim": settings.is_sim,
|
|
}
|
|
|
|
|
|
@router.post("/refresh", response_model=LoginResponse)
|
|
async def refresh_token(
|
|
username: Annotated[str, Depends(require_user)],
|
|
settings: Annotated[Settings, Depends(get_settings)],
|
|
) -> LoginResponse:
|
|
"""用仍有效的 Bearer 换发新 HMAC token(自动轮换,无需重登)。"""
|
|
token, ttl = issue_token(username, settings)
|
|
return LoginResponse(
|
|
token=token,
|
|
username=username,
|
|
expires_in=ttl,
|
|
env_name=settings.env_name,
|
|
mode=settings.mode,
|
|
)
|
|
|
|
|
|
@router.post("/change-credentials", response_model=LoginResponse)
|
|
async def change_credentials(
|
|
body: ChangeCredentialsRequest,
|
|
username: Annotated[str, Depends(require_user)],
|
|
settings: Annotated[Settings, Depends(get_settings)],
|
|
) -> LoginResponse:
|
|
_cur_user, cur_pwd = get_credentials()
|
|
if not hmac.compare_digest(
|
|
body.current_password.encode("utf-8"), cur_pwd.encode("utf-8")
|
|
):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST, detail="当前密码不正确"
|
|
)
|
|
try:
|
|
update_credentials(
|
|
new_username=body.new_username, new_password=body.new_password
|
|
)
|
|
# 作废旧 token
|
|
new_ver = int(settings.auth_token_version) + 1
|
|
upsert_env_file("AUTH_TOKEN_VERSION", str(new_ver))
|
|
get_settings.cache_clear()
|
|
except ValueError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
|
|
) from e
|
|
settings2 = get_settings()
|
|
token, ttl = issue_token(body.new_username.strip(), settings2)
|
|
return LoginResponse(
|
|
token=token,
|
|
username=body.new_username.strip(),
|
|
expires_in=ttl,
|
|
env_name=settings2.env_name,
|
|
mode=settings2.mode,
|
|
)
|