- 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)
201 lines
5.7 KiB
Python
201 lines
5.7 KiB
Python
import io
|
|
import math
|
|
import secrets
|
|
import time
|
|
import uuid
|
|
import base64
|
|
from dataclasses import dataclass
|
|
from typing import List, Dict, Optional
|
|
|
|
from PIL import Image, ImageDraw
|
|
from django.core.cache import caches
|
|
|
|
default_cache = caches['default']
|
|
|
|
SLIDER_WIDTH = 60
|
|
SLIDER_HEIGHT = 60
|
|
TOLERANCE = 5
|
|
CAPTCHA_TIMEOUT = 300
|
|
TRAJECTORY_MIN_POINTS = 5
|
|
TRAJECTORY_MAX_DURATION = 10000
|
|
|
|
|
|
class SliderCaptchaError(Exception):
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class SliderPosition:
|
|
x: int
|
|
y: int
|
|
|
|
|
|
def _generate_background_image(width: int = 300, height: int = 150) -> Image.Image:
|
|
image = Image.new('RGB', (width, height), (245, 245, 245))
|
|
draw = ImageDraw.Draw(image)
|
|
|
|
for _ in range(5):
|
|
x1 = secrets.randbelow(width)
|
|
y1 = secrets.randbelow(height)
|
|
x2 = secrets.randbelow(width)
|
|
y2 = secrets.randbelow(height)
|
|
color = tuple(secrets.randbelow(200) for _ in range(3))
|
|
draw.line([(x1, y1), (x2, y2)], fill=color, width=1)
|
|
|
|
for _ in range(50):
|
|
x = secrets.randbelow(width)
|
|
y = secrets.randbelow(height)
|
|
color = tuple(secrets.randbelow(200) for _ in range(3))
|
|
draw.point((x, y), fill=color)
|
|
|
|
return image
|
|
|
|
|
|
def _generate_slider_image(width: int = SLIDER_WIDTH, height: int = SLIDER_HEIGHT) -> Image.Image:
|
|
image = Image.new('RGBA', (width, height), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(image)
|
|
|
|
draw.rectangle([(0, 0), (width-1, height-1)], fill=(0, 100, 255, 200))
|
|
draw.rectangle([(5, 5), (width-6, height-6)], fill=(0, 120, 255, 180))
|
|
|
|
notch_x = width // 2 - 5
|
|
notch_y = height // 2 - 5
|
|
draw.arc([(notch_x, notch_y), (notch_x+10, notch_y+10)], 0, 360, fill=(255, 255, 255), width=2)
|
|
|
|
return image
|
|
|
|
|
|
def _cut_slider_from_background(bg_image: Image.Image, position: SliderPosition) -> Image.Image:
|
|
slider_img = bg_image.crop((
|
|
position.x,
|
|
position.y,
|
|
position.x + SLIDER_WIDTH,
|
|
position.y + SLIDER_HEIGHT
|
|
))
|
|
return slider_img
|
|
|
|
|
|
def _image_to_base64(image: Image.Image) -> str:
|
|
buffer = io.BytesIO()
|
|
image.save(buffer, format='PNG')
|
|
image_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
|
return f'data:image/png;base64,{image_base64}'
|
|
|
|
|
|
def generate_slider_captcha() -> Dict:
|
|
captcha_key = str(uuid.uuid4())
|
|
|
|
# 上限 239:保证缺口在客户端可拖动范围内可达(滑块行程 = 图宽300 - 块宽60 = 240,含 ±5 容差)
|
|
x_position = secrets.randbelow(190) + 50
|
|
y_position = secrets.randbelow(50) + 30
|
|
|
|
bg_image = _generate_background_image()
|
|
|
|
slider_img = _cut_slider_from_background(bg_image, SliderPosition(x_position, y_position))
|
|
|
|
draw = ImageDraw.Draw(bg_image)
|
|
draw.rectangle([
|
|
(x_position, y_position),
|
|
(x_position + SLIDER_WIDTH, y_position + SLIDER_HEIGHT)
|
|
], fill=(200, 200, 200))
|
|
|
|
bg_base64 = _image_to_base64(bg_image)
|
|
slider_base64 = _image_to_base64(slider_img)
|
|
|
|
cache_data = {
|
|
'x_position': x_position,
|
|
'y_position': y_position,
|
|
'timestamp': time.time()
|
|
}
|
|
default_cache.set(f'slider_captcha_{captcha_key}', cache_data, timeout=CAPTCHA_TIMEOUT)
|
|
|
|
# 安全修复:坐标答案(x)只存服务端缓存,绝不下发客户端(否则脚本可直接读答案绕过);
|
|
# y_position 非答案,仅用于客户端垂直摆放拼图块
|
|
return {
|
|
'captcha_key': captcha_key,
|
|
'bg_image': bg_base64,
|
|
'slider_image': slider_base64,
|
|
'y_position': y_position,
|
|
}
|
|
|
|
|
|
def verify_slider_captcha(captcha_key: str, x_position: int) -> bool:
|
|
cache_key = f'slider_captcha_{captcha_key}'
|
|
cached_data = default_cache.get(cache_key)
|
|
|
|
if cached_data is None:
|
|
raise SliderCaptchaError('验证码已过期')
|
|
|
|
stored_x = cached_data['x_position']
|
|
|
|
# 安全修复:无论成败都销毁 key,杜绝同一 key 的穷举重试
|
|
default_cache.delete(cache_key)
|
|
return abs(stored_x - x_position) <= TOLERANCE
|
|
|
|
|
|
def verify_slider_trajectory(
|
|
captcha_key: str,
|
|
x_position: int,
|
|
trajectory: List[Dict[str, int]]
|
|
) -> bool:
|
|
cache_key = f'slider_captcha_{captcha_key}'
|
|
cached_data = default_cache.get(cache_key)
|
|
|
|
if cached_data is None:
|
|
raise SliderCaptchaError('验证码已过期')
|
|
|
|
stored_x = cached_data['x_position']
|
|
|
|
# 安全修复:x 错误或轨迹校验失败均立即销毁 key,客户端需重新获取验证码
|
|
if abs(stored_x - x_position) > TOLERANCE:
|
|
default_cache.delete(cache_key)
|
|
return False
|
|
|
|
if len(trajectory) < TRAJECTORY_MIN_POINTS:
|
|
default_cache.delete(cache_key)
|
|
return False
|
|
|
|
if trajectory[-1]['t'] - trajectory[0]['t'] > TRAJECTORY_MAX_DURATION:
|
|
default_cache.delete(cache_key)
|
|
return False
|
|
|
|
if not _analyze_trajectory(trajectory):
|
|
default_cache.delete(cache_key)
|
|
return False
|
|
|
|
default_cache.delete(cache_key)
|
|
return True
|
|
|
|
|
|
def _analyze_trajectory(trajectory: List[Dict[str, int]]) -> bool:
|
|
if len(trajectory) < 3:
|
|
return False
|
|
|
|
velocities = []
|
|
for i in range(1, len(trajectory)):
|
|
dx = trajectory[i]['x'] - trajectory[i-1]['x']
|
|
dt = trajectory[i]['t'] - trajectory[i-1]['t']
|
|
if dt == 0:
|
|
continue
|
|
velocities.append(dx / dt)
|
|
|
|
if len(velocities) < 2:
|
|
return False
|
|
|
|
velocity_changes = []
|
|
for i in range(1, len(velocities)):
|
|
velocity_changes.append(abs(velocities[i] - velocities[i-1]))
|
|
|
|
avg_change = sum(velocity_changes) / len(velocity_changes)
|
|
if avg_change < 0.001:
|
|
return False
|
|
|
|
has_pause = False
|
|
for i in range(1, len(trajectory)):
|
|
dt = trajectory[i]['t'] - trajectory[i-1]['t']
|
|
if dt > 50:
|
|
has_pause = True
|
|
break
|
|
|
|
return has_pause
|