Files
chunyu_project/utils/slider_captcha.py
T
root 3618323192 fix(security): P1/P2 审计修复 + JWT HttpOnly Cookie 双模认证 + 限流
- 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)
2026-09-08 11:28:00 +08:00

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