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

@@ -10,7 +10,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from middleware.auth import admin_required
from middleware.dependencies import SessionDep, TableViewRequestDep
from sqlmodels import (
Policy, PolicyType, User, ListResponse,
JWTPayload, Policy, PolicyType, User, ListResponse,
Object, ObjectType, AdminFileResponse, FileBanRequest, )
from service.storage import LocalStorageService
@@ -164,14 +164,13 @@ async def router_admin_preview_file(
path='/ban/{file_id}',
summary='封禁/解禁文件',
description='Ban the file, user can\'t open, copy, move, download or share this file if administrator ban.',
dependencies=[Depends(admin_required)],
status_code=204,
)
async def router_admin_ban_file(
session: SessionDep,
file_id: UUID,
request: FileBanRequest,
admin: Annotated[User, Depends(admin_required)],
claims: Annotated[JWTPayload, Depends(admin_required)],
) -> None:
"""
封禁或解禁文件/文件夹。封禁后用户无法访问该文件。
@@ -180,14 +179,14 @@ async def router_admin_ban_file(
:param session: 数据库会话
:param file_id: 文件UUID
:param request: 封禁请求
:param admin: 当前管理员
:param claims: 当前管理员 JWT claims
:return: 封禁结果
"""
file_obj = await Object.get(session, Object.id == file_id)
if not file_obj:
raise HTTPException(status_code=404, detail="文件不存在")
count = await _set_ban_recursive(session, file_obj, request.ban, admin.id, request.reason)
count = await _set_ban_recursive(session, file_obj, request.ban, claims.sub, request.reason)
action = "封禁" if request.ban else "解禁"
l.info(f"管理员{action}了对象: {file_obj.name},共影响 {count} 个对象")

View File

@@ -6,13 +6,14 @@ from sqlalchemy import func
from middleware.auth import admin_required
from middleware.dependencies import SessionDep, TableViewRequestDep, UserFilterParamsDep
from service.redis.user_ban_store import UserBanStore
from sqlmodels import (
User, ResponseBase, UserPublic, ListResponse,
Group, Object, ObjectType, Setting, SettingsType,
BatchDeleteRequest,
)
from sqlmodels.user import (
UserAdminCreateRequest, UserAdminUpdateRequest, UserCalibrateResponse,
UserAdminCreateRequest, UserAdminUpdateRequest, UserCalibrateResponse, UserStatus,
)
from utils import Password, http_exceptions
@@ -159,11 +160,21 @@ async def router_admin_update_user(
if len(update_data['two_factor']) != 32:
raise HTTPException(status_code=400, detail="两步验证密钥必须为32位字符串")
# 记录旧 status 以便检测变更
old_status = user.status
# 更新字段
for key, value in update_data.items():
setattr(user, key, value)
user = await user.save(session)
# 封禁状态变更 → 更新 BanStore
new_status = user.status
if old_status == UserStatus.ACTIVE and new_status != UserStatus.ACTIVE:
await UserBanStore.ban(str(user_id))
elif old_status != UserStatus.ACTIVE and new_status == UserStatus.ACTIVE:
await UserBanStore.unban(str(user_id))
l.info(f"管理员更新了用户: {request.email}")