Harden auth risk controls and admin reauth
This commit is contained in:
@@ -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(批次任务截图收集) ====================
|
||||
|
||||
Reference in New Issue
Block a user