mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-03 05:44:44 +08:00
feat(widget): 增加当前主登录的只读账号上下文 (#7176)
This commit is contained in:
@@ -20,11 +20,33 @@ from app.application.security.widget import WidgetAccessError, WidgetAuthService
|
||||
from app.application.widget import WidgetSnapshotService
|
||||
from app.runtime.execution import run_in_threadpool
|
||||
from app.schemas.response import Response as ApiResponse
|
||||
from app.schemas.widget import WidgetRegistrationRequest, WidgetRegistrationResponse, WidgetSnapshot
|
||||
from app.schemas.widget import WidgetContext, WidgetRegistrationRequest, WidgetRegistrationResponse, WidgetSnapshot
|
||||
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
|
||||
@router.get("/context", summary="读取当前登录的小组件账户上下文", response_model=ApiResponse[WidgetContext])
|
||||
def widget_context(
|
||||
response: Response,
|
||||
current_user: ApiPrincipal = Depends(get_current_active_user),
|
||||
service: WidgetAuthService = Depends(get_widget_auth_service),
|
||||
) -> ApiResponse[WidgetContext]:
|
||||
"""
|
||||
通过既有主登录核对不可变身份与当前权限,不登记安装或返回凭据清单。
|
||||
|
||||
:param response: 禁止缓存当前账号的上下文
|
||||
:param current_user: 原认证确定的激活账号
|
||||
:param service: 当前宿主的只读授权上下文服务
|
||||
:return: 当前身份及可用内容,不含主登录或小组件密钥
|
||||
"""
|
||||
response.headers.update(WIDGET_CACHE_HEADERS)
|
||||
try:
|
||||
result = service.context(current_user.id, current_user.notification_identity)
|
||||
except WidgetAccessError as error:
|
||||
widget_http_error(error)
|
||||
return ApiResponse(success=True, data=result)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/registrations", summary="启用或轮换本机小组件授权", response_model=ApiResponse[WidgetRegistrationResponse]
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ from typing import Protocol, TypeAlias
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.application.security.user import UserSnapshot
|
||||
from app.schemas.widget import WidgetContent, WidgetRegistrationResponse
|
||||
from app.schemas.widget import WidgetContent, WidgetContext, WidgetRegistrationResponse
|
||||
|
||||
# 前台主 App 通过已有登录更新;扩展不能无限自续。
|
||||
WIDGET_CREDENTIAL_LIFETIME = 30 * 24 * 60 * 60
|
||||
@@ -179,6 +179,19 @@ class WidgetAuthService:
|
||||
raise WidgetAccessError(401, "小组件授权已失效,请在 App 中重新启用")
|
||||
return user
|
||||
|
||||
def context(self, user_id: int, owner: str) -> WidgetContext:
|
||||
"""
|
||||
重新读取主登录账号的不可变身份和权限,不创建或轮换小组件授权。
|
||||
|
||||
:param user_id: 既有主认证给出的用户主键
|
||||
:param owner: 既有主认证给出的不可变身份,不接受请求参数指定
|
||||
:return: 当前身份和可用内容,无可用权限时返回空集合
|
||||
:raises WidgetAccessError: 账号禁用、删除或身份已变化
|
||||
"""
|
||||
with self._scope() as transaction:
|
||||
user = self._require_user(transaction.get_user(user_id), owner)
|
||||
return WidgetContext(owner=user.notification_identity, availableContents=available_widget_contents(user))
|
||||
|
||||
def register(self, user_id: int, owner: str, installation_id: UUID) -> WidgetRegistrationResponse:
|
||||
"""
|
||||
原子轮换本安装凭据,只有成功提交后的新密钥可以返回客户端。
|
||||
|
||||
@@ -13,6 +13,16 @@ WidgetContent = Literal[
|
||||
WidgetIcon = Literal["movie", "television", "episodes", "music", "download", "upload", "storage", "items", "clock"]
|
||||
|
||||
|
||||
class WidgetContext(BaseModel):
|
||||
"""
|
||||
当前主登录的小组件身份与权限;读取不会创建、轮换或返回任何凭据。
|
||||
"""
|
||||
|
||||
version: Literal[1] = Field(default=1, description="小组件账户上下文协议版本")
|
||||
owner: str = Field(pattern=r"^[0-9a-f]{32}$", description="当前激活用户的不可变身份,用于核对已启用资料")
|
||||
availableContents: list[WidgetContent] = Field(max_length=6, description="按最新权限可选择的内容,无权限时为空")
|
||||
|
||||
|
||||
class WidgetRegistrationRequest(BaseModel):
|
||||
"""
|
||||
当前登录用户明确启用某个本机安装;不接受 owner、权限或来源地址。
|
||||
|
||||
@@ -28,8 +28,9 @@
|
||||
|
||||
### App 小组件只读接口
|
||||
|
||||
小组件使用独立的有限期只读 Bearer,通过 `/api/v1/widget/registrations` 登记和
|
||||
`/api/v1/widget/snapshot` 刷新。该凭据不适用于普通 REST/MCP,也不加入 Agent 工具目录;
|
||||
主 App 使用既有登录通过 `/api/v1/widget/context` 核对账号、`/api/v1/widget/registrations` 登记。
|
||||
扩展使用独立的有限期只读 Bearer 通过 `/api/v1/widget/snapshot` 刷新。
|
||||
该凭据不适用于普通 REST/MCP,小组件接口也不加入 Agent 工具目录;
|
||||
详见[授权、权限与快照协议](widgets.md)。主登录凭据不得交给系统扩展。
|
||||
|
||||
### 通知配置与交互身份
|
||||
|
||||
@@ -5,6 +5,11 @@ MoviePilot App 的系统小组件直接读取用户连接的 MoviePilot 实例
|
||||
|
||||
## 启用与移除
|
||||
|
||||
前台 App 先使用既有主登录读取 `GET /api/v1/widget/context`。标准响应的 `data` 只包含
|
||||
`version=1`、不可变 `owner` 和当前 `availableContents`,不会创建、轮换或列出任何登记。
|
||||
没有可用内容时返回空集合。App 用不可变身份核对组件资料与当前网页账号,不能仅比较可复用的
|
||||
数字用户 ID 或显示名;组件专用凭据不能调用该接口。此响应禁止缓存,不能用旧上下文替代当前校验。
|
||||
|
||||
前台 App 在用户明确启用后,使用既有登录调用
|
||||
`POST /api/v1/widget/registrations`,请求体只包含随机 UUID `installationId`。
|
||||
标准响应的 `data` 包含 `version=1`、`registrationId`、`installationId`、不可变 `owner`、
|
||||
|
||||
@@ -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 = "64"
|
||||
MOVIEPILOT_API_SKILL_VERSION = "65"
|
||||
DATABASE_OPERATION_SKILL_VERSION = "15"
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: moviepilot-api
|
||||
version: 64
|
||||
version: 65
|
||||
description: >-
|
||||
Use this skill for MoviePilot product operations such as media search, torrent
|
||||
search, downloads, subscriptions, library checks, sites, storage, workflows,
|
||||
@@ -227,9 +227,11 @@ frontmatter allowlist, and the matching gateway contract together.
|
||||
## API Surface Scope
|
||||
|
||||
System widget endpoints under `/api/v1/widget/*` are outside this skill and the Agent tool catalog.
|
||||
They use a separate, expiring, read-only Bearer; never use it as an ordinary API/MCP login credential,
|
||||
or enable, rotate, or export widget grants on a user's behalf. See `docs/widgets.md` for the protocol
|
||||
and user scope. System extensions must not hold the main login token.
|
||||
Context, registration, and owner revocation use the existing main login. Snapshot reads and self
|
||||
revocation use a separate, expiring, read-only Bearer; never use it as an ordinary API/MCP login
|
||||
credential, or enable, rotate, or export widget grants on a user's behalf. Reading context must not
|
||||
issue or rotate credentials. See `docs/widgets.md` for the protocol and user scope. System extensions
|
||||
must not hold the main login token.
|
||||
|
||||
For installed plugins with a bound source, `plugin.market` only lists an update
|
||||
when that repository has a newer version. A higher version in another repository
|
||||
|
||||
@@ -130,7 +130,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
|
||||
"database-operation": "15",
|
||||
"feedback-issue": "12",
|
||||
"anysearch": "3",
|
||||
"moviepilot-api": "64",
|
||||
"moviepilot-api": "65",
|
||||
"moviepilot-update": "7",
|
||||
"organize-files": "6",
|
||||
"transfer-failed-retry": "6",
|
||||
|
||||
@@ -23,7 +23,7 @@ from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.adapters.web.security.access import verify_token
|
||||
from app.api.dependencies import widget as dependencies
|
||||
from app.api.dependencies.auth import get_current_active_user
|
||||
from app.api.dependencies.auth import get_current_active_user, get_current_user
|
||||
from app.api.dependencies.widget import get_widget_auth_service, get_widget_snapshot_service
|
||||
from app.api.endpoints import widget as endpoint
|
||||
from app.application.dashboard import DashboardQueryService
|
||||
@@ -157,6 +157,49 @@ def _expect_error(status, operation, *args):
|
||||
assert caught.value.status == status
|
||||
|
||||
|
||||
def test_widget_context_reads_current_permissions_without_issuing_or_rotating(widget_case):
|
||||
"""
|
||||
未启用也可核对当前账号;上下文不登记安装、轮换密钥或延长已有授权。
|
||||
"""
|
||||
case = widget_case
|
||||
result = case.auth.context(101, case.owners[0])
|
||||
assert result.model_dump() == {
|
||||
"version": 1,
|
||||
"owner": case.owners[0],
|
||||
"availableContents": ["downloads", "imports", "movieSubscriptions", "tvSubscriptions", "musicSubscriptions"],
|
||||
}
|
||||
with case.factory() as session:
|
||||
assert session.scalar(sa.select(sa.func.count()).select_from(WidgetRegistration)) == 0
|
||||
issued = _register(case)
|
||||
_update_user(case, permissions={"subscribe": True, "features": {"subscribe.movie": False, "subscribe.music": False}})
|
||||
case.clock[0] += 120
|
||||
assert case.auth.context(101, issued.owner).availableContents == ["tvSubscriptions"]
|
||||
current = case.auth.authenticate(issued.secret)
|
||||
assert current.registration.registration_id == str(issued.registrationId)
|
||||
assert current.registration.expires_at == issued.expiresAt
|
||||
with case.factory() as session:
|
||||
assert session.scalar(sa.select(sa.func.count()).select_from(WidgetRegistration)) == 1
|
||||
_update_user(case, permissions={})
|
||||
assert case.auth.context(101, issued.owner).availableContents == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["inactive", "deleted", "identity"])
|
||||
def test_widget_context_rejects_stale_main_account_identity(widget_case, change):
|
||||
"""
|
||||
主登录校验后的并发禁用、删除和身份重建不能被上下文读取掩盖。
|
||||
"""
|
||||
case = widget_case
|
||||
if change == "deleted":
|
||||
with case.factory() as session:
|
||||
session.execute(sa.delete(User).where(User.id == 101))
|
||||
session.commit()
|
||||
elif change == "identity":
|
||||
_update_user(case, notification_identity=uuid4().hex)
|
||||
else:
|
||||
_update_user(case, is_active=False)
|
||||
_expect_error(401, case.auth.context, 101, case.owners[0])
|
||||
|
||||
|
||||
def test_widget_issue_rotation_owner_and_self_revoke(widget_case):
|
||||
"""
|
||||
同安装隔离用户、轮换只撤销旧凭据、自撤销只影响自身,数据库没有原文。
|
||||
@@ -482,6 +525,43 @@ def _client(case):
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_widget_http_context_requires_main_login_and_never_exports_credentials(widget_case):
|
||||
"""
|
||||
真实路由拒绝组件凭据,只返回当前账号最小上下文,错误及成功均不泄露密钥。
|
||||
"""
|
||||
case = widget_case
|
||||
issued = _register(case)
|
||||
with _client(case) as client:
|
||||
def main_user(_token=Depends(verify_token)):
|
||||
"""
|
||||
隔离宿主数据库装配,但保留真实主令牌验证及原激活账号检查。
|
||||
"""
|
||||
return SimpleNamespace(id=101, notification_identity=case.owners[0], is_active=True)
|
||||
|
||||
client.app.dependency_overrides[get_current_user] = main_user
|
||||
for headers in ({}, {"Authorization": f"Bearer {issued.secret}"}):
|
||||
response = client.get("/api/v1/widget/context", headers=headers)
|
||||
assert response.status_code == 401
|
||||
assert issued.secret not in response.text
|
||||
client.app.dependency_overrides[get_current_active_user] = lambda: SimpleNamespace(
|
||||
id=101, notification_identity=case.owners[0]
|
||||
)
|
||||
response = client.get(f"/api/v1/widget/context?owner={case.owners[1]}")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["Cache-Control"] == "no-store, private"
|
||||
assert response.headers["Vary"] == "Authorization"
|
||||
assert response.json()["data"] == case.auth.context(101, issued.owner).model_dump()
|
||||
assert set(response.json()["data"]) == {"version", "owner", "availableContents"}
|
||||
assert issued.secret not in response.text
|
||||
assert str(issued.registrationId) not in response.text
|
||||
assert case.auth.authenticate(issued.secret).registration.expires_at == issued.expiresAt
|
||||
_update_user(case, notification_identity=uuid4().hex)
|
||||
response = client.get("/api/v1/widget/context")
|
||||
assert response.status_code == 401
|
||||
assert response.headers["Cache-Control"] == "no-store, private"
|
||||
assert "owner" not in response.json()
|
||||
|
||||
|
||||
def test_widget_http_header_only_no_cache_and_no_general_api_access(widget_case):
|
||||
"""
|
||||
真实 HTTP 拒绝查询、Cookie、通用 API Key 和登录 JWT;只读凭据不能转成业务认证。
|
||||
|
||||
Reference in New Issue
Block a user