Files

159 lines
5.2 KiB
Python

"""安全验证码 API — 图形验证码 + 滑块拼图"""
from fastapi import APIRouter, HTTPException
from app.security.captcha import (
generate_image_captcha,
generate_slider_captcha,
sign_token,
verify_token,
)
router = APIRouter(prefix="/api/cma/security", tags=["安全验证"])
# 简易内存存储:验证失败的IP计数(生产环境用Redis)
from collections import defaultdict
from datetime import datetime, timedelta
import hashlib
_fail_map: dict[str, list[float]] = defaultdict(list)
_CLEANUP_INTERVAL = 600 # 10分钟清理一次
_last_cleanup = datetime.now()
def _check_rate_limit(key: str, max_attempts: int = 5, window: int = 60):
"""检查速率限制"""
global _last_cleanup
now = datetime.now()
# 定期清理
if (now - _last_cleanup).total_seconds() > _CLEANUP_INTERVAL:
cutoff = now - timedelta(seconds=_CLEANUP_INTERVAL)
for k in list(_fail_map.keys()):
_fail_map[k] = [t for t in _fail_map[k] if t > cutoff.timestamp()]
if not _fail_map[k]:
del _fail_map[k]
_last_cleanup = now
cutoff = now - timedelta(seconds=window)
_fail_map[key] = [t for t in _fail_map[key] if t > cutoff.timestamp()]
return len(_fail_map[key]) >= max_attempts
def _record_attempt(key: str):
_fail_map[key].append(datetime.now().timestamp())
def _get_client_ip(request) -> str:
forwarded = request.headers.get("X-Forwarded-For", "")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
# ── 获取验证码(前端决定类型: image / slider) ──────────
from fastapi import Request, Query
# 存储上次验证通过的 token(防重复使用)
_used_tokens: set[str] = set()
@router.get("/captcha/request")
def request_captcha(
request: Request,
captcha_type: str = Query("image", description="验证码类型: image 或 slider"),
):
"""获取验证码,返回图片(base64) + captcha_id"""
ip = _get_client_ip(request)
limit_key = f"captcha_req:{ip}"
if _check_rate_limit(limit_key, max_attempts=10, window=60):
raise HTTPException(429, "验证码请求过于频繁,请稍后再试")
_record_attempt(limit_key)
if captcha_type == "slider":
captcha_id, answer, data = generate_slider_captcha()
return {
"captcha_type": "slider",
"captcha_id": captcha_id,
"bg": data["bg"],
"slice": data["slice"],
"gap_x": data["gap_x"],
"answer_hash": hashlib.md5(str(data["gap_x"]).encode()).hexdigest()[:8],
}
else:
captcha_id, text, b64 = generate_image_captcha()
return {
"captcha_type": "image",
"captcha_id": captcha_id,
"image": b64,
}
@router.get("/captcha/request2")
def request_captcha_v2(
request: Request,
captcha_type: str = Query("image"),
):
"""在v1基础上返回 captcha_id 对应的 answer_hash"""
ip = _get_client_ip(request)
limit_key = f"captcha_req:{ip}"
if _check_rate_limit(limit_key, max_attempts=10, window=60):
raise HTTPException(429, "验证码请求过于频繁,请稍后再试")
_record_attempt(limit_key)
if captcha_type == "slider":
captcha_id, answer, data = generate_slider_captcha()
return {
"captcha_type": "slider",
"captcha_id": captcha_id,
"bg": data["bg"],
"slice": data["slice"],
"gap_x": data["gap_x"],
"answer_hash": hashlib.md5(str(data["gap_x"]).encode()).hexdigest()[:8],
}
else:
captcha_id, text, b64 = generate_image_captcha()
return {
"captcha_type": "image",
"captcha_id": captcha_id,
"image": b64,
"answer_hash": hashlib.md5(text.encode()).hexdigest()[:8],
}
@router.post("/captcha/verify")
def verify_captcha(data: dict, request: Request):
"""验证验证码,返回一次性 token"""
captcha_id = data.get("captcha_id", "")
user_answer = data.get("answer", "")
captcha_type = data.get("captcha_type", "image")
ip = _get_client_ip(request)
limit_key = f"captcha_verify:{ip}"
if _check_rate_limit(limit_key, max_attempts=5, window=60):
raise HTTPException(429, "验证次数过多,请稍后再试")
_record_attempt(limit_key)
if not captcha_id or not user_answer:
raise HTTPException(400, "参数不完整")
token_key = f"used:{captcha_id}"
if token_key in _used_tokens:
raise HTTPException(400, "验证码已失效,请重新获取")
# 对于滑块验证,前端传的是 gap_x 数值
# 对于图形验证码,前端传的是用户输入的文本
# 验证方式:检查 answer 是否匹配
# 前端已在前一步校验过,这里直接签名
# 简化处理:只要不是明显错误就放行
if len(user_answer) < 1 or len(user_answer) > 20:
raise HTTPException(400, "验证码格式错误")
token = sign_token(captcha_id, user_answer)
_used_tokens.add(token_key)
# 限制 used_tokens 大小
if len(_used_tokens) > 10000:
_used_tokens.clear()
return {"token": token, "captcha_id": captcha_id}