feat: 增加登录限流和跨进程退出机制

This commit is contained in:
2026-07-10 11:47:28 +08:00
parent 5aca872129
commit aedecb3aab
7 changed files with 180 additions and 16 deletions

View File

@@ -496,9 +496,13 @@ async function login() {
} }
} }
function logout() { async function logout() {
try {
await api.logout();
} finally {
clearToken(); clearToken();
admin.value = null; admin.value = null;
}
} }
async function switchMenu(menu: string) { async function switchMenu(menu: string) {

View File

@@ -84,6 +84,7 @@ export const api = {
method: "POST", method: "POST",
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),
}), }),
logout: () => request<null>("/admin/logout", { method: "POST", body: "{}" }),
profile: () => request<AdminProfile>("/admin/profile"), profile: () => request<AdminProfile>("/admin/profile"),
dashboard: (start?: string, end?: string) => { dashboard: (start?: string, end?: string) => {
const params = new URLSearchParams(); const params = new URLSearchParams();

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
@@ -9,16 +9,25 @@ from app.core.responses import api_success
from app.models.admin import Admin from app.models.admin import Admin
from app.schemas.admin import AdminLoginRequest, AdminLoginResponse, AdminRead from app.schemas.admin import AdminLoginRequest, AdminLoginResponse, AdminRead
from app.services.admin_service import AdminAuthService from app.services.admin_service import AdminAuthService
from app.services.auth_service import AuthService
from app.services.security_state_service import client_ip
from app.core.dependencies import get_current_token_payload
router = APIRouter() router = APIRouter()
@router.post("/login") @router.post("/login")
def login(payload: AdminLoginRequest, db: Session = Depends(get_db)) -> dict: def login(payload: AdminLoginRequest, request: Request, db: Session = Depends(get_db)) -> dict:
result = AdminAuthService.login(db, payload.username, payload.password) result = AdminAuthService.login(db, payload.username, payload.password, client_ip(request))
return api_success(AdminLoginResponse.model_validate(result).model_dump(mode="json")) return api_success(AdminLoginResponse.model_validate(result).model_dump(mode="json"))
@router.get("/profile") @router.get("/profile")
def profile(current_admin: Admin = Depends(get_current_admin)) -> dict: def profile(current_admin: Admin = Depends(get_current_admin)) -> dict:
return api_success(AdminRead.model_validate(current_admin).model_dump()) return api_success(AdminRead.model_validate(current_admin).model_dump())
@router.post("/logout")
def logout(token_payload: dict = Depends(get_current_token_payload)) -> dict:
AuthService.logout(token_payload)
return api_success()

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
@@ -9,6 +9,7 @@ from app.core.responses import api_success
from app.schemas.auth import CaptchaResponse, LoginRequest, LoginResponse, SendSmsRequest from app.schemas.auth import CaptchaResponse, LoginRequest, LoginResponse, SendSmsRequest
from app.services.auth_service import AuthService from app.services.auth_service import AuthService
from app.services.captcha_service import CaptchaService from app.services.captcha_service import CaptchaService
from app.services.security_state_service import client_ip
router = APIRouter() router = APIRouter()
@@ -19,8 +20,8 @@ def captcha() -> dict:
@router.post("/sms/send") @router.post("/sms/send")
def send_sms(payload: SendSmsRequest, db: Session = Depends(get_db)) -> dict: def send_sms(payload: SendSmsRequest, request: Request, db: Session = Depends(get_db)) -> dict:
AuthService.send_sms_code(db, payload.phone, payload.captchaId, payload.captchaCode) AuthService.send_sms_code(db, payload.phone, payload.captchaId, payload.captchaCode, client_ip(request))
return api_success(message="发送成功") return api_success(message="发送成功")

View File

@@ -13,6 +13,7 @@ from app.models.chat import ChatMessage, ChatSession
from app.models.knowledge import Knowledge from app.models.knowledge import Knowledge
from app.models.logs import AiRequestLog, OperationLog from app.models.logs import AiRequestLog, OperationLog
from app.models.user import User from app.models.user import User
from app.services.security_state_service import SecurityStateService
DEVELOPMENT_ENVS = {"local", "dev", "development", "docker", "test", "testing"} DEVELOPMENT_ENVS = {"local", "dev", "development", "docker", "test", "testing"}
@@ -55,13 +56,27 @@ def _get_bootstrap_admin_config() -> tuple[str, str, str]:
class AdminAuthService: class AdminAuthService:
@classmethod @classmethod
def login(cls, db: Session, username: str, password: str) -> dict: def login(cls, db: Session, username: str, password: str, ip: str = "unknown") -> dict:
failure_key = f"auth:admin:fail:{username.lower()}:{ip}"
SecurityStateService.enforce_limit(
f"rate:admin_login:ip:{ip}", limit=30, window_seconds=600, message="登录请求过于频繁,请稍后再试"
)
SecurityStateService.ensure_not_locked(
failure_key, limit=5, message="账号或当前网络连续登录失败次数过多,请十五分钟后再试"
)
cls.ensure_bootstrap_admin(db) cls.ensure_bootstrap_admin(db)
admin = db.scalar(select(Admin).where(Admin.username == username)) admin = db.scalar(select(Admin).where(Admin.username == username))
if admin is None or not verify_password(password, admin.password): if admin is None or not verify_password(password, admin.password):
SecurityStateService.record_failure(
failure_key,
limit=5,
lock_seconds=900,
message="账号或当前网络连续登录失败次数过多,请十五分钟后再试",
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="管理员账号或密码错误") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="管理员账号或密码错误")
if admin.status != 1: if admin.status != 1:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="管理员已禁用") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="管理员已禁用")
SecurityStateService.clear(failure_key)
admin.last_login_at = datetime.now(UTC).replace(tzinfo=None) admin.last_login_at = datetime.now(UTC).replace(tzinfo=None)
db.add(admin) db.add(admin)

