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>
This commit is contained in:
@@ -1,18 +1,20 @@
|
||||
import abc
|
||||
from enum import StrEnum
|
||||
|
||||
import aiohttp
|
||||
|
||||
from loguru import logger as l
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .gcaptcha import GCaptcha
|
||||
from .turnstile import TurnstileCaptcha
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
|
||||
class CaptchaRequestBase(BaseModel):
|
||||
"""验证码验证请求"""
|
||||
token: str
|
||||
"""验证 token"""
|
||||
|
||||
response: str
|
||||
"""用户的验证码 response token"""
|
||||
|
||||
secret: str
|
||||
"""验证密钥"""
|
||||
"""服务端密钥"""
|
||||
|
||||
|
||||
class CaptchaBase(abc.ABC):
|
||||
@@ -30,10 +32,89 @@ class CaptchaBase(abc.ABC):
|
||||
"""
|
||||
payload = request.model_dump()
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(self.verify_url, data=payload) as response:
|
||||
if response.status != 200:
|
||||
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 response.json()
|
||||
return result.get('success', 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="验证码验证失败")
|
||||
|
||||
72
service/redis/user_ban_store.py
Normal file
72
service/redis/user_ban_store.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
用户封禁状态存储
|
||||
|
||||
用于 JWT 模式下的即时封禁生效。
|
||||
支持 Redis(首选)和内存缓存(降级)两种存储后端。
|
||||
"""
|
||||
from typing import ClassVar
|
||||
|
||||
from cachetools import TTLCache
|
||||
from loguru import logger as l
|
||||
|
||||
from . import RedisManager
|
||||
|
||||
# access_token 有效期(秒)
|
||||
_BAN_TTL: int = 3600
|
||||
|
||||
|
||||
class UserBanStore:
|
||||
"""
|
||||
用户封禁状态存储
|
||||
|
||||
管理员封禁用户时调用 ban(),jwt_required 每次请求调用 is_banned() 检查。
|
||||
TTL 与 access_token 有效期一致(1h),过期后旧 token 自然失效,无需继续记录。
|
||||
"""
|
||||
|
||||
_memory_cache: ClassVar[TTLCache[str, bool]] = TTLCache(maxsize=10000, ttl=_BAN_TTL)
|
||||
"""内存缓存降级方案"""
|
||||
|
||||
@classmethod
|
||||
async def ban(cls, user_id: str) -> None:
|
||||
"""
|
||||
标记用户为已封禁。
|
||||
|
||||
:param user_id: 用户 UUID 字符串
|
||||
"""
|
||||
client = RedisManager.get_client()
|
||||
if client is not None:
|
||||
key = f"user_ban:{user_id}"
|
||||
await client.set(key, "1", ex=_BAN_TTL)
|
||||
else:
|
||||
cls._memory_cache[user_id] = True
|
||||
l.info(f"用户 {user_id} 已加入封禁黑名单")
|
||||
|
||||
@classmethod
|
||||
async def unban(cls, user_id: str) -> None:
|
||||
"""
|
||||
移除用户封禁标记(解封时调用)。
|
||||
|
||||
:param user_id: 用户 UUID 字符串
|
||||
"""
|
||||
client = RedisManager.get_client()
|
||||
if client is not None:
|
||||
key = f"user_ban:{user_id}"
|
||||
await client.delete(key)
|
||||
else:
|
||||
cls._memory_cache.pop(user_id, None)
|
||||
l.info(f"用户 {user_id} 已从封禁黑名单移除")
|
||||
|
||||
@classmethod
|
||||
async def is_banned(cls, user_id: str) -> bool:
|
||||
"""
|
||||
检查用户是否在封禁黑名单中。
|
||||
|
||||
:param user_id: 用户 UUID 字符串
|
||||
:return: True 表示已封禁
|
||||
"""
|
||||
client = RedisManager.get_client()
|
||||
if client is not None:
|
||||
key = f"user_ban:{user_id}"
|
||||
return await client.exists(key) > 0
|
||||
else:
|
||||
return user_id in cls._memory_cache
|
||||
@@ -4,6 +4,8 @@ from loguru import logger
|
||||
|
||||
from middleware.dependencies import SessionDep
|
||||
from sqlmodels import LoginRequest, TokenResponse, User
|
||||
from sqlmodels.group import GroupClaims, GroupOptions
|
||||
from sqlmodels.user import UserStatus
|
||||
from utils import http_exceptions
|
||||
from utils.JWT import create_access_token, create_refresh_token
|
||||
from utils.password.pwd import Password, PasswordStatus
|
||||
@@ -22,15 +24,13 @@ async def login(
|
||||
|
||||
:return: TokenResponse 对象或状态码或 None
|
||||
"""
|
||||
# TODO: 验证码校验
|
||||
# captcha_setting = await Setting.get(
|
||||
# session,
|
||||
# (Setting.type == "auth") & (Setting.name == "login_captcha")
|
||||
# )
|
||||
# is_captcha_required = captcha_setting and captcha_setting.value == "1"
|
||||
|
||||
# 获取用户信息
|
||||
current_user: User = await User.get(session, User.email == login_request.email, fetch_mode="first") #type: ignore
|
||||
# 获取用户信息(预加载 group 关系)
|
||||
current_user: User = await User.get(
|
||||
session,
|
||||
User.email == login_request.email,
|
||||
fetch_mode="first",
|
||||
load=User.group,
|
||||
) #type: ignore
|
||||
|
||||
# 验证用户是否存在
|
||||
if not current_user:
|
||||
@@ -42,8 +42,8 @@ async def login(
|
||||
logger.debug(f"Password verification failed for user: {login_request.email}")
|
||||
http_exceptions.raise_unauthorized("Invalid email or password")
|
||||
|
||||
# 验证用户是否可登录
|
||||
if not current_user.status:
|
||||
# 验证用户是否可登录(修复:显式枚举比较,StrEnum 永远 truthy)
|
||||
if current_user.status != UserStatus.ACTIVE:
|
||||
http_exceptions.raise_forbidden("Your account is disabled")
|
||||
|
||||
# 检查两步验证
|
||||
@@ -58,10 +58,22 @@ async def login(
|
||||
logger.debug(f"Invalid 2FA code for user: {login_request.email}")
|
||||
http_exceptions.raise_unauthorized("Invalid 2FA code")
|
||||
|
||||
# 加载 GroupOptions
|
||||
group_options: GroupOptions | None = await GroupOptions.get(
|
||||
session,
|
||||
GroupOptions.group_id == current_user.group_id,
|
||||
)
|
||||
|
||||
# 构建权限快照
|
||||
current_user.group.options = group_options
|
||||
group_claims = GroupClaims.from_group(current_user.group)
|
||||
|
||||
# 创建令牌
|
||||
access_token = create_access_token(
|
||||
sub=current_user.id,
|
||||
jti=uuid4()
|
||||
sub=current_user.id,
|
||||
jti=uuid4(),
|
||||
status=current_user.status.value,
|
||||
group=group_claims,
|
||||
)
|
||||
refresh_token = create_refresh_token(
|
||||
sub=current_user.id,
|
||||
@@ -73,4 +85,4 @@ async def login(
|
||||
access_expires=access_token.access_expires,
|
||||
refresh_token=refresh_token.refresh_token,
|
||||
refresh_expires=refresh_token.refresh_expires,
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user