Files
disknext/service/captcha/__init__.py
于小丘 a99091ea7a feat: embed permission claims in JWT and add captcha verification
- 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>
2026-02-10 19:07:48 +08:00

121 lines
3.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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="验证码验证失败")