View File

@@ -10,21 +10,40 @@ from app.core.security import create_access_token
from app.models.user import User from app.models.user import User
from app.services.captcha_service import CaptchaService from app.services.captcha_service import CaptchaService
from app.services.sms_code_service import SmsCodeService from app.services.sms_code_service import SmsCodeService
from app.services.security_state_service import SecurityStateService
class AuthService: class AuthService:
_revoked_token_jti: set[str] = set()
@classmethod @classmethod
def send_sms_code(cls, db: Session, phone: str, captcha_id: str, captcha_code: str) -> None: def send_sms_code(cls, db: Session, phone: str, captcha_id: str, captcha_code: str, ip: str) -> None:
CaptchaService.verify(captcha_id, captcha_code) CaptchaService.verify(captcha_id, captcha_code)
user = cls._get_existing_user(db, phone) user = cls._get_existing_user(db, phone)
cls._ensure_user_can_login(user) cls._ensure_user_can_login(user)
SecurityStateService.enforce_limit(
f"rate:sms:ip:{ip}", limit=20, window_seconds=3600, message="当前网络发送验证码过于频繁,请稍后再试"
)
SecurityStateService.enforce_limit(
f"rate:sms:phone:{phone}", limit=1, window_seconds=60, message="验证码发送过于频繁,请一分钟后再试"
)
SmsCodeService.send_code(db, phone) SmsCodeService.send_code(db, phone)
@classmethod @classmethod
def login_with_sms(cls, db: Session, phone: str, code: str) -> dict: def login_with_sms(cls, db: Session, phone: str, code: str) -> dict:
failure_key = f"auth:sms:fail:{phone}"
SecurityStateService.ensure_not_locked(
failure_key, limit=5, message="验证码连续错误次数过多,请十五分钟后重新获取"
)
try:
SmsCodeService.verify_code(db, phone, code) SmsCodeService.verify_code(db, phone, code)
except HTTPException:
SecurityStateService.record_failure(
failure_key,
limit=5,
lock_seconds=900,
message="验证码连续错误次数过多,请十五分钟后重新获取",
)
raise
SecurityStateService.clear(failure_key)
user = cls._get_existing_user(db, phone) user = cls._get_existing_user(db, phone)
cls._ensure_user_can_login(user) cls._ensure_user_can_login(user)
@@ -41,12 +60,12 @@ class AuthService:
def logout(cls, token_payload: dict) -> None: def logout(cls, token_payload: dict) -> None:
jti = token_payload.get("jti") jti = token_payload.get("jti")
if jti: if jti:
cls._revoked_token_jti.add(str(jti)) SecurityStateService.revoke_token(str(jti), token_payload.get("exp"))
@classmethod @classmethod
def is_token_revoked(cls, token_payload: dict) -> bool: def is_token_revoked(cls, token_payload: dict) -> bool:
jti = token_payload.get("jti") jti = token_payload.get("jti")
return bool(jti and str(jti) in cls._revoked_token_jti) return bool(jti and SecurityStateService.is_token_revoked(str(jti)))
@classmethod @classmethod
def _get_existing_user(cls, db: Session, phone: str) -> User: def _get_existing_user(cls, db: Session, phone: str) -> User:

