fix(api): enforce token authentication for subscription list

Refs #7198
This commit is contained in:
jxxghp
2026-10-26 23:19:31 +08:00
parent f4fbc6cad8
commit 1bf496d0f3
7 changed files with 150 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"
},
{

View File

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

View 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", [])}