ForcePilot/backend/package/yuxi/channel/security/auth_rate_limiter.py
Kris 849d21120f feat(channel/security): 新增完整安全工具模块
新增一系列安全相关功能:
1.  新增二维码生成工具,支持自定义参数导出图片/Base64/字节流
2.  新增HTML内容安全处理工具,包括标签剥离、转义、URL校验
3.  新增日志敏感信息脱敏工具,支持配置、字典、日志记录脱敏
4.  新增密钥管理运行时,支持从环境变量/文件加载密钥
5.  新增身份链接管理,支持多渠道身份绑定与解析
6.  新增SSRF防护工具,支持域名/IP校验与固定主机
7.  新增安全权限修复工具,修复文件目录权限与配置项
8.  新增外部内容安全处理,支持LLM特殊令牌剥离与注入检测
9.  新增认证限流工具,支持多维度限流与本地回环豁免
10. 新增配对管理工具,支持安全配对码生成与校验
11. 新增白名单管理工具,支持DM/群组/来源白名单校验
2026-05-21 10:32:57 +08:00

237 lines
8.4 KiB
Python
Raw 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 asyncio
import contextlib
import time
from collections import defaultdict
from collections.abc import Callable
from dataclasses import dataclass, field
from enum import StrEnum
from functools import wraps
class RateLimitScope(StrEnum):
LOGIN = "login"
API = "api"
SIGNUP = "signup"
PASSWORD_RESET = "password_reset"
TOKEN_REFRESH = "token_refresh"
@dataclass
class ScopeConfig:
max_attempts: int = 10
window_seconds: int = 60
cooldown_seconds: int = 300
loopback_exempt: bool = True
DEFAULT_SCOPE_CONFIGS = {
RateLimitScope.LOGIN: ScopeConfig(max_attempts=10, window_seconds=60, cooldown_seconds=300),
RateLimitScope.API: ScopeConfig(max_attempts=100, window_seconds=60, cooldown_seconds=60),
RateLimitScope.SIGNUP: ScopeConfig(max_attempts=3, window_seconds=3600, cooldown_seconds=7200),
RateLimitScope.PASSWORD_RESET: ScopeConfig(max_attempts=5, window_seconds=300, cooldown_seconds=600),
RateLimitScope.TOKEN_REFRESH: ScopeConfig(max_attempts=30, window_seconds=60, cooldown_seconds=120),
}
_LOCAL_IPS = frozenset({"127.0.0.1", "::1", "localhost"})
@dataclass
class _BucketState:
timestamps: list[float] = field(default_factory=list)
cooldown_until: float = 0.0
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
class AuthRateLimiter:
"""认证级限流器:内存滑动窗口 + 多 Scope + 本地回环豁免 + 锁定冷却。
特性:
- 多 Scope 独立配置login/api/signup/password_reset/token_refresh
- 滑动窗口计数,窗口外记录自动淘汰
- 本地回环 (127.0.0.1, ::1) 可豁免
- 锁定冷却期 5min超限后拒绝所有尝试
- asyncio.Lock 串行化防竞态
- 后台定期清理失效 bucket防止 DDoS 下内存持续增长
"""
_CLEANUP_INTERVAL = 300 # seconds between stale bucket cleanups
def __init__(self, scope_configs: dict[RateLimitScope, ScopeConfig] | None = None):
self._configs = scope_configs or DEFAULT_SCOPE_CONFIGS
self._buckets: dict[str, dict[RateLimitScope, _BucketState]] = defaultdict(dict)
self._lock = asyncio.Lock()
self._last_cleanup: float = 0.0
self._cleanup_task: asyncio.Task | None = None
self._start_background_cleanup()
def _is_loopback(self, identifier: str) -> bool:
if identifier in _LOCAL_IPS:
return True
host = identifier.split(":")[0]
if host.startswith("127."):
return True
try:
import ipaddress
ip = ipaddress.ip_address(host)
return ip.is_loopback
except ValueError:
return False
async def _get_bucket(self, identifier: str, scope: RateLimitScope) -> _BucketState:
async with self._lock:
scope_buckets = self._buckets[identifier]
if scope not in scope_buckets:
scope_buckets[scope] = _BucketState()
return scope_buckets[scope]
async def _cleanup_stale_buckets(self, now: float) -> None:
async with self._lock:
stale_identifiers: list[str] = []
for identifier, scopes in self._buckets.items():
active_scopes: dict[RateLimitScope, _BucketState] = {}
for scope, bucket in scopes.items():
config = self._configs.get(scope)
window = config.window_seconds if config else 60
bucket.timestamps = [t for t in bucket.timestamps if t > now - window]
has_activity = bucket.timestamps or bucket.cooldown_until > now
if has_activity:
active_scopes[scope] = bucket
if active_scopes:
self._buckets[identifier] = active_scopes
else:
stale_identifiers.append(identifier)
for identifier in stale_identifiers:
del self._buckets[identifier]
def _start_background_cleanup(self) -> None:
async def _loop() -> None:
while True:
await asyncio.sleep(self._CLEANUP_INTERVAL)
await self._cleanup_stale_buckets(time.monotonic())
try:
loop = asyncio.get_running_loop()
self._cleanup_task = loop.create_task(_loop())
except RuntimeError:
pass
async def shutdown(self) -> None:
if self._cleanup_task and not self._cleanup_task.done():
self._cleanup_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._cleanup_task
async def check_and_record(
self,
identifier: str,
scope: RateLimitScope,
) -> bool:
"""检查是否允许请求,并记录本次尝试。
返回 True 表示允许False 表示被限流。
"""
config = self._configs.get(scope)
if config is None:
return True
if config.loopback_exempt and self._is_loopback(identifier):
return True
now = time.monotonic()
if now - self._last_cleanup > self._CLEANUP_INTERVAL:
self._last_cleanup = now
await self._cleanup_stale_buckets(now)
bucket = await self._get_bucket(identifier, scope)
now = time.monotonic()
async with bucket.lock:
if bucket.cooldown_until > now:
return False
window_start = now - config.window_seconds
bucket.timestamps = [t for t in bucket.timestamps if t > window_start]
if len(bucket.timestamps) >= config.max_attempts:
bucket.cooldown_until = now + config.cooldown_seconds
return False
bucket.timestamps.append(now)
return True
async def check_and_record_or_raise(
self,
identifier: str,
scope: RateLimitScope,
) -> None:
if not await self.check_and_record(identifier, scope):
config = self._configs.get(scope)
retry_after = config.cooldown_seconds if config else 60
raise RateLimitExceeded(scope=scope, retry_after=retry_after)
async def clear_cooldown(self, identifier: str, scope: RateLimitScope):
bucket = await self._get_bucket(identifier, scope)
async with bucket.lock:
bucket.cooldown_until = 0.0
async def reset(self, identifier: str, scope: RateLimitScope | None = None):
async with self._lock:
if scope:
if identifier in self._buckets and scope in self._buckets[identifier]:
async with self._buckets[identifier][scope].lock:
self._buckets[identifier].pop(scope, None)
else:
self._buckets.pop(identifier, None)
def get_stats(self) -> dict:
now = time.monotonic()
stats: dict[str, dict] = {}
for identifier, scopes in self._buckets.items():
stats[identifier] = {}
for scope, bucket in scopes.items():
config = self._configs.get(scope)
window = config.window_seconds if config else 60
active = len([t for t in bucket.timestamps if t > now - window])
stats[identifier][scope.value] = {
"attempts_in_window": active,
"cooldown_active": bucket.cooldown_until > now,
"cooldown_remaining": max(0, bucket.cooldown_until - now),
}
return stats
def wrap(
self,
scope: RateLimitScope,
identifier_fn: Callable[..., str] | None = None,
):
"""装饰器:将限流包装到异步函数上。
使用方式:
@auth_rate_limiter.wrap(RateLimitScope.LOGIN)
async def login(request: Request):
...
"""
def decorator(fn):
@wraps(fn)
async def wrapper(*args, **kwargs):
ident = identifier_fn(*args, **kwargs) if identifier_fn else "default"
await self.check_and_record_or_raise(ident, scope)
return await fn(*args, **kwargs)
return wrapper
return decorator
class RateLimitExceeded(Exception):
def __init__(self, scope: RateLimitScope, retry_after: int = 60):
self.scope = scope
self.retry_after = retry_after
super().__init__(f"Rate limit exceeded for scope '{scope.value}', retry after {retry_after}s")
auth_rate_limiter = AuthRateLimiter()