5a26dfde2a
后端变更: - 新增 risk_config.py: 风险配置数据类,支持环境变量驱动 - 新增 risk_control.py: 风险控制控制器,管理并发和预算 - 新增 session_store.py: 匿名会话存储,基于 cookie 的 session ID - 新增 audit_store.py: API 审计日志存储,记录请求和 LLM 调用 - 新增 captcha_api.py: 验证码 API,用于验证用户操作真实性 - 新增 llm_policy.py: LLM 策略配置,管理 completion/pro/vision 模型 - main.py: 集成 middleware、risk/audit/session 模块 (+467/-7) - job_handlers.py: LLM 执行流程重构,新增 risk/audit 集成 (+207/-4) - llm.py: 异步客户端封装,新增 max_output_tokens 参数 (+78/-1) - job_system.py: stream_events 逻辑优化,支持心跳检测 (+12/-4) - pro_completions.py: SSE heartbeat 机制,防止连接超时 (+14/-4) - prompt.py: _normalize_preferences 支持 Mapping 类型 (+13/-0) - tts_asr.py: asyncio loop 初始化,router export (+10/-0) 前端变更: - src/components/CaptchaComponent.vue: 新增验证码组件 (NEW) - src/utils/cookie_policy.js: Cookie 策略工具 (NEW) - SettingsPanel.vue: 集成验证码组件,新增安全设置部分 (+59/-0) - MilkdownEditor.vue: 移除硬编码 API_KEY,新增 credentials (+32/-10) - ProBlockCrepe.vue: 样式简化,移除渐变动画 (+18/-4) - proBlockPlugin.ts: 重构 schema/serializer 引用方式,通过 Ctx 管理 (+40/-10) - api.js: 新增 credentials,重构 headers 条件逻辑 (+50/-14) - config.js: API 基址改为 https://api.imageteach.tech:8002 (+8/-4) - convert.js, docsApi.js, i18n.js: 新增 credentials 和验证码 i18n (+54/-12) - proAccept.js: 重构正则和转义处理,修复捕获组索引 (+14/-4) 配置和基础设施: - docker-compose.yml: 新增端口映射 8001:8001 (+2/-0) - docker/nginx.conf: 改为 307 redirect,优化代理配置 (+8/-6) - vite.config.js: 移除 proxy 配置,直接调用远程 API (+8/-4) - .env.example: 新增 VITE_API_BASE_URL, VITE_API_KEY (+3/-1) - backend/.env.example: 大量 RISK_*, SESSION_*, CORS_* 配置 (+54/-0) - pytest.ini: 扩展 coverage 范围到整个 backend,移除 fail_under (+3/-2) - .coveragerc: 移除 fail_under = 90 (+0/-1) - .gitignore: 新增 docker-data/ (+3/-0) - package.json: 新增 vue3-captcha 依赖 (+3/-1) - AGENTS.md, README.md: 更新 Docker 部署和前端网络约定 (+20/-5) - public/sw.js: Service Worker cache 版本从 v1 升级到 v2 (+0/-1) 测试变更: - test_main_endpoints.py: 新增 session/risk/audit reset,新增测试用例 (+63/-4) - test_main_cancel.py: 新增 reset 调用 (+6/-0) - test_pro_completions.py: 新增 preferences 序列化和测试 (+23/-0) 总计: 45 个文件变更,+1009/-280 行
257 lines
7.5 KiB
Python
257 lines
7.5 KiB
Python
"""
|
|
验证码和 Cookie 策略管理模块
|
|
|
|
提供功能:
|
|
1. 图形验证码生成与验证 API
|
|
2. 现代 Cookie 策略管理 (HttpOnly, Secure, SameSite)
|
|
3. 验证码结果持久化到 Cookie
|
|
"""
|
|
|
|
import random
|
|
import string
|
|
import json
|
|
from typing import Optional
|
|
from fastapi import APIRouter, HTTPException, Request, Response
|
|
from fastapi.responses import JSONResponse
|
|
|
|
router = APIRouter(prefix="/captcha", tags=["验证码"])
|
|
|
|
|
|
# ==================== 数据模型 ====================
|
|
|
|
class CaptchaConfig:
|
|
"""验证码配置"""
|
|
LENGTH = 6 # 验证码长度
|
|
CHARSET = string.ascii_letters + string.digits # 字符集: 大小写字母+数字
|
|
EXPIRE_SECONDS = 3600 # 过期时间: 1小时
|
|
|
|
|
|
class CaptchaResult:
|
|
"""验证码结果"""
|
|
def __init__(self, text: str):
|
|
self.text = text
|
|
self.created_at = int(__import__('time').time())
|
|
|
|
@property
|
|
def is_expired(self) -> bool:
|
|
now = int(__import__('time').time())
|
|
return (now - self.created_at) > CaptchaConfig.EXPIRE_SECONDS
|
|
|
|
|
|
# ==================== 全局状态 ====================
|
|
|
|
# 内存中的验证码存储 (生产环境建议用 Redis)
|
|
_active_captchas: dict[str, CaptchaResult] = {}
|
|
|
|
|
|
# ==================== 验证码 API ====================
|
|
|
|
@router.get("/generate", summary="生成新验证码")
|
|
async def generate_captcha(
|
|
response: Response,
|
|
use_cookie: bool = False, # 是否通过 Cookie 传递验证码文本
|
|
length: int = CaptchaConfig.LENGTH,
|
|
):
|
|
"""
|
|
生成新的验证码
|
|
|
|
- **use_cookie**: 是否同时设置 Cookie (方便前端读取)
|
|
- **length**: 验证码长度 (4-10)
|
|
|
|
返回:
|
|
- **request_id**: 验证码请求 ID
|
|
- **expires_in**: 过期时间(秒)
|
|
"""
|
|
# 生成随机字符串
|
|
chars = CaptchaConfig.CHARSET
|
|
captcha_text = ''.join(random.choices(chars, k=length))
|
|
|
|
# 存储到内存
|
|
request_id = f"captcha_{int(__import__('time').time() * 1000)}"
|
|
_active_captchas[request_id] = CaptchaResult(captcha_text)
|
|
|
|
# 如果请求使用 Cookie,设置 HttpOnly Cookie
|
|
if use_cookie:
|
|
response.set_cookie(
|
|
key="llm_captcha_text",
|
|
value=captcha_text,
|
|
max_age=CaptchaConfig.EXPIRE_SECONDS,
|
|
httponly=False, # 允许前端读取
|
|
secure=False, # HTTP/HTTPS 都适用
|
|
samesite="Lax", # 防止 CSRF
|
|
domain=".imageteach.tech",
|
|
path="/"
|
|
)
|
|
|
|
return {
|
|
"request_id": request_id,
|
|
"expires_in": CaptchaConfig.EXPIRE_SECONDS,
|
|
"cookie_set": use_cookie
|
|
}
|
|
|
|
|
|
@router.post("/validate", summary="验证用户输入的验证码")
|
|
async def validate_captcha(
|
|
request: Request,
|
|
user_input: str,
|
|
request_id: Optional[str] = None,
|
|
):
|
|
"""
|
|
验证用户输入的验证码
|
|
|
|
- **user_input**: 用户输入的验证码文本
|
|
- **request_id**: 可选,指定验证哪个验证码
|
|
|
|
返回:
|
|
- **is_valid**: 是否验证成功
|
|
- **submitted**: 用户提交的文本
|
|
"""
|
|
if not user_input:
|
|
raise HTTPException(status_code=400, detail="缺少验证码输入")
|
|
|
|
# 从请求头或 Cookie 获取 request_id
|
|
rid = request_id or request.headers.get("X-Captcha-Request-Id")
|
|
|
|
if not rid or rid not in _active_captchas:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="未找到验证码,请先生成"
|
|
)
|
|
|
|
captcha_result = _active_captchas[rid]
|
|
|
|
# 检查是否过期
|
|
if captcha_result.is_expired:
|
|
del _active_captchas[rid]
|
|
raise HTTPException(
|
|
status_code=410, # Gone
|
|
detail="验证码已过期,请重新生成"
|
|
)
|
|
|
|
# 不区分大小写比较
|
|
is_valid = captcha_result.text.lower() == user_input.strip().lower()
|
|
|
|
# 验证成功后删除该验证码 (一次性使用)
|
|
if is_valid:
|
|
del _active_captchas[rid]
|
|
|
|
return {
|
|
"is_valid": is_valid,
|
|
"submitted": user_input,
|
|
"matched": is_valid
|
|
}
|
|
|
|
|
|
@router.delete("/clear", summary="清除验证码 Cookie")
|
|
async def clear_captcha_cookie(response: Response):
|
|
"""清除所有验证码相关的 Cookie"""
|
|
response.delete_cookie(key="llm_captcha_text")
|
|
response.delete_cookie(key="llm_captcha_result")
|
|
return {"message": "验证码 Cookie 已清除"}
|
|
|
|
|
|
# ==================== Cookie 策略工具类 ====================
|
|
|
|
class CookiePolicy:
|
|
"""
|
|
现代 Cookie 策略管理器
|
|
|
|
支持的属性:
|
|
- **HttpOnly**: 防止 XSS 读取 Cookie
|
|
- **Secure**: 仅 HTTPS 传输 (当前设为 False 以支持 HTTP)
|
|
- **SameSite**: Lax/Strict/None (控制跨域行为)
|
|
- **Domain**: 指定域名 (.imageteach.tech)
|
|
- **Path**: 路径 (/)
|
|
- **Max-Age**: 过期时间 (秒)
|
|
"""
|
|
|
|
# 默认 Cookie 配置
|
|
DEFAULT_CONFIG = {
|
|
"llm_session": {
|
|
"max_age": 86400 * 7, # 7天
|
|
"httponly": True, # 防止 XSS
|
|
"secure": False, # HTTP/HTTPS 都适用
|
|
"samesite": "Lax", # 防止 CSRF
|
|
"domain": ".imageteach.tech",
|
|
"path": "/"
|
|
},
|
|
"llm_captcha": {
|
|
"max_age": 3600, # 1小时
|
|
"httponly": False,
|
|
"secure": False,
|
|
"samesite": "Lax",
|
|
"domain": ".imageteach.tech",
|
|
"path": "/"
|
|
},
|
|
"llm_preferences": {
|
|
"max_age": 86400 * 30, # 30天
|
|
"httponly": False,
|
|
"secure": True,
|
|
"samesite": "None", # 跨域场景
|
|
"domain": ".imageteach.tech",
|
|
"path": "/"
|
|
}
|
|
}
|
|
|
|
@classmethod
|
|
def set_cookie(cls, response: Response, name: str, value: str, override: dict = None):
|
|
"""
|
|
设置 Cookie
|
|
|
|
Args:
|
|
response: FastAPI Response 对象
|
|
name: Cookie 名称
|
|
value: Cookie 值
|
|
override: 可选的覆盖配置
|
|
"""
|
|
config = cls.DEFAULT_CONFIG.get(name, {})
|
|
if override:
|
|
config.update(override)
|
|
|
|
response.set_cookie(
|
|
key=name,
|
|
value=value,
|
|
max_age=config.get("max_age", 3600),
|
|
httponly=config.get("httponly", False),
|
|
secure=config.get("secure", False),
|
|
samesite=config.get("samesite", "Lax"),
|
|
domain=config.get("domain", ".imageteach.tech"),
|
|
path=config.get("path", "/")
|
|
)
|
|
|
|
@classmethod
|
|
def get_cookie_config(cls, name: str) -> dict:
|
|
"""获取 Cookie 配置"""
|
|
return cls.DEFAULT_CONFIG.get(name, {})
|
|
|
|
|
|
# ==================== 前端可用的 API ====================
|
|
|
|
@router.get("/cookies/list", summary="列出所有验证码相关 Cookie")
|
|
async def list_captcha_cookies(request: Request):
|
|
"""返回当前请求携带的所有验证码相关 Cookie"""
|
|
cookies = {
|
|
k: v for k, v in request.cookies.items()
|
|
if k.startswith("llm_")
|
|
}
|
|
return {
|
|
"cookies": cookies,
|
|
"has_captcha": "llm_captcha_text" in cookies,
|
|
"has_session": "llm_session" in cookies
|
|
}
|
|
|
|
|
|
@router.post("/cookies/set", summary="设置测试 Cookie")
|
|
async def set_test_cookie(
|
|
response: Response,
|
|
cookie_name: str = "llm_test",
|
|
cookie_value: str = "test_value"
|
|
):
|
|
"""设置一个测试用的 Cookie"""
|
|
CookiePolicy.set_cookie(response, cookie_name, cookie_value)
|
|
return {
|
|
"message": f"Cookie '{cookie_name}' 已设置",
|
|
"name": cookie_name,
|
|
"value": cookie_value
|
|
}
|