- P0/P1 审计修复: 滑块验证码不再下发 x_position/成败即销毁 key、 user-login 补失败计数+滑块门控、限流标识改 X-Real-IP、 百度翻译 appkey 环境化、ChangeEmail/ChangePhone 补调 avalidate、 logs/tasks.py Count(filter=Q) 修复、chat 收藏 SSRF 内网黑名单 - P1 #6/7: token_blacklist + ROTATE_REFRESH_TOKENS 开启, /user/token/refresh/ 挂载 - #2 JWT HttpOnly Cookie 双模认证: user/cookie_auth.py 种/清 Cookie, user/authentication.py CookieOrHeaderJWTAuthentication(Bearer 优先/_COOKIE 兜底), user/views/token.py CookieTokenRefreshView + UserLogoutAPIView(/user/logout/), create_standardized_response 自动对含 token 的响应种 Cookie, 异步视图内 RefreshToken.for_user 全部 sync_to_async 包裹(修 SynchronousOnlyOperation 500), WS ChatConsumer 优先读 Cookie token - P2 #11 限流: utils/rate_limit.py 固定窗口频控, shorturl 生成 匿名10次/分+登录60次/分, 邮箱验证码 同邮箱60s1次+同IP10次/10min, 登录/注册验证码 错5次作废+成功即销毁防重放, 换绑邮箱/手机 同步落地, urls.py 补挂 shorturl 路由(此前 404)
103 lines
2.7 KiB
Python
103 lines
2.7 KiB
Python
# utils/rate_limit.py — 基于 Redis 缓存的频控与失败计数(同步/异步兼容)
|
|
from django.core.cache import caches
|
|
|
|
default_cache = caches['default']
|
|
|
|
|
|
def get_client_ip(request) -> str:
|
|
"""
|
|
安全提取客户端真实 IP:优先取反向代理注入的 X-Real-IP,杜绝伪造 X-Forwarded-For 绕过限流
|
|
"""
|
|
if not request:
|
|
return ''
|
|
real_ip = request.META.get('HTTP_X_REAL_IP')
|
|
if real_ip:
|
|
return real_ip.strip()
|
|
fwd = request.META.get('HTTP_X_FORWARDED_FOR')
|
|
if fwd:
|
|
return fwd.split(',')[0].strip()
|
|
return request.META.get('REMOTE_ADDR', '').strip()
|
|
|
|
|
|
def _client_key(scope: str, identifier: str) -> str:
|
|
return f'rl_{scope}_{identifier}'
|
|
|
|
|
|
def check_rate_limit(scope: str, identifier: str, limit: int, window_seconds: int) -> bool:
|
|
"""
|
|
固定窗口频控:同 identifier 在 window_seconds 秒内允许最多 limit 次请求。
|
|
返回 True 表示放行,False 表示已被限流。
|
|
"""
|
|
if not identifier:
|
|
return True
|
|
key = _client_key(scope, identifier)
|
|
try:
|
|
count = default_cache.get(key)
|
|
except Exception:
|
|
# Redis 异常时保持放行,避免误伤业务
|
|
return True
|
|
|
|
if count is None:
|
|
try:
|
|
default_cache.set(key, 1, timeout=window_seconds)
|
|
except Exception:
|
|
return True
|
|
return True
|
|
|
|
try:
|
|
count = int(count)
|
|
except (TypeError, ValueError):
|
|
count = 0
|
|
|
|
if count >= limit:
|
|
return False
|
|
|
|
try:
|
|
default_cache.set(key, count + 1, timeout=window_seconds)
|
|
except Exception:
|
|
return True
|
|
return True
|
|
|
|
|
|
def record_failure(scope: str, identifier: str, max_failures: int = 5, window_seconds: int = 300) -> int:
|
|
"""
|
|
记录一次失败并返回当前累计失败次数。
|
|
"""
|
|
if not identifier:
|
|
return 0
|
|
key = f'fail_{scope}_{identifier}'
|
|
try:
|
|
count = default_cache.get(key)
|
|
new_count = (int(count) + 1) if count is not None else 1
|
|
default_cache.set(key, new_count, timeout=window_seconds)
|
|
return new_count
|
|
except Exception:
|
|
return 0
|
|
|
|
|
|
def get_failure_count(scope: str, identifier: str) -> int:
|
|
"""
|
|
获取当前失败次数
|
|
"""
|
|
if not identifier:
|
|
return 0
|
|
key = f'fail_{scope}_{identifier}'
|
|
try:
|
|
count = default_cache.get(key)
|
|
return int(count) if count is not None else 0
|
|
except Exception:
|
|
return 0
|
|
|
|
|
|
def reset_failures(scope: str, identifier: str):
|
|
"""
|
|
重置失败计数
|
|
"""
|
|
if not identifier:
|
|
return
|
|
key = f'fail_{scope}_{identifier}'
|
|
try:
|
|
default_cache.delete(key)
|
|
except Exception:
|
|
pass
|