From 7200df6d877160fd1a74dfa0513cd0a6185875d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8E=E5=B0=8F=E4=B8=98?= Date: Tue, 17 Feb 2026 22:20:43 +0800 Subject: [PATCH] fix: patch storage quota bypass and harden auth security - Fix WebDAV chunked PUT bypassing storage quota when remaining_quota <= 0 - Add QuotaLimitedWriter to enforce quota during streaming writes - Clean up residual files on write failure in end_write() - Add Magic Link replay attack prevention via TokenStore - Reject startup when JWT SECRET_KEY is not configured - Sanitize OAuth callback and Magic Link log output Co-Authored-By: Claude Opus 4.6 --- routers/api/v1/callback/__init__.py | 16 ++++--- routers/api/v1/user/__init__.py | 2 +- routers/dav/provider.py | 67 ++++++++++++++++++++++++++--- service/user/login.py | 8 ++++ utils/JWT/__init__.py | 6 +++ 5 files changed, 84 insertions(+), 15 deletions(-) diff --git a/routers/api/v1/callback/__init__.py b/routers/api/v1/callback/__init__.py index 778bf5d..d57a9c4 100644 --- a/routers/api/v1/callback/__init__.py +++ b/routers/api/v1/callback/__init__.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, Query from fastapi.responses import PlainTextResponse +from loguru import logger as l from sqlmodels import ResponseBase import service.oauth @@ -64,16 +65,17 @@ async def router_callback_github( """ try: access_token = await service.oauth.github.get_access_token(code) - # [TODO] 把access_token写数据库里 if not access_token: - return PlainTextResponse("Failed to retrieve access token from GitHub.", status_code=400) - + return PlainTextResponse("GitHub 认证失败", status_code=400) + user_data = await service.oauth.github.get_user_info(access_token.access_token) - # [TODO] 把user_data写数据库里 - - return PlainTextResponse(f"User information processed successfully, code: {code}, user_data: {user_data.json_dump()}", status_code=200) + # [TODO] 把 access_token 和 user_data 写数据库,生成 JWT,重定向到前端 + l.info(f"GitHub OAuth 回调成功: user={user_data.user_data.login}") + + return PlainTextResponse("认证成功,功能开发中", status_code=200) except Exception as e: - return PlainTextResponse(f"An error occurred: {str(e)}", status_code=500) + l.error(f"GitHub OAuth 回调异常: {e}") + return PlainTextResponse("认证过程中发生错误,请重试", status_code=500) @pay_router.post( path='/alipay', diff --git a/routers/api/v1/user/__init__.py b/routers/api/v1/user/__init__.py index 335346c..2e829c0 100644 --- a/routers/api/v1/user/__init__.py +++ b/routers/api/v1/user/__init__.py @@ -318,7 +318,7 @@ async def router_user_magic_link( site_url = site_url_setting.value if site_url_setting else "http://localhost" # TODO: 发送邮件(包含 {site_url}/auth/magic-link?token={token}) - logger.info(f"Magic Link token 已生成: {token} (邮件发送待实现)") + logger.info(f"Magic Link token 已为 {request.email} 生成 (邮件发送待实现)") @user_router.post( diff --git a/routers/dav/provider.py b/routers/dav/provider.py index 802db22..5c56adc 100644 --- a/routers/dav/provider.py +++ b/routers/dav/provider.py @@ -260,6 +260,33 @@ def _check_storage_quota(user: User, additional_bytes: int) -> None: raise DAVError(HTTP_INSUFFICIENT_STORAGE, "存储空间不足") +class QuotaLimitedWriter(io.RawIOBase): + """带配额限制的写入流包装器""" + + def __init__(self, stream: io.BufferedWriter, max_bytes: int) -> None: + self._stream = stream + self._max_bytes = max_bytes + self._bytes_written = 0 + + def writable(self) -> bool: + return True + + def write(self, b: bytes | bytearray) -> int: + if self._bytes_written + len(b) > self._max_bytes: + raise DAVError(HTTP_INSUFFICIENT_STORAGE, "存储空间不足") + written = self._stream.write(b) + self._bytes_written += written + return written + + def close(self) -> None: + self._stream.close() + super().close() + + @property + def bytes_written(self) -> int: + return self._bytes_written + + # ==================== Provider ==================== class DiskNextDAVProvider(DAVProvider): @@ -441,7 +468,7 @@ class DiskNextFile(DAVNonCollection): self._user_id = user_id self._account = account self._write_path: str | None = None - self._write_stream: io.BufferedWriter | None = None + self._write_stream: io.BufferedWriter | QuotaLimitedWriter | None = None def get_content_length(self) -> int | None: return self._obj.size if self._obj.size else 0 @@ -486,20 +513,32 @@ class DiskNextFile(DAVNonCollection): return open(full_path, "rb") # noqa: SIM115 - def begin_write(self, *, content_type: str | None = None) -> io.BufferedWriter: + def begin_write( + self, + *, + content_type: str | None = None, + ) -> io.BufferedWriter | QuotaLimitedWriter: """ 开始写入文件(PUT 操作)。 返回一个可写的文件流,WsgiDAV 将向其中写入请求体数据。 + 当用户有配额限制时,返回 QuotaLimitedWriter 在写入过程中实时检查配额。 """ _check_readonly(self.environ) # 检查配额 + remaining_quota: int = 0 user = _run_async(_get_user(self._user_id)) if user: - content_length = self.environ.get("CONTENT_LENGTH") - if content_length: - _check_storage_quota(user, int(content_length)) + max_storage = user.group.max_storage + if max_storage > 0: + remaining_quota = max_storage - user.storage + if remaining_quota <= 0: + raise DAVError(HTTP_INSUFFICIENT_STORAGE, "存储空间不足") + # Content-Length 预检(如果有的话) + content_length = self.environ.get("CONTENT_LENGTH") + if content_length and int(content_length) > remaining_quota: + raise DAVError(HTTP_INSUFFICIENT_STORAGE, "存储空间不足") # 获取策略以确定存储路径 policy = _run_async(_get_policy(self._obj.policy_id)) @@ -515,7 +554,14 @@ class DiskNextFile(DAVNonCollection): ) self._write_path = full_path - self._write_stream = open(full_path, "wb") # noqa: SIM115 + raw_stream = open(full_path, "wb") # noqa: SIM115 + + # 有配额限制时使用包装流,实时检查写入量 + if remaining_quota > 0: + self._write_stream = QuotaLimitedWriter(raw_stream, remaining_quota) + else: + self._write_stream = raw_stream + return self._write_stream def end_write(self, *, with_errors: bool) -> None: @@ -524,7 +570,14 @@ class DiskNextFile(DAVNonCollection): self._write_stream.close() self._write_stream = None - if with_errors or not self._write_path: + if with_errors: + if self._write_path: + file_path = Path(self._write_path) + if file_path.exists(): + file_path.unlink() + return + + if not self._write_path: return # 获取文件大小 diff --git a/service/user/login.py b/service/user/login.py index aace36c..4b97002 100644 --- a/service/user/login.py +++ b/service/user/login.py @@ -3,12 +3,14 @@ 支持多种认证方式:邮箱密码、GitHub OAuth、QQ OAuth、Passkey、Magic Link、手机短信(预留)。 """ +import hashlib from uuid import UUID, uuid4 from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired from loguru import logger as l from sqlmodel.ext.asyncio.session import AsyncSession +from service.redis.token_store import TokenStore from sqlmodels.auth_identity import AuthIdentity, AuthProviderType from sqlmodels.group import GroupClaims, GroupOptions from sqlmodels.object import Object, ObjectType @@ -363,6 +365,12 @@ async def _login_magic_link( except BadSignature: http_exceptions.raise_unauthorized("Magic Link 无效") + # 防重放:使用 token 哈希作为标识符 + token_hash = hashlib.sha256(request.identifier.encode()).hexdigest() + is_first_use = await TokenStore.mark_used(f"magic_link:{token_hash}", ttl=600) + if not is_first_use: + http_exceptions.raise_unauthorized("Magic Link 已被使用") + # 查找绑定了该邮箱的 AuthIdentity(email_password 或 magic_link) identity: AuthIdentity | None = await AuthIdentity.get( session, diff --git a/utils/JWT/__init__.py b/utils/JWT/__init__.py index a91377d..06e53fd 100644 --- a/utils/JWT/__init__.py +++ b/utils/JWT/__init__.py @@ -37,6 +37,12 @@ async def load_secret_key() -> None: if setting: SECRET_KEY = setting.value + if not SECRET_KEY: + raise RuntimeError( + "JWT SECRET_KEY 未配置,拒绝启动。" + "请在 Setting 表中添加 type='auth', name='secret_key' 的记录。" + ) + def build_token_payload( data: dict,