"""认证 API""" import jwt import threading import time from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.orm import Session from ..schemas import LoginRequest, LoginResponse from ..config import ( JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_HOURS, LOGIN_RATE_LIMIT_MAX_ATTEMPTS, LOGIN_RATE_LIMIT_WINDOW_SECONDS, LOGIN_RATE_LIMIT_LOCKOUT_SECONDS, ) from ..deps import get_current_user from ..database import get_db from ..models import User from ..security import hash_password, verify_password router = APIRouter(prefix="/api/auth", tags=["auth"]) _login_attempts: dict[str, tuple[int, float, float]] = {} _login_attempts_lock = threading.Lock() def _create_token(username: str, is_admin: bool) -> str: """生成 JWT token""" payload = { "sub": username, "is_admin": is_admin, "exp": datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS), } return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) def _check_login_rate_limit(client_ip: str) -> None: """按来源 IP 限制登录失败次数,降低在线暴力破解风险。""" now = time.monotonic() with _login_attempts_lock: attempts, first_failure, locked_until = _login_attempts.get(client_ip, (0, now, 0)) if locked_until > now: raise HTTPException(status_code=429, detail="登录失败次数过多,请稍后再试") if now - first_failure > LOGIN_RATE_LIMIT_WINDOW_SECONDS: _login_attempts.pop(client_ip, None) def _record_login_failure(client_ip: str) -> None: now = time.monotonic() with _login_attempts_lock: attempts, first_failure, _ = _login_attempts.get(client_ip, (0, now, 0)) if now - first_failure > LOGIN_RATE_LIMIT_WINDOW_SECONDS: attempts, first_failure = 0, now attempts += 1 locked_until = now + LOGIN_RATE_LIMIT_LOCKOUT_SECONDS if attempts >= LOGIN_RATE_LIMIT_MAX_ATTEMPTS else 0 _login_attempts[client_ip] = (attempts, first_failure, locked_until) def _clear_login_failures(client_ip: str) -> None: with _login_attempts_lock: _login_attempts.pop(client_ip, None) @router.post("/login", response_model=LoginResponse) async def login(credentials: LoginRequest, request: Request, db: Session = Depends(get_db)): """用户登录""" client_ip = request.client.host if request.client else "unknown" _check_login_rate_limit(client_ip) user = db.query(User).filter(User.username == credentials.username).first() valid, needs_upgrade = verify_password(credentials.password, user.password_hash) if user else (False, False) if not user or not valid: _record_login_failure(client_ip) raise HTTPException(status_code=401, detail="用户名或密码错误") if needs_upgrade: user.password_hash = hash_password(credentials.password) db.commit() _clear_login_failures(client_ip) token = _create_token(user.username, user.is_admin) return LoginResponse(token=token, username=user.username, is_admin=user.is_admin) @router.get("/me") async def get_me(user: dict = Depends(get_current_user)): """校验 token 有效性,返回当前用户信息""" return {"username": user["username"], "is_admin": user["is_admin"]}