From dc2b6910a423b3bfadeffaa303e1ba75cfb33900 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Tue, 9 Jun 2026 21:45:51 +0800 Subject: [PATCH] fix: restrict sensitive system endpoints --- app/api/endpoints/dashboard.py | 129 ++++++++++------- app/api/endpoints/login.py | 25 +++- app/api/endpoints/mfa.py | 27 +++- app/api/endpoints/plugin.py | 63 ++++++++- app/api/endpoints/system.py | 44 +++++- app/api/endpoints/user.py | 7 +- app/core/security.py | 6 +- docs/mcp-api.md | 7 + skills/moviepilot-api/SKILL.md | 4 +- tests/test_api_authorization.py | 242 ++++++++++++++++++++++++++++++++ 10 files changed, 476 insertions(+), 78 deletions(-) create mode 100644 tests/test_api_authorization.py diff --git a/app/api/endpoints/dashboard.py b/app/api/endpoints/dashboard.py index d0482e06..35ca72b2 100644 --- a/app/api/endpoints/dashboard.py +++ b/app/api/endpoints/dashboard.py @@ -7,9 +7,10 @@ from sqlalchemy.orm import Session from app import schemas from app.chain.dashboard import DashboardChain from app.chain.storage import StorageChain -from app.core.security import verify_token, verify_apitoken +from app.core.security import verify_apitoken from app.db import get_db from app.db.models.transferhistory import TransferHistory +from app.db.user_oper import get_current_active_superuser from app.helper.directory import DirectoryHelper from app.scheduler import Scheduler from app.utils.system import SystemUtils @@ -17,12 +18,9 @@ from app.utils.system import SystemUtils router = APIRouter() -@router.get("/statistic", summary="媒体数量统计", response_model=schemas.Statistic) -def statistic( - name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token) -) -> Any: +def _build_statistic(name: Optional[str] = None) -> schemas.Statistic: """ - 查询媒体数量统计信息 + 构建媒体数量统计信息。 """ media_statistics: Optional[List[schemas.Statistic]] = ( DashboardChain().media_statistic(name) @@ -42,24 +40,12 @@ def statistic( # 所有媒体服务都未提供剧集统计时,返回 None 供前端展示“未获取”。 ret_statistic.episode_count = None return ret_statistic - else: - return schemas.Statistic() + return schemas.Statistic() -@router.get( - "/statistic2", summary="媒体数量统计(API_TOKEN)", response_model=schemas.Statistic -) -def statistic2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: +def _build_storage() -> schemas.Storage: """ - 查询媒体数量统计信息 API_TOKEN认证(?token=xxx) - """ - return statistic() - - -@router.get("/storage", summary="本地存储空间", response_model=schemas.Storage) -def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any: - """ - 查询本地存储空间信息 + 构建本地存储空间信息。 """ total, available = 0, 0 dirs = DirectoryHelper().get_dirs() @@ -74,30 +60,9 @@ def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any: return schemas.Storage(total_storage=total, used_storage=total - available) -@router.get( - "/storage2", summary="本地存储空间(API_TOKEN)", response_model=schemas.Storage -) -def storage2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: +def _build_downloader(name: Optional[str] = None) -> schemas.DownloaderInfo: """ - 查询本地存储空间信息 API_TOKEN认证(?token=xxx) - """ - return storage() - - -@router.get("/processes", summary="进程信息", response_model=List[schemas.ProcessInfo]) -def processes(_: schemas.TokenPayload = Depends(verify_token)) -> Any: - """ - 查询进程信息 - """ - return SystemUtils.processes() - - -@router.get("/downloader", summary="下载器信息", response_model=schemas.DownloaderInfo) -def downloader( - name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token) -) -> Any: - """ - 查询下载器信息 + 构建下载器统计信息。 """ # 下载目录空间 download_dirs = DirectoryHelper().get_local_download_dirs() @@ -117,6 +82,62 @@ def downloader( return downloader_info +@router.get("/statistic", summary="媒体数量统计", response_model=schemas.Statistic) +def statistic( + name: Optional[str] = None, _: Any = Depends(get_current_active_superuser) +) -> Any: + """ + 查询媒体数量统计信息 + """ + return _build_statistic(name) + + +@router.get( + "/statistic2", summary="媒体数量统计(API_TOKEN)", response_model=schemas.Statistic +) +def statistic2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: + """ + 查询媒体数量统计信息 API_TOKEN认证(?token=xxx) + """ + return _build_statistic() + + +@router.get("/storage", summary="本地存储空间", response_model=schemas.Storage) +def storage(_: Any = Depends(get_current_active_superuser)) -> Any: + """ + 查询本地存储空间信息 + """ + return _build_storage() + + +@router.get( + "/storage2", summary="本地存储空间(API_TOKEN)", response_model=schemas.Storage +) +def storage2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: + """ + 查询本地存储空间信息 API_TOKEN认证(?token=xxx) + """ + return _build_storage() + + +@router.get("/processes", summary="进程信息", response_model=List[schemas.ProcessInfo]) +def processes(_: Any = Depends(get_current_active_superuser)) -> Any: + """ + 查询进程信息 + """ + return SystemUtils.processes() + + +@router.get("/downloader", summary="下载器信息", response_model=schemas.DownloaderInfo) +def downloader( + name: Optional[str] = None, _: Any = Depends(get_current_active_superuser) +) -> Any: + """ + 查询下载器信息 + """ + return _build_downloader(name) + + @router.get( "/downloader2", summary="下载器信息(API_TOKEN)", @@ -126,11 +147,11 @@ def downloader2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: """ 查询下载器信息 API_TOKEN认证(?token=xxx) """ - return downloader() + return _build_downloader() @router.get("/schedule", summary="后台服务", response_model=List[schemas.ScheduleInfo]) -async def schedule(_: schemas.TokenPayload = Depends(verify_token)) -> Any: +async def schedule(_: Any = Depends(get_current_active_superuser)) -> Any: """ 查询后台服务信息 """ @@ -146,14 +167,14 @@ async def schedule2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: """ 查询下载器信息 API_TOKEN认证(?token=xxx) """ - return await schedule() + return Scheduler().list() @router.get("/transfer", summary="文件整理统计", response_model=List[int]) async def transfer( days: Optional[int] = 7, db: Session = Depends(get_db), - _: schemas.TokenPayload = Depends(verify_token), + _: Any = Depends(get_current_active_superuser), ) -> Any: """ 查询文件整理统计信息 @@ -163,7 +184,7 @@ async def transfer( @router.get("/cpu", summary="获取当前CPU使用率", response_model=float) -def cpu(_: schemas.TokenPayload = Depends(verify_token)) -> Any: +def cpu(_: Any = Depends(get_current_active_superuser)) -> Any: """ 获取当前CPU使用率 """ @@ -175,11 +196,11 @@ def cpu2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: """ 获取当前CPU使用率 API_TOKEN认证(?token=xxx) """ - return cpu() + return SystemUtils.cpu_usage() @router.get("/memory", summary="获取当前内存使用量和使用率", response_model=List[int]) -def memory(_: schemas.TokenPayload = Depends(verify_token)) -> Any: +def memory(_: Any = Depends(get_current_active_superuser)) -> Any: """ 获取当前内存使用率 """ @@ -195,11 +216,11 @@ def memory2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: """ 获取当前内存使用率 API_TOKEN认证(?token=xxx) """ - return memory() + return SystemUtils.memory_usage() @router.get("/network", summary="获取当前网络流量", response_model=List[int]) -def network(_: schemas.TokenPayload = Depends(verify_token)) -> Any: +def network(_: Any = Depends(get_current_active_superuser)) -> Any: """ 获取当前网络流量(上行和下行流量,单位:bytes/s) """ @@ -213,4 +234,4 @@ def network2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: """ 获取当前网络流量 API_TOKEN认证(?token=xxx) """ - return network() + return SystemUtils.network_usage() diff --git a/app/api/endpoints/login.py b/app/api/endpoints/login.py index cf543c59..40232206 100644 --- a/app/api/endpoints/login.py +++ b/app/api/endpoints/login.py @@ -1,7 +1,7 @@ from datetime import timedelta from typing import Any, List, Annotated -from fastapi import APIRouter, Depends, Form, HTTPException +from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response from fastapi.security import OAuth2PasswordRequestForm from app import schemas @@ -18,6 +18,8 @@ router = APIRouter() @router.post("/access-token", summary="获取token", response_model=schemas.Token) def login_access_token( + request: Request, + response: Response, form_data: Annotated[OAuth2PasswordRequestForm, Depends()], otp_password: Annotated[str | None, Form()] = None, ) -> Any: @@ -45,14 +47,27 @@ def login_access_token( not SystemConfigOper().get(SystemConfigKey.SetupWizardState) and not settings.ADVANCED_MODE ) - return schemas.Token( - access_token=security.create_access_token( - userid=user_or_message.id, + access_token = security.create_access_token( + userid=user_or_message.id, + username=user_or_message.name, + super_user=user_or_message.is_superuser, + expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES), + level=level, + ) + security.set_or_refresh_resource_token_cookie( + request, + response, + schemas.TokenPayload( + sub=user_or_message.id, username=user_or_message.name, super_user=user_or_message.is_superuser, - expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES), level=level, + purpose="authentication", ), + ) + + return schemas.Token( + access_token=access_token, token_type="bearer", super_user=user_or_message.is_superuser, user_id=user_or_message.id, diff --git a/app/api/endpoints/mfa.py b/app/api/endpoints/mfa.py index ce90761c..4cec79d2 100644 --- a/app/api/endpoints/mfa.py +++ b/app/api/endpoints/mfa.py @@ -7,7 +7,7 @@ from datetime import timedelta from typing import Any, Annotated, Optional from app.helper.sites import SitesHelper -from fastapi import APIRouter, Depends, HTTPException, Body +from fastapi import APIRouter, Depends, HTTPException, Body, Request, Response from sqlalchemy.ext.asyncio import AsyncSession from app import schemas @@ -356,7 +356,9 @@ def passkey_authenticate_start( summary="完成 PassKey 认证", response_model=schemas.Token, ) -def passkey_authenticate_finish(passkey_req: PassKeyAuthenticationFinish) -> Any: +def passkey_authenticate_finish( + request: Request, response: Response, passkey_req: PassKeyAuthenticationFinish +) -> Any: """完成 PassKey 认证 - 验证凭证并返回 token""" try: # 提取并标准化凭证ID @@ -393,14 +395,27 @@ def passkey_authenticate_finish(passkey_req: PassKeyAuthenticationFinish) -> Any and not settings.ADVANCED_MODE ) - return schemas.Token( - access_token=security.create_access_token( - userid=user.id, + access_token = security.create_access_token( + userid=user.id, + username=user.name, + super_user=user.is_superuser, + expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES), + level=level, + ) + security.set_or_refresh_resource_token_cookie( + request, + response, + schemas.TokenPayload( + sub=user.id, username=user.name, super_user=user.is_superuser, - expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES), level=level, + purpose="authentication", ), + ) + + return schemas.Token( + access_token=access_token, token_type="bearer", super_user=user.is_superuser, user_id=user.id, diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index e1ef2b51..8d202b1f 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -4,7 +4,7 @@ from typing import Annotated, Any, List, Optional import aiofiles from anyio import Path as AsyncPath -from fastapi import APIRouter, Depends, Header, HTTPException +from fastapi import APIRouter, Depends, Header, HTTPException, Security from fastapi.concurrency import run_in_threadpool from starlette import status from starlette.responses import StreamingResponse @@ -13,7 +13,12 @@ from app import schemas from app.command import Command from app.core.config import settings from app.core.plugin import PluginManager -from app.core.security import verify_apikey, verify_token +from app.core.security import ( + resource_token_cookie, + verify_apikey, + verify_resource_token, + verify_token, +) from app.db.models import User from app.db.systemconfig_oper import SystemConfigOper from app.db.user_oper import ( @@ -163,6 +168,48 @@ def _merge_plugin_market_metadata( return plugin +def _is_plugin_auth_remote_file(plugin_id: str, filepath: str) -> bool: + """ + 判断静态文件是否属于插件声明的匿名登录认证远程组件。 + + 登录页加载插件认证组件时尚未产生登录态和资源 Cookie,因此仅对插件主动 + 声明的认证 remote 保留匿名读取能力,其余插件静态资源仍需资源令牌。 + """ + path = filepath.lstrip("/") + normalized_plugin_id = plugin_id.lower() + plugin_manager = PluginManager() + for provider in plugin_manager.get_plugin_auth_providers(): + remote = provider.get("remote") or {} + if str(remote.get("id") or "").lower() != normalized_plugin_id: + continue + remote_path = str(remote.get("url") or "").lstrip("/") + remote_path_lower = remote_path.lower() + expected_prefix = f"plugin/file/{normalized_plugin_id}/" + if not remote_path_lower.startswith(expected_prefix): + continue + remote_file = remote_path[len(expected_prefix):] + remote_dir = remote_file.rsplit("/", 1)[0] if "/" in remote_file else "" + if path == remote_file or (remote_dir and path.startswith(f"{remote_dir}/")): + return True + return False + + +def _verify_plugin_static_file_access( + plugin_id: str, + filepath: str, + resource_token: Annotated[Optional[str], Security(resource_token_cookie)] = None, +) -> None: + """ + 校验插件静态文件访问权限。 + + 普通插件资源依赖登录后写入的资源 Cookie;登录认证插件的远程组件需要在 + 登录前加载,因此仅对插件声明的认证 remote 放行匿名读取。 + """ + if _is_plugin_auth_remote_file(plugin_id, filepath): + return + verify_resource_token(resource_token) + + async def _get_plugin_history_detail( plugin_id: str, force: bool = True ) -> Optional[schemas.Plugin]: @@ -440,7 +487,7 @@ def plugin_page( @router.get("/dashboard/meta", summary="获取所有插件仪表板元信息") def plugin_dashboard_meta( - _: schemas.TokenPayload = Depends(verify_token), + _: User = Depends(get_current_active_superuser), ) -> List[dict]: """ 获取所有插件仪表板元信息 @@ -453,7 +500,7 @@ def plugin_dashboard_by_key( plugin_id: str, key: str, user_agent: Annotated[str | None, Header()] = None, - _: schemas.TokenPayload = Depends(verify_token), + _: User = Depends(get_current_active_superuser), ) -> Optional[schemas.PluginDashboard]: """ 根据插件ID获取插件仪表板 @@ -465,7 +512,7 @@ def plugin_dashboard_by_key( def plugin_dashboard( plugin_id: str, user_agent: Annotated[str | None, Header()] = None, - _: schemas.TokenPayload = Depends(verify_token), + _: User = Depends(get_current_active_superuser), ) -> schemas.PluginDashboard: """ 根据插件ID获取插件仪表板 @@ -493,7 +540,11 @@ def reset_plugin( @router.get("/file/{plugin_id}/{filepath:path}", summary="获取插件静态文件") -async def plugin_static_file(plugin_id: str, filepath: str): +async def plugin_static_file( + plugin_id: str, + filepath: str, + _: None = Depends(_verify_plugin_static_file_access), +) -> StreamingResponse: """ 获取插件静态文件 """ diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index e038a734..a9ec3314 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -49,6 +49,19 @@ from version import APP_VERSION router = APIRouter() _NETTEST_REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308} +_PUBLIC_SYSTEM_CONFIG_KEYS = { + item.value: item + for item in ( + SystemConfigKey.Directories, + SystemConfigKey.Storages, + SystemConfigKey.IndexerSites, + SystemConfigKey.EpisodeFormatRuleTable, + SystemConfigKey.DefaultMovieSubscribeConfig, + SystemConfigKey.DefaultTvSubscribeConfig, + SystemConfigKey.FollowSubscribers, + ) +} +_PUBLIC_SETTINGS_KEYS = {"PLUGIN_MARKET"} def _match_nettest_prefix(url: str, prefix: str) -> bool: @@ -501,7 +514,9 @@ async def get_user_global_setting(_: User = Depends(get_current_active_user_asyn @router.get("/env", summary="查询系统配置", response_model=schemas.Response) -async def get_env_setting(_: User = Depends(get_current_active_user_async)): +async def get_env_setting( + _: User = Depends(get_current_active_superuser_async), +) -> schemas.Response: """ 查询系统环境变量,包括当前版本号(仅管理员) """ @@ -527,6 +542,14 @@ async def usage_statistic(_: User = Depends(get_current_active_user_async)): return schemas.Response(success=True, data=await MoviePilotServerHelper.async_get_usage_statistic()) +@router.get("/ping", summary="服务存活检测", response_model=schemas.Response) +async def ping(_: User = Depends(get_current_active_user_async)) -> schemas.Response: + """ + 检测服务是否可用 + """ + return schemas.Response(success=True) + + @router.post("/env", summary="更新系统配置", response_model=schemas.Response) async def set_env_setting( env: dict, _: User = Depends(get_current_active_superuser_async) @@ -587,8 +610,25 @@ async def get_progress( return StreamingResponse(event_generator(), media_type="text/event-stream") +@router.get("/setting/public/{key}", summary="查询公开系统设置", response_model=schemas.Response) +async def get_public_setting( + key: str, _: User = Depends(get_current_active_user_async) +) -> schemas.Response: + """ + 查询普通用户可读取的非敏感系统设置 + """ + if key in _PUBLIC_SETTINGS_KEYS: + return schemas.Response(success=True, data={"value": getattr(settings, key)}) + if key not in _PUBLIC_SYSTEM_CONFIG_KEYS: + raise HTTPException(status_code=404, detail="配置项不存在") + value = SystemConfigOper().get(_PUBLIC_SYSTEM_CONFIG_KEYS[key]) + return schemas.Response(success=True, data={"value": value}) + + @router.get("/setting/{key}", summary="查询系统设置", response_model=schemas.Response) -async def get_setting(key: str, _: User = Depends(get_current_active_user_async)): +async def get_setting( + key: str, _: User = Depends(get_current_active_superuser_async) +) -> schemas.Response: """ 查询系统设置(仅管理员) """ diff --git a/app/api/endpoints/user.py b/app/api/endpoints/user.py index d4d96062..2fde892a 100644 --- a/app/api/endpoints/user.py +++ b/app/api/endpoints/user.py @@ -104,11 +104,14 @@ async def upload_avatar( user_id: int, db: AsyncSession = Depends(get_async_db), file: UploadFile = File(...), - _: User = Depends(get_current_active_user_async), -): + current_user: User = Depends(get_current_active_user_async), +) -> schemas.Response: """ 上传用户头像 """ + if current_user.id != user_id and not current_user.is_superuser: + raise HTTPException(status_code=400, detail="用户权限不足") + # 将文件转换为Base64 file_base64 = base64.b64encode(file.file.read()) # 更新到用户表 diff --git a/app/core/security.py b/app/core/security.py index ca64f094..adc9a328 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -148,7 +148,9 @@ def create_access_token( return encoded_jwt -def __set_or_refresh_resource_token_cookie(request: Request, response: Response, payload: schemas.TokenPayload): +def set_or_refresh_resource_token_cookie( + request: Request, response: Response, payload: schemas.TokenPayload +) -> None: """ 设置资源令牌 Cookie :param request: 包含请求相关的上下文数据 @@ -258,7 +260,7 @@ def verify_token( payload = __verify_token(token=jwt_token, purpose="authentication") # 如果没有 resource_token,生成并写入到 Cookie - __set_or_refresh_resource_token_cookie(request, response, payload) + set_or_refresh_resource_token_cookie(request, response, payload) return payload elif api_key: diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 126353ac..57e36ccf 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -108,6 +108,13 @@ MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所 | GET | `/api/v1/download/paths` | 查询可用于下载接口 `save_path` 参数的下载路径 | | DELETE | `/api/v1/download/{hashString}` | 删除下载任务,参数:`name` | +#### 系统 + +| 方法 | 路径 | 说明 | +| :--- | :--- | :--- | +| GET | `/api/v1/system/ping` | 登录用户服务存活检测,用于前端重启后轮询恢复状态 | +| GET | `/api/v1/system/setting/public/{key}` | 登录用户读取白名单内非敏感系统设置,仅支持目录、存储、站点范围、默认订阅规则、Follow 订阅者和插件市场地址等前端必需配置 | + ### 插件补充接口 **GET** `/api/v1/plugin/history/{plugin_id}` diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 7da30dbc..371f18e5 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -320,12 +320,14 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as ` | POST | `/api/v1/workflow/fork` | Fork shared workflow. Body: WorkflowShare JSON | | GET | `/api/v1/workflow/shares` | List shared workflows. Params: `name`, `page`, `count` | -### System (21 endpoints) +### System (23 endpoints) | Method | Path | Description | |--------|------|-------------| | GET | `/api/v1/system/env` | Get system configuration, including runtime versions and Rust acceleration availability/enabled status | | POST | `/api/v1/system/env` | Update system configuration. Body: JSON object | +| GET | `/api/v1/system/ping` | Check service availability for authenticated users | +| GET | `/api/v1/system/setting/public/{key}` | Get allowlisted non-sensitive system setting for authenticated users | | GET | `/api/v1/system/setting/{key}` | Get system setting | | POST | `/api/v1/system/setting/{key}` | Update system setting | | GET | `/api/v1/system/global` | Non-sensitive settings. Params: `token` (required) | diff --git a/tests/test_api_authorization.py b/tests/test_api_authorization.py new file mode 100644 index 00000000..44f65434 --- /dev/null +++ b/tests/test_api_authorization.py @@ -0,0 +1,242 @@ +import asyncio +import io +import inspect +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException +from starlette.requests import Request +from starlette.responses import Response + +from app.api.endpoints import login as login_endpoint +from app.api.endpoints import plugin as plugin_endpoint +from app.api.endpoints import system as system_endpoint +from app.api.endpoints import user as user_endpoint +from app.api.endpoints import dashboard as dashboard_endpoint +from app.core.security import verify_resource_token +from app.db.user_oper import ( + get_current_active_superuser, + get_current_active_superuser_async, + get_current_active_user_async, +) +from app.schemas.types import SystemConfigKey + + +def _dependency_of(func, parameter_name: str): + """读取 FastAPI 函数参数上声明的依赖函数。""" + return inspect.signature(func).parameters[parameter_name].default.dependency + + +def _build_request() -> Request: + """构造最小测试请求。""" + return Request( + { + "type": "http", + "method": "POST", + "path": "/api/v1/login/access-token", + "headers": [(b"host", b"testserver")], + "scheme": "http", + "server": ("testserver", 80), + "client": ("testclient", 123), + } + ) + + +def test_system_sensitive_read_endpoints_require_superuser(): + """系统敏感读取接口必须只允许管理员访问。""" + assert _dependency_of(system_endpoint.get_env_setting, "_") is get_current_active_superuser_async + assert _dependency_of(system_endpoint.get_setting, "_") is get_current_active_superuser_async + + +def test_system_public_read_endpoints_require_active_user(): + """公开读取接口只要求登录且启用的用户。""" + assert _dependency_of(system_endpoint.ping, "_") is get_current_active_user_async + assert _dependency_of(system_endpoint.get_public_setting, "_") is get_current_active_user_async + + +def test_dashboard_endpoints_require_superuser(): + """仪表板页面相关接口必须只允许管理员访问。""" + assert _dependency_of(dashboard_endpoint.statistic, "_") is get_current_active_superuser + assert _dependency_of(dashboard_endpoint.storage, "_") is get_current_active_superuser + assert _dependency_of(dashboard_endpoint.processes, "_") is get_current_active_superuser + assert _dependency_of(dashboard_endpoint.downloader, "_") is get_current_active_superuser + assert _dependency_of(dashboard_endpoint.schedule, "_") is get_current_active_superuser + assert _dependency_of(dashboard_endpoint.transfer, "_") is get_current_active_superuser + assert _dependency_of(dashboard_endpoint.cpu, "_") is get_current_active_superuser + assert _dependency_of(dashboard_endpoint.memory, "_") is get_current_active_superuser + assert _dependency_of(dashboard_endpoint.network, "_") is get_current_active_superuser + + +def test_plugin_dashboard_endpoints_require_superuser(): + """插件仪表板接口必须只允许管理员访问。""" + assert _dependency_of(plugin_endpoint.plugin_dashboard_meta, "_") is get_current_active_superuser + assert _dependency_of(plugin_endpoint.plugin_dashboard_by_key, "_") is get_current_active_superuser + assert _dependency_of(plugin_endpoint.plugin_dashboard, "_") is get_current_active_superuser + + +def test_system_public_setting_allows_only_non_sensitive_keys(monkeypatch): + """公开系统设置接口只能读取明确列入白名单的非敏感配置。""" + calls = [] + + class FakeSystemConfigOper: + """返回测试配置值的系统配置桩。""" + + def get(self, key): + """返回测试配置值。""" + calls.append(key) + return [{"path": "/downloads"}] + + monkeypatch.setattr(system_endpoint, "SystemConfigOper", FakeSystemConfigOper) + + response = asyncio.run( + system_endpoint.get_public_setting(SystemConfigKey.Directories.value) + ) + + assert response.success is True + assert response.data == {"value": [{"path": "/downloads"}]} + assert calls == [SystemConfigKey.Directories] + + response = asyncio.run(system_endpoint.get_public_setting("PLUGIN_MARKET")) + + assert response.success is True + assert response.data == {"value": system_endpoint.settings.PLUGIN_MARKET} + assert calls == [SystemConfigKey.Directories] + + with pytest.raises(HTTPException) as exc_info: + asyncio.run(system_endpoint.get_public_setting("API_TOKEN")) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "配置项不存在" + + +def test_system_ping_returns_success(): + """服务存活检测接口返回标准成功响应。""" + response = asyncio.run(system_endpoint.ping()) + + assert response.success is True + + +def test_login_sets_resource_token_cookie(monkeypatch): + """登录成功时应立即写入资源 Cookie,避免插件静态文件抢先加载失败。""" + + class FakeUserChain: + """返回登录成功用户的用户链桩。""" + + def user_authenticate(self, username, password, mfa_code=None): + """返回认证成功结果。""" + return True, SimpleNamespace( + id=1, + name=username, + is_superuser=False, + avatar="", + permissions={"discovery": True}, + ) + + class FakeSystemConfigOper: + """返回已完成向导状态的系统配置桩。""" + + def get(self, key): + """返回测试配置值。""" + return "1" + + form_data = SimpleNamespace(username="user", password="password") + request = _build_request() + response = Response() + + monkeypatch.setattr(login_endpoint, "UserChain", FakeUserChain) + monkeypatch.setattr(login_endpoint, "SystemConfigOper", FakeSystemConfigOper) + + token = login_endpoint.login_access_token( + request=request, + response=response, + form_data=form_data, + ) + + assert token.user_id == 1 + assert token.permissions == {"discovery": True} + assert "set-cookie" in response.headers + + resource_cookie = response.headers["set-cookie"].split("=", 1)[1].split(";", 1)[0] + payload = verify_resource_token(resource_cookie) + assert payload.sub == 1 + assert payload.username == "user" + assert payload.purpose == "resource" + + +def test_plugin_static_file_requires_resource_token_by_default(monkeypatch): + """普通插件静态资源必须校验资源令牌。""" + calls = [] + + class FakePluginManager: + """返回空认证提供方的插件管理器桩。""" + + def get_plugin_auth_providers(self): + """返回插件认证入口列表。""" + return [] + + monkeypatch.setattr(plugin_endpoint, "PluginManager", FakePluginManager) + monkeypatch.setattr(plugin_endpoint, "verify_resource_token", lambda token: calls.append(token)) + + plugin_endpoint._verify_plugin_static_file_access( + plugin_id="DemoPlugin", + filepath="dist/remoteEntry.js", + resource_token="resource-token", + ) + + assert calls == ["resource-token"] + + +def test_plugin_auth_remote_files_allow_anonymous_bootstrap(monkeypatch): + """插件登录认证远程组件需要允许登录前匿名加载。""" + calls = [] + + class FakePluginManager: + """返回认证插件 remote 信息的插件管理器桩。""" + + def get_plugin_auth_providers(self): + """返回插件认证入口列表。""" + return [ + { + "remote": { + "id": "AuthPlugin", + "url": "/plugin/file/AuthPlugin/dist/remoteEntry.js", + } + } + ] + + monkeypatch.setattr(plugin_endpoint, "PluginManager", FakePluginManager) + monkeypatch.setattr(plugin_endpoint, "verify_resource_token", lambda token: calls.append(token)) + + plugin_endpoint._verify_plugin_static_file_access( + plugin_id="AuthPlugin", + filepath="dist/remoteEntry.js", + ) + plugin_endpoint._verify_plugin_static_file_access( + plugin_id="AuthPlugin", + filepath="dist/assets/chunk.js", + ) + plugin_endpoint._verify_plugin_static_file_access( + plugin_id="authplugin", + filepath="dist/assets/chunk.js", + ) + + assert calls == [] + + +def test_upload_avatar_rejects_other_user_for_non_superuser(): + """普通用户不能通过 user_id 参数修改其他用户头像。""" + current_user = SimpleNamespace(id=1, is_superuser=False) + upload_file = SimpleNamespace(file=io.BytesIO(b"avatar"), filename="avatar.png") + + with pytest.raises(HTTPException) as exc_info: + asyncio.run( + user_endpoint.upload_avatar( + user_id=2, + db=object(), + file=upload_file, + current_user=current_user, + ) + ) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "用户权限不足"