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}")

View File

@@ -2,8 +2,7 @@ from typing import Annotated, Literal
from uuid import UUID, uuid4
import jwt
from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from fastapi import APIRouter, Depends, Form, HTTPException
from loguru import logger
from webauthn import generate_registration_options
from webauthn.helpers import options_to_json_dict
@@ -11,7 +10,9 @@ from webauthn.helpers import options_to_json_dict
import service
import sqlmodels
from middleware.auth import auth_required
from middleware.dependencies import SessionDep
from middleware.dependencies import SessionDep, require_captcha
from service.captcha import CaptchaScene
from sqlmodels.user import UserStatus
from utils import JWT, Password, http_exceptions
from .settings import user_settings_router
@@ -22,48 +23,60 @@ user_router = APIRouter(
user_router.include_router(user_settings_router)
class OAuth2PasswordWithExtrasForm:
"""
扩展 OAuth2 密码表单。
在标准 username/password 基础上添加 otp_code 字段。
captcha_code 由 require_captcha 依赖注入单独处理。
"""
def __init__(
self,
*,
username: Annotated[str, Form()],
password: Annotated[str, Form()],
otp_code: Annotated[str | None, Form(min_length=6, max_length=6)] = None,
):
self.username = username
self.password = password
self.otp_code = otp_code
@user_router.post(
path='/session',
summary='用户登录',
description='User login endpoint. 当用户启用两步验证时,需要传入 otp 参数',
description='用户登录端点,支持验证码校验和两步验证',
dependencies=[Depends(require_captcha(CaptchaScene.LOGIN))],
)
async def router_user_session(
session: SessionDep,
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
form_data: Annotated[OAuth2PasswordWithExtrasForm, Depends()],
) -> sqlmodels.TokenResponse:
"""
用户登录端点
用户登录端点
根据 OAuth2.1 规范,使用 password grant type 进行登录。
当用户启用两步验证时,需要在表单中传入 otp 参数(通过 scopes 字段传递)。
表单字段:
- username: 用户邮箱
- password: 用户密码
- captcha_code: 验证码 token可选由 require_captcha 依赖校验)
- otp_code: 两步验证码(可选,仅在用户启用 2FA 时需要)
OAuth2 scopes 字段格式: "otp:123456" 或直接传入验证码
错误处理:
- 400: 需要验证码但未提供
- 401: 邮箱/密码错误,或 2FA 验证码错误
- 403: 账户已禁用 / 验证码验证失败
- 428: 需要两步验证但未提供 otp_code
"""
email = form_data.username # OAuth2 表单字段名为 username实际传入的是 email
password = form_data.password
# 从 scopes 中提取 OTP 验证码OAuth2.1 扩展方式)
# scopes 格式可以是 ["otp:123456"] 或 ["123456"]
otp_code: str | None = None
for scope in form_data.scopes:
if scope.startswith("otp:"):
otp_code = scope[4:]
break
elif scope.isdigit() and len(scope) == 6:
otp_code = scope
break
result = await service.user.login(
return await service.user.login(
session,
sqlmodels.LoginRequest(
email=email,
password=password,
two_fa_code=otp_code,
email=form_data.username,
password=form_data.password,
two_fa_code=form_data.otp_code,
),
)
return result
@user_router.post(
path='/session/refresh',
summary="用刷新令牌刷新会话",
@@ -101,17 +114,27 @@ async def router_user_session_refresh(
http_exceptions.raise_unauthorized("令牌缺少用户标识")
user_id = UUID(user_id_str)
user = await sqlmodels.User.get(session, sqlmodels.User.id == user_id)
user = await sqlmodels.User.get(session, sqlmodels.User.id == user_id, load=sqlmodels.User.group)
if not user:
http_exceptions.raise_unauthorized("用户不存在")
if not user.status:
if user.status != UserStatus.ACTIVE:
http_exceptions.raise_forbidden("账户已被禁用")
# 加载 GroupOptions获取最新权限
group_options = await sqlmodels.GroupOptions.get(
session,
sqlmodels.GroupOptions.group_id == user.group_id,
)
user.group.options = group_options
group_claims = sqlmodels.GroupClaims.from_group(user.group)
# 签发新令牌
access_token = JWT.create_access_token(
sub=user.id,
jti=uuid4(),
status=user.status.value,
group=group_claims,
)
refresh_token = JWT.create_refresh_token(
sub=user.id,