Files

152 lines
5.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
密码登录的连续失败计数与指数退避锁定策略。
"""
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