- Add GroupClaims model for JWT permission snapshots - Add JWTPayload model for typed JWT decoding - Refactor auth middleware: jwt_required (no DB) -> admin_required (no DB) -> auth_required (DB) - Add UserBanStore for instant ban enforcement via Redis + memory fallback - Fix status check bug: StrEnum is always truthy, use explicit != ACTIVE - Shorten access_token expiry from 3h to 1h - Add CaptchaScene enum and verify_captcha_if_needed service - Add require_captcha dependency injection factory - Add CLA document and new default settings - Update all tests for new JWT API Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
121 lines
3.8 KiB
Python
121 lines
3.8 KiB
Python
import abc
|
||
from enum import StrEnum
|
||
|
||
import aiohttp
|
||
from loguru import logger as l
|
||
from pydantic import BaseModel
|
||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||
|
||
|
||
class CaptchaRequestBase(BaseModel):
|
||
"""验证码验证请求"""
|
||
|
||
response: str
|
||
"""用户的验证码 response token"""
|
||
|
||
secret: str
|
||
"""服务端密钥"""
|
||
|
||
|
||
class CaptchaBase(abc.ABC):
|
||
"""验证码验证器抽象基类"""
|
||
|
||
verify_url: str
|
||
"""验证 API 地址(子类必须定义)"""
|
||
|
||
async def verify_captcha(self, request: CaptchaRequestBase) -> bool:
|
||
"""
|
||
验证 token 是否有效。
|
||
|
||
:return: 如果验证成功返回 True,否则返回 False
|
||
:rtype: bool
|
||
"""
|
||
payload = request.model_dump()
|
||
|
||
async with aiohttp.ClientSession() as client_session:
|
||
async with client_session.post(self.verify_url, data=payload) as resp:
|
||
if resp.status != 200:
|
||
return False
|
||
|
||
result = await resp.json()
|
||
return result.get('success', False)
|
||
|
||
|
||
# 子类导入必须在 CaptchaBase 定义之后(gcaptcha.py / turnstile.py 依赖 CaptchaBase)
|
||
from .gcaptcha import GCaptcha # noqa: E402
|
||
from .turnstile import TurnstileCaptcha # noqa: E402
|
||
|
||
|
||
class CaptchaScene(StrEnum):
|
||
"""验证码使用场景,value 对应 Setting 表中的 name"""
|
||
|
||
LOGIN = "login_captcha"
|
||
REGISTER = "reg_captcha"
|
||
FORGET = "forget_captcha"
|
||
|
||
|
||
async def verify_captcha_if_needed(
|
||
session: AsyncSession,
|
||
scene: CaptchaScene,
|
||
captcha_code: str | None,
|
||
) -> None:
|
||
"""
|
||
通用验证码校验:查询设置判断是否需要,需要则校验。
|
||
|
||
:param session: 数据库异步会话
|
||
:param scene: 验证码使用场景
|
||
:param captcha_code: 用户提交的验证码 response token
|
||
:raises HTTPException 400: 需要验证码但未提供
|
||
:raises HTTPException 403: 验证码验证失败
|
||
:raises HTTPException 500: 验证码密钥未配置
|
||
"""
|
||
from sqlmodels import Setting, SettingsType
|
||
from sqlmodels.setting import CaptchaType
|
||
from utils import http_exceptions
|
||
|
||
# 1. 查询该场景是否需要验证码
|
||
scene_setting = await Setting.get(
|
||
session,
|
||
(Setting.type == SettingsType.LOGIN) & (Setting.name == scene.value),
|
||
)
|
||
if not scene_setting or scene_setting.value != "1":
|
||
return
|
||
|
||
# 2. 需要但未提供
|
||
if not captcha_code:
|
||
http_exceptions.raise_bad_request(detail="请完成验证码验证")
|
||
|
||
# 3. 查询验证码类型和密钥
|
||
captcha_settings: list[Setting] = await Setting.get(
|
||
session, Setting.type == SettingsType.CAPTCHA, fetch_mode="all",
|
||
)
|
||
s: dict[str, str | None] = {item.name: item.value for item in captcha_settings}
|
||
captcha_type = CaptchaType(s.get("captcha_type") or "default")
|
||
|
||
# 4. DEFAULT 图片验证码尚未实现,跳过
|
||
if captcha_type == CaptchaType.DEFAULT:
|
||
l.warning("DEFAULT 图片验证码尚未实现,跳过验证")
|
||
return
|
||
|
||
# 5. 选择验证器和密钥
|
||
if captcha_type == CaptchaType.GCAPTCHA:
|
||
secret = s.get("captcha_ReCaptchaSecret")
|
||
verifier: CaptchaBase = GCaptcha()
|
||
elif captcha_type == CaptchaType.CLOUD_FLARE_TURNSTILE:
|
||
secret = s.get("captcha_CloudflareSecret")
|
||
verifier = TurnstileCaptcha()
|
||
else:
|
||
l.error(f"未知的验证码类型: {captcha_type}")
|
||
http_exceptions.raise_internal_error()
|
||
|
||
if not secret:
|
||
l.error(f"验证码密钥未配置: captcha_type={captcha_type}")
|
||
http_exceptions.raise_internal_error()
|
||
|
||
# 6. 调用第三方 API 校验
|
||
is_valid = await verifier.verify_captcha(
|
||
CaptchaRequestBase(response=captcha_code, secret=secret)
|
||
)
|
||
if not is_valid:
|
||
http_exceptions.raise_forbidden(detail="验证码验证失败")
|