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:
2026-02-10 19:07:00 +08:00
parent 209cb24ab4
commit a99091ea7a
20 changed files with 766 additions and 244 deletions

View File

@@ -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,
)
)