提交
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
"""只登录、不注册的鉴权 API。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response, status
|
||||
@@ -49,6 +51,33 @@ def clear_session_cookie(response: Response) -> None:
|
||||
)
|
||||
|
||||
|
||||
# ---------- 登录按 IP 限速(进程内滑动窗口,无外部依赖) ----------
|
||||
# 补充账户级锁定(auth_max_failed_logins):本层拦多 IP 分布爆破,也稀释
|
||||
# 「故意输错 5 次锁死他人账户」的滥用面。反代部署时 host 是代理 IP,需改读 X-Forwarded-For。
|
||||
_LOGIN_WINDOW = 60.0
|
||||
_LOGIN_MAX_PER_WINDOW = 15
|
||||
_login_attempts: dict[str, deque[float]] = {}
|
||||
_login_gc_at = 0.0
|
||||
|
||||
|
||||
def _login_rate_limited(ip: str) -> bool:
|
||||
"""超限返回 True;未超限记录本次尝试(成功失败都计)。"""
|
||||
global _login_gc_at
|
||||
now = time.monotonic()
|
||||
q = _login_attempts.setdefault(ip, deque())
|
||||
while q and q[0] <= now - _LOGIN_WINDOW:
|
||||
q.popleft()
|
||||
if len(q) >= _LOGIN_MAX_PER_WINDOW:
|
||||
return True
|
||||
q.append(now)
|
||||
if now - _login_gc_at > 3600: # 顺手回收陈旧 entry,防长跑内存增长
|
||||
_login_gc_at = now
|
||||
stale = now - _LOGIN_WINDOW * 10
|
||||
for k in [k for k, v in _login_attempts.items() if not v or v[-1] <= stale]:
|
||||
del _login_attempts[k]
|
||||
return False
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
payload: LoginRequest,
|
||||
@@ -56,6 +85,10 @@ async def login(
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> LoginResponse:
|
||||
ip = request.client.host if request.client else "?"
|
||||
if _login_rate_limited(ip):
|
||||
raise HTTPException(status_code=429, detail="登录尝试过于频繁,请稍后再试")
|
||||
|
||||
now = utcnow()
|
||||
username = payload.username.strip()
|
||||
user = (await db.execute(select(User).where(User.username == username))).scalar_one_or_none()
|
||||
|
||||
Reference in New Issue
Block a user