feat(widget): 增加当前主登录的只读账号上下文 (#7176)

This commit is contained in:
jxxghp
2026-10-25 06:28:57 +08:00
committed by GitHub
parent 721c03734a
commit 86ededa739
9 changed files with 144 additions and 11 deletions

View File

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

View File

@@ -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:
"""
原子轮换本安装凭据,只有成功提交后的新密钥可以返回客户端。

View File

@@ -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、权限或来源地址。

View File

@@ -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)。主登录凭据不得交给系统扩展。
### 通知配置与交互身份

View File

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

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 = "64"
MOVIEPILOT_API_SKILL_VERSION = "65"
DATABASE_OPERATION_SKILL_VERSION = "15"

View File

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

View File

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

View File

@@ -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;只读凭据不能转成业务认证。