mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-31 12:24:39 +08:00
152 lines
5.4 KiB
Python
152 lines
5.4 KiB
Python
"""
|
||
密码登录的连续失败计数与指数退避锁定策略。
|
||
"""
|
||
|
||
import math
|
||
import threading
|
||
import time
|
||
from collections.abc import Callable
|
||
from dataclasses import dataclass
|
||
from typing import Optional
|
||
|
||
# 连续失败达到该次数后开始锁定
|
||
LOGIN_MAX_FAILURES = 5
|
||
# 首次锁定时长,之后每再失败一次翻倍
|
||
LOGIN_BASE_LOCK_SECONDS = 30
|
||
# 单次锁定上限
|
||
LOGIN_MAX_LOCK_SECONDS = 15 * 60
|
||
# 记录在最后一次失败后保留多久
|
||
LOGIN_THROTTLE_TTL_SECONDS = 60 * 60
|
||
# 内存记录上限,超出后按最近更新时间淘汰最旧
|
||
LOGIN_THROTTLE_MAX_ITEMS = 4096
|
||
# 指数上限,避免 2 ** n 无意义增长
|
||
_MAX_BACKOFF_EXPONENT = 20
|
||
|
||
LoginThrottleKey = tuple[str, str]
|
||
|
||
|
||
def build_login_throttle_key(client_host: Optional[str], username: Optional[str]) -> LoginThrottleKey:
|
||
"""
|
||
构造登录限流键:(客户端地址, 归一化用户名)。
|
||
|
||
只使用直连客户端地址,不解析 X-Forwarded-For;没有可信代理配置时盲信该头会让
|
||
攻击者换头绕过。同机反代场景下所有请求同地址,键退化为按用户名限流,这正是
|
||
暴力破解防护需要的维度;直接暴露服务时复合键避免单一地址扫号锁死所有用户。
|
||
"""
|
||
return str(client_host or "").strip(), str(username or "").strip().lower()
|
||
|
||
|
||
@dataclass
|
||
class _Entry:
|
||
"""
|
||
单个键的失败计数与锁定状态。
|
||
|
||
:param failures: 该客户端与用户名组合累计的连续失败次数
|
||
:param locked_until: 锁定结束的单调时钟时间,单位为秒,默认 0 表示未锁定
|
||
:param updated_at: 最近失败时的单调时钟时间,单位为秒,用于过期和淘汰
|
||
"""
|
||
|
||
failures: int = 0
|
||
locked_until: float = 0.0
|
||
updated_at: float = 0.0
|
||
|
||
|
||
class LoginThrottle:
|
||
"""
|
||
按 (客户端地址, 用户名) 记录连续失败并施加指数锁定的进程内限流器。
|
||
|
||
连续失败达到 ``max_failures`` 后锁定 ``base_lock_seconds``,之后每再失败一次
|
||
锁定时长翻倍直到 ``max_lock_seconds``;登录成功清除记录。状态只在当前进程
|
||
内存中,多个工作进程之间不共享。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
max_failures: int = LOGIN_MAX_FAILURES,
|
||
base_lock_seconds: int = LOGIN_BASE_LOCK_SECONDS,
|
||
max_lock_seconds: int = LOGIN_MAX_LOCK_SECONDS,
|
||
ttl_seconds: int = LOGIN_THROTTLE_TTL_SECONDS,
|
||
max_items: int = LOGIN_THROTTLE_MAX_ITEMS,
|
||
clock: Callable[[], float] = time.monotonic,
|
||
) -> None:
|
||
"""
|
||
配置失败锁定阈值与单调时钟,初始化受锁保护的进程内记录。
|
||
|
||
时间和记录数量至少为 1;锁定上限不得低于首次锁定时长。
|
||
"""
|
||
self._max_failures = max(1, int(max_failures))
|
||
self._base_lock_seconds = max(1, int(base_lock_seconds))
|
||
self._max_lock_seconds = max(self._base_lock_seconds, int(max_lock_seconds))
|
||
self._ttl_seconds = max(1, int(ttl_seconds))
|
||
self._max_items = max(1, int(max_items))
|
||
self._clock = clock
|
||
self._entries: dict[LoginThrottleKey, _Entry] = {}
|
||
self._lock = threading.RLock()
|
||
|
||
def retry_after(self, key: LoginThrottleKey) -> int:
|
||
"""
|
||
返回该键仍需等待的秒数(向上取整),0 表示允许尝试。
|
||
"""
|
||
now = self._clock()
|
||
with self._lock:
|
||
self._cleanup(now)
|
||
entry = self._entries.get(key)
|
||
if entry is None:
|
||
return 0
|
||
remaining = entry.locked_until - now
|
||
return math.ceil(remaining) if remaining > 0 else 0
|
||
|
||
def record_failure(self, key: LoginThrottleKey) -> int:
|
||
"""
|
||
记录一次失败,返回本次施加的锁定秒数,0 表示尚未达到锁定阈值。
|
||
"""
|
||
now = self._clock()
|
||
with self._lock:
|
||
self._cleanup(now)
|
||
entry = self._entries.setdefault(key, _Entry())
|
||
entry.failures += 1
|
||
entry.updated_at = now
|
||
lock_seconds = 0
|
||
if entry.failures >= self._max_failures:
|
||
exponent = min(entry.failures - self._max_failures, _MAX_BACKOFF_EXPONENT)
|
||
lock_seconds = min(self._base_lock_seconds * (2 ** exponent), self._max_lock_seconds)
|
||
entry.locked_until = now + lock_seconds
|
||
self._cleanup(now)
|
||
return lock_seconds
|
||
|
||
def record_success(self, key: LoginThrottleKey) -> None:
|
||
"""
|
||
登录成功后清除该键的失败记录。
|
||
"""
|
||
with self._lock:
|
||
self._entries.pop(key, None)
|
||
|
||
def _cleanup(self, now: float) -> None:
|
||
"""
|
||
清理过期记录,并在超出容量时淘汰最久未更新的记录。
|
||
"""
|
||
expired = [
|
||
key
|
||
for key, entry in self._entries.items()
|
||
if entry.locked_until <= now and now - entry.updated_at > self._ttl_seconds
|
||
]
|
||
for key in expired:
|
||
self._entries.pop(key, None)
|
||
overflow = len(self._entries) - self._max_items
|
||
if overflow <= 0:
|
||
return
|
||
ordered = sorted(self._entries.items(), key=lambda item: item[1].updated_at)
|
||
for key, _ in ordered[:overflow]:
|
||
self._entries.pop(key, None)
|
||
|
||
|
||
_default_throttle = LoginThrottle()
|
||
|
||
|
||
def get_login_throttle() -> LoginThrottle:
|
||
"""
|
||
返回进程内共享的登录限流器。
|
||
"""
|
||
return _default_throttle
|