feat: 增加登录限流和跨进程退出机制
This commit is contained in:
@@ -496,9 +496,13 @@ async function login() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
async function logout() {
|
||||||
clearToken();
|
try {
|
||||||
admin.value = null;
|
await api.logout();
|
||||||
|
} finally {
|
||||||
|
clearToken();
|
||||||
|
admin.value = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function switchMenu(menu: string) {
|
async function switchMenu(menu: string) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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="发送成功")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
SmsCodeService.verify_code(db, phone, code)
|
failure_key = f"auth:sms:fail:{phone}"
|
||||||
|
SecurityStateService.ensure_not_locked(
|
||||||
|
failure_key, limit=5, message="验证码连续错误次数过多,请十五分钟后重新获取"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
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:
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user