mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-28 19:06:36 +08:00
fix(api): enforce token authentication for subscription list
Refs #7198
This commit is contained in:
@@ -230,15 +230,20 @@ async def read_subscribes(
|
||||
openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True},
|
||||
)
|
||||
async def list_subscribes(
|
||||
_: Annotated[str, Depends(verify_apitoken)],
|
||||
response: Response = None, # type: ignore[assignment]
|
||||
query: SubscriptionQueryService = Depends(get_subscription_query_service),
|
||||
status_service: SubscriptionExecutionStatusService = Depends(get_subscription_execution_status_service),
|
||||
_: Optional[Annotated[str, Depends(verify_apitoken)]] = None,
|
||||
page: CompatiblePageParam = None,
|
||||
count: CompatibleCountParam = None,
|
||||
) -> Any:
|
||||
"""
|
||||
查询所有订阅 API_TOKEN认证(?token=xxx)
|
||||
通过 API_TOKEN 及当前管理员身份校验后查询全部用户的订阅。
|
||||
|
||||
认证依赖必须放在最外层 Annotated 中,避免被可空联合类型隐藏。
|
||||
|
||||
:param _: 由 ?token= 或 X-API-KEY 传入并已通过校验的凭据
|
||||
:return: 按可选分页返回的订阅列表
|
||||
"""
|
||||
page, count = resolve_compatible_pagination(page, count)
|
||||
if response is not None:
|
||||
|
||||
@@ -102,6 +102,11 @@ JSON 使用十进制字符串避免浏览器数字精度丢失,客户端应原
|
||||
- 这是**管理员级集成入口**,可以访问站点、配置、数据库及有副作用的操作。只向可信客户端提供密钥,并使用 HTTPS 或受信网络。
|
||||
- 写操作的用户授权由调用方负责取得;不要假设 MCP 会弹出 MoviePilot 内置 Agent 的确认界面。隐藏工具也不构成权限隔离。
|
||||
|
||||
REST 兼容集成接口 `GET /api/v1/subscribe/list` 使用 `X-API-KEY: <API_TOKEN>`,
|
||||
也接受 `?token=<API_TOKEN>`;缺失、错误凭据或绑定的管理员身份失效时返回 HTTP `401`。
|
||||
认证成功后可读取全部用户的订阅及总量。Agent 的 `subscription.list` 使用当前用户身份访问
|
||||
`GET /api/v1/subscribe/`,普通用户只能读取自己的订阅。
|
||||
|
||||
下文的 HTTP 示例使用两个占位环境变量,由接入方在自己的环境中设置。不要把真实密钥写进文档、模型提示词或代码仓库。
|
||||
|
||||
```bash
|
||||
|
||||
@@ -17,7 +17,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
MOVIEPILOT_API_SKILL_VERSION = "65"
|
||||
MOVIEPILOT_API_SKILL_VERSION = "66"
|
||||
DATABASE_OPERATION_SKILL_VERSION = "16"
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: moviepilot-api
|
||||
version: 65
|
||||
version: 66
|
||||
description: >-
|
||||
Use this skill for MoviePilot product operations such as media search, torrent
|
||||
search, downloads, subscriptions, library checks, sites, storage, workflows,
|
||||
@@ -103,6 +103,11 @@ users may update or delete only their own subscriptions. An unbound or inactive
|
||||
channel user must bind an active account before retrying, not switch to an
|
||||
administrator identity.
|
||||
|
||||
`subscription.list` uses the current user identity and subscription ownership filtering.
|
||||
The separate global subscription integration endpoint requires a valid API_TOKEN
|
||||
and administrator identity. It is outside this skill's allowed operations and must
|
||||
never be used to bypass user scope.
|
||||
|
||||
For QQ, either `qq_userid` or `qq_openid` may match the current channel ID; for
|
||||
Feishu, either `feishu_userid` or `feishu_openid` may match. A single matching
|
||||
field is sufficient, but the match must identify exactly one active MoviePilot
|
||||
|
||||
4
tests/fixtures/architecture/mypy-policy.json
vendored
4
tests/fixtures/architecture/mypy-policy.json
vendored
@@ -624,8 +624,8 @@
|
||||
"codes": [
|
||||
"assignment"
|
||||
],
|
||||
"fingerprint": "b36833696f77492798f44e25bc64c613b3a5b6e232a3df2d2337475afa2c30fa",
|
||||
"statement": "@router.get('/list', summary='查询所有订阅(API_TOKEN)', response_model=List[_SchemaSubscribe], openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True})\nasync def list_subscribes(response: Response=None, query: SubscriptionQueryService=Depends(get_subscription_query_service), status_service: SubscriptionExecutionStatusService=Depends(get_subscription_execution_status_service), _: Optional[Annotated[str, Depends(verify_apitoken)]]=None, page: CompatiblePageParam=None, count: CompatibleCountParam=None) -> Any:",
|
||||
"fingerprint": "9013a36a2221a1c539535d5cfb8a7ee7cde3fe3f6e0a15fb2e20f0a58a2db052",
|
||||
"statement": "@router.get('/list', summary='查询所有订阅(API_TOKEN)', response_model=List[_SchemaSubscribe], openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True})\nasync def list_subscribes(_: Annotated[str, Depends(verify_apitoken)], response: Response=None, query: SubscriptionQueryService=Depends(get_subscription_query_service), status_service: SubscriptionExecutionStatusService=Depends(get_subscription_execution_status_service), page: CompatiblePageParam=None, count: CompatibleCountParam=None) -> Any:",
|
||||
"reason": "fastapi-response-default"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -130,7 +130,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
|
||||
"database-operation": "16",
|
||||
"feedback-issue": "12",
|
||||
"anysearch": "3",
|
||||
"moviepilot-api": "65",
|
||||
"moviepilot-api": "66",
|
||||
"moviepilot-update": "7",
|
||||
"organize-files": "6",
|
||||
"transfer-failed-retry": "6",
|
||||
|
||||
128
tests/test_subscribe_api_auth.py
Normal file
128
tests/test_subscribe_api_auth.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
验证订阅全量查询路由真实执行 API_TOKEN 认证后才读取数据。
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.adapters.web.security import access
|
||||
from app.api.endpoints import subscribe as subscribe_endpoint
|
||||
from app.schemas.subscribe import Subscribe
|
||||
from app.schemas.token import TokenPayload
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def subscribe_api(monkeypatch) -> Iterator[SimpleNamespace]:
|
||||
"""
|
||||
保留真实认证依赖,仅替换配置、管理员身份端口和订阅查询边界。
|
||||
|
||||
:param monkeypatch: 用例结束后恢复配置读取及身份端口的 pytest 夹具
|
||||
:yields: 独立 HTTP 客户端及用于验证读取和身份校验的替身
|
||||
"""
|
||||
monkeypatch.setattr(access, "get_runtime_setting", lambda _key: "test-subscription-api-token")
|
||||
identity = Mock(return_value=TokenPayload(sub=7, username="admin", super_user=True))
|
||||
monkeypatch.setattr(access, "_superuser_token_payload_provider", identity)
|
||||
query = SimpleNamespace(
|
||||
count_public=AsyncMock(return_value=2),
|
||||
list_public=AsyncMock(return_value=[
|
||||
Subscribe(id=1, name="订阅一", username="alice"),
|
||||
Subscribe(id=2, name="订阅二", username="bob"),
|
||||
]),
|
||||
)
|
||||
status = SimpleNamespace(for_subscriptions=AsyncMock(return_value={}))
|
||||
app = FastAPI()
|
||||
app.include_router(subscribe_endpoint.router, prefix="/api/v1/subscribe")
|
||||
app.dependency_overrides[subscribe_endpoint.get_subscription_query_service] = lambda: query
|
||||
app.dependency_overrides[subscribe_endpoint.get_subscription_execution_status_service] = lambda: status
|
||||
|
||||
with TestClient(app) as client:
|
||||
yield SimpleNamespace(client=client, query=query, status=status, identity=identity)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("params", "headers"),
|
||||
[
|
||||
({}, {}),
|
||||
({"token": ""}, {}),
|
||||
({"token": "wrong-token"}, {}),
|
||||
({}, {"X-API-KEY": "wrong-token"}),
|
||||
({"_": "test-subscription-api-token"}, {}),
|
||||
({"token": "test-subscription-api-token"}, {"X-API-KEY": "wrong-token"}),
|
||||
],
|
||||
)
|
||||
def test_subscribe_list_rejects_invalid_credentials_before_reading(subscribe_api, params, headers) -> None:
|
||||
"""
|
||||
缺失或错误凭据必须返回 401,不能查询订阅总量、数据或执行状态。
|
||||
"""
|
||||
response = subscribe_api.client.get("/api/v1/subscribe/list", params=params, headers=headers)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.headers["www-authenticate"] == "Bearer"
|
||||
assert response.json() == {"detail": "token 校验不通过"}
|
||||
subscribe_api.query.count_public.assert_not_awaited()
|
||||
subscribe_api.query.list_public.assert_not_awaited()
|
||||
subscribe_api.status.for_subscriptions.assert_not_awaited()
|
||||
subscribe_api.identity.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("params", "headers"),
|
||||
[
|
||||
({"token": "test-subscription-api-token"}, {}),
|
||||
({}, {"X-API-KEY": "test-subscription-api-token"}),
|
||||
({"token": "wrong-token"}, {"X-API-KEY": "test-subscription-api-token"}),
|
||||
],
|
||||
)
|
||||
def test_subscribe_list_accepts_valid_credentials_and_preserves_global_query(subscribe_api, params, headers) -> None:
|
||||
"""
|
||||
正确凭据通过当前管理员校验后,仍按既有分页合同读取全部用户订阅。
|
||||
"""
|
||||
response = subscribe_api.client.get(
|
||||
"/api/v1/subscribe/list",
|
||||
params={**params, "page": 1, "count": 2},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["x-total-count"] == "2"
|
||||
assert [(item["id"], item["username"]) for item in response.json()["data"]] == [(1, "alice"), (2, "bob")]
|
||||
subscribe_api.query.count_public.assert_awaited_once_with()
|
||||
subscribe_api.query.list_public.assert_awaited_once_with(page=1, count=2)
|
||||
subscribe_api.status.for_subscriptions.assert_awaited_once_with((1, 2))
|
||||
subscribe_api.identity.assert_called_once_with()
|
||||
|
||||
|
||||
def test_subscribe_list_rejects_invalid_admin_identity_before_reading(subscribe_api) -> None:
|
||||
"""
|
||||
正确 API_TOKEN 也不能绕过所绑定管理员的当前身份校验。
|
||||
"""
|
||||
subscribe_api.identity.side_effect = PermissionError("用户不存在或已禁用")
|
||||
|
||||
response = subscribe_api.client.get(
|
||||
"/api/v1/subscribe/list", params={"token": "test-subscription-api-token"}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.headers["www-authenticate"] == "Bearer"
|
||||
subscribe_api.identity.assert_called_once_with()
|
||||
subscribe_api.query.count_public.assert_not_awaited()
|
||||
subscribe_api.query.list_public.assert_not_awaited()
|
||||
subscribe_api.status.for_subscriptions.assert_not_awaited()
|
||||
|
||||
|
||||
def test_subscribe_list_openapi_declares_authentication(subscribe_api) -> None:
|
||||
"""
|
||||
OpenAPI 应声明 API 凭据,不能把认证占位参数暴露为普通查询参数。
|
||||
"""
|
||||
response = subscribe_api.client.get("/openapi.json")
|
||||
|
||||
assert response.status_code == 200
|
||||
operation = response.json()["paths"]["/api/v1/subscribe/list"]["get"]
|
||||
assert {"api_token_query": []} in operation["security"]
|
||||
assert {"api_key_header": []} in operation["security"]
|
||||
assert "_" not in {parameter["name"] for parameter in operation.get("parameters", [])}
|
||||
Reference in New Issue
Block a user