fix: patch storage quota bypass and harden auth security
All checks were successful
Test / test (push) Successful in 2m11s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 22:20:43 +08:00
parent 40b6a31c98
commit 7200df6d87
5 changed files with 84 additions and 15 deletions

View File

@@ -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写数据库
# [TODO] 把 access_token 和 user_data 写数据库,生成 JWT重定向到前端
l.info(f"GitHub OAuth 回调成功: user={user_data.user_data.login}")
return PlainTextResponse(f"User information processed successfully, code: {code}, user_data: {user_data.json_dump()}", status_code=200)
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',

View File

@@ -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(

View File

@@ -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:
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:
_check_storage_quota(user, int(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
# 获取文件大小

View File

@@ -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 已被使用")
# 查找绑定了该邮箱的 AuthIdentityemail_password 或 magic_link
identity: AuthIdentity | None = await AuthIdentity.get(
session,

View File

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