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,49 +4,79 @@ from uuid import UUID
from fastapi import Depends
import jwt
from sqlmodels.user import User
from sqlmodels.user import JWTPayload, User, UserStatus
from utils import JWT
from .dependencies import SessionDep
from utils import http_exceptions
from service.redis import RedisManager
from service.redis.user_ban_store import UserBanStore
async def auth_required(
async def jwt_required(
session: SessionDep,
token: Annotated[str, Depends(JWT.oauth2_scheme)],
) -> User:
) -> JWTPayload:
"""
AuthRequired 需要登录
验证 JWT 并返回 claims。
封禁检查策略:
1. JWT 内嵌 status 检查(签发时快照)
2. Redis 黑名单检查(即时封禁,如果 Redis 可用)
3. Redis 不可用时查库检查 status降级方案
"""
try:
payload = jwt.decode(token, JWT.SECRET_KEY, algorithms=["HS256"])
user_id = payload.get("sub")
if user_id is None:
http_exceptions.raise_unauthorized("账号或密码错误")
user_id = UUID(user_id)
# 从数据库获取用户信息(预加载 group 关系)
user = await User.get(session, User.id == user_id, load=User.group)
if not user:
http_exceptions.raise_unauthorized("账号或密码错误")
return user
except jwt.InvalidTokenError:
claims = JWTPayload(
sub=payload["sub"],
jti=payload["jti"],
status=payload["status"],
group=payload["group"],
)
except (jwt.InvalidTokenError, KeyError, ValueError):
http_exceptions.raise_unauthorized("凭据过期或无效")
# 1. JWT 内嵌 status 检查
if claims.status != UserStatus.ACTIVE:
http_exceptions.raise_forbidden("账户已被禁用")
# 2. 即时封禁检查
user_id_str = str(claims.sub)
if RedisManager.is_available():
# Redis 可用:查黑名单
if await UserBanStore.is_banned(user_id_str):
http_exceptions.raise_forbidden("账户已被禁用")
else:
# Redis 不可用:查库(仅 status 字段,不加载关系)
user = await User.get(session, User.id == claims.sub)
if not user or user.status != UserStatus.ACTIVE:
http_exceptions.raise_forbidden("账户已被禁用")
return claims
async def admin_required(
user: Annotated[User, Depends(auth_required)],
) -> User:
claims: Annotated[JWTPayload, Depends(jwt_required)],
) -> JWTPayload:
"""
验证是否为管理员。
验证管理员权限(仅读取 JWT claims不查库
使用方法:
>>> APIRouter(dependencies=[Depends(admin_required)])
"""
if user.group.admin:
return user
raise http_exceptions.raise_forbidden("Admin Required")
if not claims.group.admin:
http_exceptions.raise_forbidden("Admin Required")
return claims
async def auth_required(
session: SessionDep,
claims: Annotated[JWTPayload, Depends(jwt_required)],
) -> User:
"""验证 JWT + 从数据库加载完整 User含 group 关系)"""
user = await User.get(session, User.id == claims.sub, load=User.group)
if not user:
http_exceptions.raise_unauthorized("用户不存在")
return user
def verify_download_token(token: str) -> tuple[str, UUID, UUID] | None:
@@ -65,4 +95,4 @@ def verify_download_token(token: str) -> tuple[str, UUID, UUID] | None:
http_exceptions.raise_unauthorized("Download token required")
return jti, UUID(payload["file_id"]), UUID(payload["owner_id"])
except jwt.InvalidTokenError:
http_exceptions.raise_unauthorized("Download token required")
http_exceptions.raise_unauthorized("Download token required")

View File

@@ -6,12 +6,14 @@ FastAPI 依赖注入
- TimeFilterRequestDep: 时间筛选查询依赖(用于 count 等统计接口)
- TableViewRequestDep: 分页排序查询依赖(包含时间筛选 + 分页排序)
- UserFilterParamsDep: 用户筛选参数依赖(用于管理员用户列表)
- require_captcha: 验证码校验依赖注入工厂
"""
from collections.abc import Awaitable, Callable
from datetime import datetime
from typing import Annotated, Literal, TypeAlias
from uuid import UUID
from fastapi import Depends, Query
from fastapi import Depends, Form, Query
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodels.database_connection import DatabaseManager
@@ -94,3 +96,30 @@ async def _get_user_filter_params(
UserFilterParamsDep: TypeAlias = Annotated[UserFilterParams, Depends(_get_user_filter_params)]
"""获取用户筛选参数的依赖(用于管理员用户列表)"""
# --- 验证码校验依赖 ---
def require_captcha(scene: 'CaptchaScene') -> Callable[..., Awaitable[None]]:
"""
验证码校验依赖注入工厂。
根据场景查询数据库设置,判断是否需要验证码。
需要则校验前端提交的 captcha_code失败则抛出异常。
使用方式::
@router.post('/session', dependencies=[Depends(require_captcha(CaptchaScene.LOGIN))])
async def login(...): ...
:param scene: 验证码使用场景LOGIN / REGISTER / FORGET
"""
from service.captcha import CaptchaScene, verify_captcha_if_needed
async def _verify_captcha(
session: SessionDep,
captcha_code: Annotated[str | None, Form()] = None,
) -> None:
await verify_captcha_if_needed(session, scene, captcha_code)
return _verify_captcha