"""登录防爆破:内存滑动窗口限速(公网暴露前的最低防线)。 - 按 (IP, 用户名) 组合计数,失败 5 次 / 10 分钟 → 锁定 15 分钟; - 同一 IP 全局失败 30 次 / 10 分钟 → 锁定 15 分钟(防换用户名扫号); - 成功登录清零该组合的计数。 内存实现,单进程部署足够;重启即清零(可接受的测试期取舍)。 """ from __future__ import annotations import threading import time from fastapi import HTTPException FAIL_WINDOW_SECONDS = 600 # 10 分钟滑动窗口 FAIL_LIMIT_PER_KEY = 5 # 同 IP+用户名 失败上限 FAIL_LIMIT_PER_IP = 30 # 同 IP 全局失败上限 LOCK_SECONDS = 900 # 锁定时长 _lock = threading.Lock() _fails: dict[str, list[float]] = {} # key -> 失败时间戳列表 _locked_until: dict[str, float] = {} # key -> 锁定截止时间 def _prune(now: float) -> None: cutoff = now - FAIL_WINDOW_SECONDS for key in list(_fails): rows = [t for t in _fails[key] if t > cutoff] if rows: _fails[key] = rows else: _fails.pop(key, None) for key in list(_locked_until): if _locked_until[key] < now: _locked_until.pop(key, None) def _guard_locked(key: str, now: float) -> None: until = _locked_until.get(key) if until and until > now: remain = int((until - now) / 60) + 1 raise HTTPException(429, f"尝试次数过多,请约 {remain} 分钟后再试") def check_login_allowed(ip: str, username: str) -> None: """登录前调用:命中锁定则抛 429。""" now = time.time() with _lock: _prune(now) _guard_locked(f"key:{ip}:{username}", now) _guard_locked(f"ip:{ip}", now) def record_login_failure(ip: str, username: str) -> None: """登录失败后调用:达到阈值即锁定。""" now = time.time() with _lock: _prune(now) key = f"key:{ip}:{username}" _fails.setdefault(key, []).append(now) if len(_fails[key]) >= FAIL_LIMIT_PER_KEY: _locked_until[key] = now + LOCK_SECONDS ip_key = f"ip:{ip}" _fails.setdefault(ip_key, []).append(now) if len(_fails[ip_key]) >= FAIL_LIMIT_PER_IP: _locked_until[ip_key] = now + LOCK_SECONDS def record_login_success(ip: str, username: str) -> None: """登录成功后清零该组合计数(IP 级计数保留)。""" with _lock: _fails.pop(f"key:{ip}:{username}", None) _locked_until.pop(f"key:{ip}:{username}", None) def client_ip(request) -> str: """优先取反代/隧道传递的真实 IP。""" forwarded = request.headers.get("x-forwarded-for") if request else None if forwarded: return forwarded.split(",")[0].strip() real = request.headers.get("x-real-ip") if request else None if real: return real.strip() client = request.client if request else None return client.host if client else "unknown" __all__ = ["check_login_allowed", "record_login_failure", "record_login_success", "client_ip"]