fix: patch storage quota bypass and harden auth security
All checks were successful
Test / test (push) Successful in 2m11s
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:
@@ -1,5 +1,6 @@
|
|||||||
from fastapi import APIRouter, Query
|
from fastapi import APIRouter, Query
|
||||||
from fastapi.responses import PlainTextResponse
|
from fastapi.responses import PlainTextResponse
|
||||||
|
from loguru import logger as l
|
||||||
|
|
||||||
from sqlmodels import ResponseBase
|
from sqlmodels import ResponseBase
|
||||||
import service.oauth
|
import service.oauth
|
||||||
@@ -64,16 +65,17 @@ async def router_callback_github(
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
access_token = await service.oauth.github.get_access_token(code)
|
access_token = await service.oauth.github.get_access_token(code)
|
||||||
# [TODO] 把access_token写数据库里
|
|
||||||
if not 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)
|
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:
|
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(
|
@pay_router.post(
|
||||||
path='/alipay',
|
path='/alipay',
|
||||||
|
|||||||
@@ -318,7 +318,7 @@ async def router_user_magic_link(
|
|||||||
site_url = site_url_setting.value if site_url_setting else "http://localhost"
|
site_url = site_url_setting.value if site_url_setting else "http://localhost"
|
||||||
|
|
||||||
# TODO: 发送邮件(包含 {site_url}/auth/magic-link?token={token})
|
# 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(
|
@user_router.post(
|
||||||
|
|||||||
@@ -260,6 +260,33 @@ def _check_storage_quota(user: User, additional_bytes: int) -> None:
|
|||||||
raise DAVError(HTTP_INSUFFICIENT_STORAGE, "存储空间不足")
|
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 ====================
|
# ==================== Provider ====================
|
||||||
|
|
||||||
class DiskNextDAVProvider(DAVProvider):
|
class DiskNextDAVProvider(DAVProvider):
|
||||||
@@ -441,7 +468,7 @@ class DiskNextFile(DAVNonCollection):
|
|||||||
self._user_id = user_id
|
self._user_id = user_id
|
||||||
self._account = account
|
self._account = account
|
||||||
self._write_path: str | None = None
|
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:
|
def get_content_length(self) -> int | None:
|
||||||
return self._obj.size if self._obj.size else 0
|
return self._obj.size if self._obj.size else 0
|
||||||
@@ -486,20 +513,32 @@ class DiskNextFile(DAVNonCollection):
|
|||||||
|
|
||||||
return open(full_path, "rb") # noqa: SIM115
|
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 操作)。
|
开始写入文件(PUT 操作)。
|
||||||
|
|
||||||
返回一个可写的文件流,WsgiDAV 将向其中写入请求体数据。
|
返回一个可写的文件流,WsgiDAV 将向其中写入请求体数据。
|
||||||
|
当用户有配额限制时,返回 QuotaLimitedWriter 在写入过程中实时检查配额。
|
||||||
"""
|
"""
|
||||||
_check_readonly(self.environ)
|
_check_readonly(self.environ)
|
||||||
|
|
||||||
# 检查配额
|
# 检查配额
|
||||||
|
remaining_quota: int = 0
|
||||||
user = _run_async(_get_user(self._user_id))
|
user = _run_async(_get_user(self._user_id))
|
||||||
if user:
|
if user:
|
||||||
content_length = self.environ.get("CONTENT_LENGTH")
|
max_storage = user.group.max_storage
|
||||||
if content_length:
|
if max_storage > 0:
|
||||||
_check_storage_quota(user, int(content_length))
|
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))
|
policy = _run_async(_get_policy(self._obj.policy_id))
|
||||||
@@ -515,7 +554,14 @@ class DiskNextFile(DAVNonCollection):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self._write_path = full_path
|
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
|
return self._write_stream
|
||||||
|
|
||||||
def end_write(self, *, with_errors: bool) -> None:
|
def end_write(self, *, with_errors: bool) -> None:
|
||||||
@@ -524,7 +570,14 @@ class DiskNextFile(DAVNonCollection):
|
|||||||
self._write_stream.close()
|
self._write_stream.close()
|
||||||
self._write_stream = None
|
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
|
return
|
||||||
|
|
||||||
# 获取文件大小
|
# 获取文件大小
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
|
|
||||||
支持多种认证方式:邮箱密码、GitHub OAuth、QQ OAuth、Passkey、Magic Link、手机短信(预留)。
|
支持多种认证方式:邮箱密码、GitHub OAuth、QQ OAuth、Passkey、Magic Link、手机短信(预留)。
|
||||||
"""
|
"""
|
||||||
|
import hashlib
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
||||||
from loguru import logger as l
|
from loguru import logger as l
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from service.redis.token_store import TokenStore
|
||||||
from sqlmodels.auth_identity import AuthIdentity, AuthProviderType
|
from sqlmodels.auth_identity import AuthIdentity, AuthProviderType
|
||||||
from sqlmodels.group import GroupClaims, GroupOptions
|
from sqlmodels.group import GroupClaims, GroupOptions
|
||||||
from sqlmodels.object import Object, ObjectType
|
from sqlmodels.object import Object, ObjectType
|
||||||
@@ -363,6 +365,12 @@ async def _login_magic_link(
|
|||||||
except BadSignature:
|
except BadSignature:
|
||||||
http_exceptions.raise_unauthorized("Magic Link 无效")
|
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)
|
# 查找绑定了该邮箱的 AuthIdentity(email_password 或 magic_link)
|
||||||
identity: AuthIdentity | None = await AuthIdentity.get(
|
identity: AuthIdentity | None = await AuthIdentity.get(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ async def load_secret_key() -> None:
|
|||||||
if setting:
|
if setting:
|
||||||
SECRET_KEY = setting.value
|
SECRET_KEY = setting.value
|
||||||
|
|
||||||
|
if not SECRET_KEY:
|
||||||
|
raise RuntimeError(
|
||||||
|
"JWT SECRET_KEY 未配置,拒绝启动。"
|
||||||
|
"请在 Setting 表中添加 type='auth', name='secret_key' 的记录。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_token_payload(
|
def build_token_payload(
|
||||||
data: dict,
|
data: dict,
|
||||||
|
|||||||
Reference in New Issue
Block a user