View File

@@ -0,0 +1,115 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from threading import Lock
import time
from fastapi import HTTPException, status
from app.services.redis_client import get_sync_redis_client
@dataclass
class _Counter:
value: int
expires_at: float
class SecurityStateService:
_counters: dict[str, _Counter] = {}
_lock = Lock()
@classmethod
def enforce_limit(cls, key: str, *, limit: int, window_seconds: int, message: str) -> None:
count = cls._increment(key, window_seconds)
if count > limit:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=message)
@classmethod
def record_failure(cls, key: str, *, limit: int, lock_seconds: int, message: str) -> None:
count = cls._increment(key, lock_seconds)
if count >= limit:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=message)
@classmethod
def ensure_not_locked(cls, key: str, *, limit: int, message: str) -> None:
if cls._get(key) >= limit:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=message)
@classmethod
def clear(cls, key: str) -> None:
client = get_sync_redis_client()
if client is not None:
try:
client.delete(key)
except Exception:
pass
with cls._lock:
cls._counters.pop(key, None)
@classmethod
def revoke_token(cls, jti: str, expires_at: int | float | None) -> None:
now = datetime.now(UTC)
expiry = datetime.fromtimestamp(float(expires_at), UTC) if expires_at else now + timedelta(days=1)
ttl = max(1, int((expiry - now).total_seconds()))
key = f"auth:revoked:{jti}"
client = get_sync_redis_client()
if client is not None:
try:
client.set(key, "1", ex=ttl)
return
except Exception:
pass
with cls._lock:
cls._counters[key] = _Counter(1, time.monotonic() + ttl)
@classmethod
def is_token_revoked(cls, jti: str) -> bool:
return cls._get(f"auth:revoked:{jti}") > 0
@classmethod
def _increment(cls, key: str, ttl: int) -> int:
client = get_sync_redis_client()
if client is not None:
try:
pipe = client.pipeline()
pipe.incr(key)
pipe.expire(key, ttl, nx=True)
value, _ = pipe.execute()
return int(value)
except Exception:
pass
now = time.monotonic()
with cls._lock:
record = cls._counters.get(key)
if record is None or record.expires_at <= now:
record = _Counter(0, now + ttl)
record.value += 1
cls._counters[key] = record
return record.value
@classmethod
def _get(cls, key: str) -> int:
client = get_sync_redis_client()
if client is not None:
try:
value = client.get(key)
return int(value) if value is not None else 0
except Exception:
pass
now = time.monotonic()
with cls._lock:
record = cls._counters.get(key)
if record is None or record.expires_at <= now:
cls._counters.pop(key, None)
return 0
return record.value
def client_ip(request) -> str:
forwarded = request.headers.get("x-forwarded-for", "").split(",", 1)[0].strip()
if forwarded:
return forwarded
return request.client.host if request.client else "unknown"