Harden auth risk controls and admin reauth

This commit is contained in:
2025-12-26 21:07:47 +08:00
parent f90b0a4f11
commit e3b0c35da6
32 changed files with 741 additions and 92 deletions

View File

@@ -12,6 +12,7 @@ from __future__ import annotations
import threading
import time
import random
from typing import Any, Dict, List, Optional, Tuple
from app_config import get_config
@@ -423,48 +424,291 @@ def cleanup_expired_ip_request_rates(now_ts: Optional[float] = None) -> int:
return removed
# ==================== 登录失败追踪(触发验证码 ====================
# ==================== 登录风控(验证码/限流/延迟/锁定 ====================
_login_failures: Dict[str, Dict[str, Any]] = {}
_login_failures_lock = threading.RLock()
_login_rate_limits: Dict[str, Dict[str, Any]] = {}
_login_rate_limits_lock = threading.RLock()
_login_scan_state: Dict[str, Dict[str, Any]] = {}
_login_scan_lock = threading.RLock()
_login_ip_user_locks: Dict[str, Dict[str, Any]] = {}
_login_ip_user_lock = threading.RLock()
_login_alert_state: Dict[int, Dict[str, Any]] = {}
_login_alert_lock = threading.RLock()
def _normalize_login_key(kind: str, ip_address: str, username: Optional[str] = None) -> str:
ip_key = str(ip_address or "")
user_key = str(username or "").strip().lower()
if kind == "ip":
return f"ip:{ip_key}"
if kind == "user":
return f"user:{user_key}" if user_key else ""
return f"ipuser:{ip_key}:{user_key}" if user_key else ""
def _get_login_captcha_config() -> Tuple[int, int]:
return int(config.LOGIN_CAPTCHA_AFTER_FAILURES), int(config.LOGIN_CAPTCHA_WINDOW_SECONDS)
def record_login_failure(ip_address: str) -> None:
def _get_login_rate_limit_config() -> Tuple[int, int, int, int]:
return (
int(config.LOGIN_IP_MAX_ATTEMPTS),
int(config.LOGIN_USERNAME_MAX_ATTEMPTS),
int(config.LOGIN_IP_USERNAME_MAX_ATTEMPTS),
int(config.LOGIN_RATE_LIMIT_WINDOW_SECONDS),
)
def _get_login_lock_config() -> Tuple[int, int, int]:
return (
int(config.LOGIN_ACCOUNT_LOCK_FAILURES),
int(config.LOGIN_ACCOUNT_LOCK_WINDOW_SECONDS),
int(config.LOGIN_ACCOUNT_LOCK_SECONDS),
)
def _get_login_scan_config() -> Tuple[int, int, int]:
return (
int(config.LOGIN_SCAN_UNIQUE_USERNAME_THRESHOLD),
int(config.LOGIN_SCAN_WINDOW_SECONDS),
int(config.LOGIN_SCAN_COOLDOWN_SECONDS),
)
def _get_or_reset_bucket(data: Optional[Dict[str, Any]], now_ts: float, window_seconds: int) -> Dict[str, Any]:
if not data or (now_ts - float(data.get("window_start", 0) or 0)) > window_seconds:
return {"window_start": now_ts, "count": 0}
return data
def record_login_username_attempt(ip_address: str, username: str) -> bool:
now_ts = time.time()
max_failures, window_seconds = _get_login_captcha_config()
threshold, window_seconds, cooldown_seconds = _get_login_scan_config()
ip_key = str(ip_address or "")
with _login_failures_lock:
data = _login_failures.get(ip_key)
if not data or (now_ts - float(data.get("first_failed", 0) or 0)) > window_seconds:
data = {"first_failed": now_ts, "count": 0}
_login_failures[ip_key] = data
data["count"] = int(data.get("count", 0) or 0) + 1
if int(data["count"]) > max_failures * 5:
data["count"] = max_failures * 5
user_key = str(username or "").strip().lower()
if not ip_key or not user_key:
return False
with _login_scan_lock:
data = _login_scan_state.get(ip_key)
if not data or (now_ts - float(data.get("first_seen", 0) or 0)) > window_seconds:
data = {"first_seen": now_ts, "usernames": set(), "scan_until": 0}
_login_scan_state[ip_key] = data
data["usernames"].add(user_key)
if len(data["usernames"]) >= threshold:
data["scan_until"] = max(float(data.get("scan_until", 0) or 0), now_ts + cooldown_seconds)
return now_ts < float(data.get("scan_until", 0) or 0)
def clear_login_failures(ip_address: str) -> None:
ip_key = str(ip_address or "")
with _login_failures_lock:
_login_failures.pop(ip_key, None)
def check_login_captcha_required(ip_address: str) -> bool:
def is_login_scan_locked(ip_address: str) -> bool:
now_ts = time.time()
max_failures, window_seconds = _get_login_captcha_config()
ip_key = str(ip_address or "")
with _login_failures_lock:
data = _login_failures.get(ip_key)
with _login_scan_lock:
data = _login_scan_state.get(ip_key)
if not data:
return False
if (now_ts - float(data.get("first_failed", 0) or 0)) > window_seconds:
_login_failures.pop(ip_key, None)
if now_ts >= float(data.get("scan_until", 0) or 0):
return False
return int(data.get("count", 0) or 0) >= max_failures
return True
def check_login_rate_limits(ip_address: str, username: str) -> Tuple[bool, Optional[str]]:
now_ts = time.time()
ip_max, user_max, ip_user_max, window_seconds = _get_login_rate_limit_config()
ip_key = _normalize_login_key("ip", ip_address)
user_key = _normalize_login_key("user", "", username)
ip_user_key = _normalize_login_key("ipuser", ip_address, username)
def _check(key: str, max_requests: int) -> Tuple[bool, Optional[str]]:
if not key or max_requests <= 0:
return True, None
data = _get_or_reset_bucket(_login_rate_limits.get(key), now_ts, window_seconds)
if int(data.get("count", 0) or 0) >= max_requests:
remaining = max(1, int(window_seconds - (now_ts - float(data.get("window_start", 0) or 0))))
wait_hint = f"{remaining // 60 + 1}分钟" if remaining >= 60 else f"{remaining}"
return False, f"请求过于频繁,请{wait_hint}后再试"
data["count"] = int(data.get("count", 0) or 0) + 1
_login_rate_limits[key] = data
return True, None
with _login_rate_limits_lock:
allowed, msg = _check(ip_key, ip_max)
if not allowed:
return False, msg
allowed, msg = _check(ip_user_key, ip_user_max)
if not allowed:
return False, msg
allowed, msg = _check(user_key, user_max)
if not allowed:
return False, msg
return True, None
def _update_login_failure(key: str, now_ts: float, window_seconds: int) -> int:
data = _login_failures.get(key)
if not data or (now_ts - float(data.get("first_failed", 0) or 0)) > window_seconds:
data = {"first_failed": now_ts, "count": 0}
_login_failures[key] = data
data["count"] = int(data.get("count", 0) or 0) + 1
return int(data["count"])
def record_login_failure(ip_address: str, username: Optional[str] = None) -> None:
now_ts = time.time()
max_failures, window_seconds = _get_login_captcha_config()
lock_failures, lock_window, lock_seconds = _get_login_lock_config()
ip_key = _normalize_login_key("ip", ip_address)
user_key = _normalize_login_key("user", "", username or "")
ip_user_key = _normalize_login_key("ipuser", ip_address, username or "")
with _login_failures_lock:
ip_count = _update_login_failure(ip_key, now_ts, window_seconds)
user_count = _update_login_failure(user_key, now_ts, window_seconds)
ip_user_count = _update_login_failure(ip_user_key, now_ts, window_seconds)
for key in (ip_key, user_key, ip_user_key):
data = _login_failures.get(key)
if data and int(data.get("count", 0) or 0) > max_failures * 5:
data["count"] = max_failures * 5
if username:
ip_user_lock_key = _normalize_login_key("ipuser", ip_address, username)
with _login_ip_user_lock:
if ip_user_count >= lock_failures:
_login_ip_user_locks[ip_user_lock_key] = {
"lock_until": now_ts + lock_seconds,
"first_failed": now_ts - lock_window,
}
def clear_login_failures(ip_address: str, username: Optional[str] = None) -> None:
ip_key = _normalize_login_key("ip", ip_address)
user_key = _normalize_login_key("user", "", username or "")
ip_user_key = _normalize_login_key("ipuser", ip_address, username or "")
with _login_failures_lock:
_login_failures.pop(ip_key, None)
_login_failures.pop(user_key, None)
_login_failures.pop(ip_user_key, None)
with _login_ip_user_lock:
_login_ip_user_locks.pop(ip_user_key, None)
def _get_login_failure_count(ip_address: str, username: Optional[str] = None) -> int:
now_ts = time.time()
_, window_seconds = _get_login_captcha_config()
ip_user_key = _normalize_login_key("ipuser", ip_address, username or "")
with _login_failures_lock:
data = _login_failures.get(ip_user_key)
if not data:
return 0
if (now_ts - float(data.get("first_failed", 0) or 0)) > window_seconds:
_login_failures.pop(ip_user_key, None)
return 0
return int(data.get("count", 0) or 0)
def check_login_captcha_required(ip_address: str, username: Optional[str] = None) -> bool:
now_ts = time.time()
max_failures, window_seconds = _get_login_captcha_config()
ip_key = _normalize_login_key("ip", ip_address)
ip_user_key = _normalize_login_key("ipuser", ip_address, username or "")
with _login_failures_lock:
ip_data = _login_failures.get(ip_key)
if ip_data and (now_ts - float(ip_data.get("first_failed", 0) or 0)) <= window_seconds:
if int(ip_data.get("count", 0) or 0) >= max_failures:
return True
ip_user_data = _login_failures.get(ip_user_key)
if ip_user_data and (now_ts - float(ip_user_data.get("first_failed", 0) or 0)) <= window_seconds:
if int(ip_user_data.get("count", 0) or 0) >= max_failures:
return True
if is_login_scan_locked(ip_address):
return True
return False
def check_login_ip_user_locked(ip_address: str, username: Optional[str]) -> Tuple[bool, int]:
now_ts = time.time()
if not username:
return False, 0
ip_user_key = _normalize_login_key("ipuser", ip_address, username)
with _login_ip_user_lock:
data = _login_ip_user_locks.get(ip_user_key)
if not data:
return False, 0
lock_until = float(data.get("lock_until", 0) or 0)
if now_ts >= lock_until:
_login_ip_user_locks.pop(ip_user_key, None)
return False, 0
remaining = int(lock_until - now_ts)
return True, max(1, remaining)
def get_login_failure_delay_seconds(ip_address: str, username: Optional[str]) -> float:
fail_count = _get_login_failure_count(ip_address, username)
if fail_count <= 0:
return 0.0
base_ms = max(0, int(config.LOGIN_FAIL_DELAY_BASE_MS))
max_ms = max(base_ms, int(config.LOGIN_FAIL_DELAY_MAX_MS))
delay_ms = min(max_ms, int(base_ms * (1.6 ** max(0, fail_count - 1))))
jitter = random.randint(0, max(50, int(base_ms * 0.3)))
return float(delay_ms + jitter) / 1000.0
def should_send_login_alert(user_id: int, ip_address: str) -> bool:
now_ts = time.time()
min_interval = int(config.LOGIN_ALERT_MIN_INTERVAL_SECONDS)
with _login_alert_lock:
data = _login_alert_state.get(int(user_id))
if not data:
_login_alert_state[int(user_id)] = {"last_sent": now_ts, "last_ip": ip_address}
return True
last_sent = float(data.get("last_sent", 0) or 0)
last_ip = str(data.get("last_ip", "") or "")
if ip_address and ip_address != last_ip:
_login_alert_state[int(user_id)] = {"last_sent": now_ts, "last_ip": ip_address}
return True
if (now_ts - last_sent) >= min_interval:
_login_alert_state[int(user_id)] = {"last_sent": now_ts, "last_ip": ip_address}
return True
return False
# ==================== 邮箱维度限流 ====================
_email_rate_limit: Dict[str, Dict[str, Any]] = {}
_email_rate_limit_lock = threading.RLock()
def check_email_rate_limit(email: str, action: str) -> Tuple[bool, Optional[str]]:
now_ts = time.time()
max_requests = int(config.EMAIL_RATE_LIMIT_MAX)
window_seconds = int(config.EMAIL_RATE_LIMIT_WINDOW_SECONDS)
email_key = str(email or "").strip().lower()
if not email_key:
return True, None
key = f"{action}:{email_key}"
with _email_rate_limit_lock:
data = _get_or_reset_bucket(_email_rate_limit.get(key), now_ts, window_seconds)
if int(data.get("count", 0) or 0) >= max_requests:
remaining = max(1, int(window_seconds - (now_ts - float(data.get("window_start", 0) or 0))))
wait_hint = f"{remaining // 60 + 1}分钟" if remaining >= 60 else f"{remaining}"
return False, f"请求过于频繁,请{wait_hint}后再试"
data["count"] = int(data.get("count", 0) or 0) + 1
_email_rate_limit[key] = data
return True, None
# ==================== Batch screenshots批次任务截图收集 ====================