Files
musicdl-catalog-sync-suite/Music_Server/docs/superpowers/specs/2026-04-20-music-server-token-binding-design.md
T

16 KiB
Raw Blame History

Music_Server 单终端绑定 Token 与时效控制设计

日期:2026-04-20
状态:已确认设计,待实现计划
范围:Music_ServerMusicFree 插件、MusicFree 客户端插件列表页

1. 背景

当前 Music_Server 的鉴权逻辑非常简单:

  • 所有受保护接口只校验 Authorization: Bearer <token>
  • 服务端把 Bearer 字符串与 PUBLIC_MUSIC_ACCESS_TOKEN 做一次直接比较
  • 不区分不同终端
  • 不支持过期、撤销、解绑、状态查询

这套逻辑足够支撑最初的自用联调,但在当前链路下已经不够用:

  • MusicFree 插件已经支持通过订阅地址自动更新,插件分发更方便后,访问控制也需要更精细
  • 你希望一个 token 只能给一个终端使用,避免同一个 token 被多端直接共享
  • 你希望 token 本身有失效时间,例如默认 90 天
  • 你还希望在 MusicFree 插件管理页一眼看到 token 是否可用、剩余多久、是否已绑到别的终端,以及当前服务里已有多少首可播歌曲

本设计要解决的是“可控授权”,不是“强 DRM”。目标是把权限边界收紧到“一个 token 对应一个当前终端,并且 token 自身会过期”,同时保持部署和日常签发仍然足够简单。

2. 目标与非目标

2.1 目标

  • 一个 token 在同一时刻只能绑定一个终端
  • token 首次被合法终端使用时自动绑定
  • token 本身有明确过期时间,默认 90 天
  • 支持查看、签发、撤销、解绑 token
  • Music_Server 能返回 token 状态,供 MusicFree 展示剩余时间和绑定情况
  • Music_Server 的状态接口在 token 有效时,同时返回当前全局可播歌曲数
  • MusicFree 为每个安装实例持久化一个随机 clientId
  • 清缓存或重装后生成新的 clientId,服务端视为另一台终端

2.2 非目标

  • 不做硬件指纹、IMEI、MAC 地址等强设备识别
  • 不做自动续期,token 过期后必须重新签发
  • 不做“一个 token 多终端共享配额”
  • 不做歌单、搜索、播放接口语义调整
  • 不修改 catalog-sync 的采集、下载、导出链路

3. 设计结论

本轮采用以下方案:

  1. Music_Server 把 token 状态落到本地可写库 player.db
  2. token 不明文入库,只保存哈希
  3. 业务接口使用“Bearer + clientId”联合校验
  4. token 第一次被使用时自动首绑到当前 clientId
  5. token 一旦绑定后,只允许同一 clientId 继续使用
  6. token 过期或被撤销后立即失效,不做自动恢复
  7. MusicFree 客户端为插件维护稳定的随机 clientId,插件只负责随请求发送
  8. MusicFree 插件列表页通过新状态接口显示“剩余时间/绑定状态/可播歌曲数”

推荐原因:

  • 对现有部署侵入最小,不需要额外数据库服务
  • 绑定语义清楚,用户容易理解
  • 清缓存后的行为也一致:会变成“另一台终端”,而不是偷偷重置有效期
  • 服务端掌握最终状态判断,插件和客户端只展示,不各自发明规则

4. 系统边界

4.1 Music_Server

负责:

  • token 的签发数据存储
  • token 过期、撤销、绑定状态校验
  • 首绑与续用时更新时间戳
  • 对外提供 token 状态接口
  • catalog_read.db 聚合当前至少有一个 active 文件位置的歌曲总数
  • 为运维提供签发、列出、撤销、解绑命令

不负责:

  • 识别物理设备真身
  • 自动续期
  • 为插件生成 UI 文案

4.2 MusicFree 插件

负责:

  • 从运行环境拿到 baseUrlaccessTokenclientId
  • 给所有 Music_Server 请求附带鉴权头
  • 调用 token 状态接口并把结果透传给客户端展示层,包括 playableSongCount

不负责:

  • 本地计算 token 是否过期
  • 本地决定绑定是否合法
  • 本地生成“剩余 89 天”这种最终展示文案

4.3 MusicFree 客户端

负责:

  • Music_Server 插件保存稳定的随机 clientId
  • 在插件管理列表页展示 token 状态与当前可播歌曲数
  • 在用户修改 accessToken 后刷新一次状态

不负责:

  • 直接解析或验证 token
  • 代替服务端决定绑定、过期、撤销逻辑

4.4 catalog-sync

本轮无改动。它继续只负责歌单采集、下载、上传、导出,不参与 Music_Server 的访问授权。

5. 数据模型

5.1 存储位置

token 元数据写入 Music_Serverplayer.db。原因:

  • catalog_read.db 是读模型快照,不适合存可写授权状态
  • player.db 已经承担 Music_Server 本地状态数据职责,继续放 token 合理且部署简单

5.2 access_tokens 表

新增表:access_tokens

建议字段:

  • token_id TEXT PRIMARY KEY
  • token_hash TEXT NOT NULL UNIQUE
  • label TEXT
  • issued_at TEXT NOT NULL
  • expires_at TEXT NOT NULL
  • bound_client_id TEXT
  • bound_client_label TEXT
  • bound_at TEXT
  • last_seen_at TEXT
  • revoked_at TEXT
  • revoked_reason TEXT

字段语义:

  • token_id:运维侧稳定主键,供 list/revoke/unbind 使用
  • token_hash:token 明文的哈希值,服务端不保存明文
  • label:签发时填的备注,例如 iphone16
  • issued_at:签发时间
  • expires_at:失效时间
  • bound_client_id:当前已绑定终端 ID
  • bound_client_label:终端备注,例如 My iPhone
  • bound_at:首次绑定时间
  • last_seen_at:最近一次成功校验时间
  • revoked_at:撤销时间,非空即永久失效
  • revoked_reason:撤销备注,可空

建议索引:

  • UNIQUE INDEX idx_access_tokens_token_hash(token_hash)
  • INDEX idx_access_tokens_expires_at(expires_at)
  • INDEX idx_access_tokens_bound_client_id(bound_client_id)

5.3 token 明文格式

token 明文设计为高熵随机字符串,例如:

msv1_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

约束:

  • 明文只在签发时输出一次
  • 服务端落库时只保存哈希
  • 后续所有管理动作以 token_id 为主,不依赖再次输入明文

6. 鉴权与绑定流程

6.1 请求头约定

所有受保护的 Music_Server 接口统一接受:

  • Authorization: Bearer <token>
  • X-Music-Client-Id: <clientId>
  • X-Music-Client-Label: <clientLabel> 可选

其中:

  • clientIdMusicFree 客户端为该插件实例持久化的随机 ID
  • clientLabel 是可选终端备注,不参与唯一性判断,只用于展示

6.2 校验顺序

服务端按以下顺序处理:

  1. 校验 Authorization 是否存在且格式正确
  2. 校验 X-Music-Client-Id 是否存在
  3. 根据 token 明文哈希查 access_tokens
  4. 若 token 不存在,拒绝
  5. revoked_at 非空,拒绝
  6. 若当前时间超过 expires_at,拒绝
  7. bound_client_id 为空,则原子地绑定到当前 clientId
  8. bound_client_id 等于当前 clientId,允许通过并更新 last_seen_at
  9. bound_client_id 不等于当前 clientId,拒绝

6.3 首绑规则

首绑发生在“第一次成功访问受保护接口”时,而不是签发时。

这样做的原因:

  • 不需要在签发时就知道具体终端
  • token 可以先发出去,再由用户首次在设备上配置
  • 终端备注也能在首次请求时一并带上

这里的“受保护接口”包括业务接口,也包括 GET /auth/v1/token-status。也就是说,用户第一次在 MusicFree 里配置好插件后,插件列表页如果先请求状态接口,也可以完成首绑。

6.4 清缓存/重装行为

MusicFree 清缓存或卸载重装后,会生成新的 clientId。在本设计里,这被视为另一台终端:

  • token 原有过期时间不变
  • 不会重置 90 天倒计时
  • 若 token 已绑到旧 clientId,新 clientId 再使用会被拒绝
  • 用户需要执行 unbind-token 或重新签发一个新 token

这与用户已确认的预期一致。

7. 状态接口设计

7.1 路由

新增:

  • GET /auth/v1/token-status

7.2 输入

请求头与业务接口一致:

  • Authorization
  • X-Music-Client-Id
  • X-Music-Client-Label 可选

7.3 输出

建议统一返回:

{
  "valid": true,
  "status": "active",
  "tokenId": "tok_01",
  "label": "iphone16",
  "issuedAt": "2026-04-20T08:00:00Z",
  "expiresAt": "2026-07-19T08:00:00Z",
  "remainingSeconds": 7776000,
  "remainingDays": 90,
  "playableSongCount": 12345,
  "bound": true,
  "isCurrentClientBound": true,
  "boundClientLabel": "My iPhone"
}

其中 playableSongCount 的语义固定为:当前 Music_Server 读模型中,至少存在一个 active 文件位置的歌曲总数。

返回策略:

  • status = active 且统计成功时,返回实际整数值
  • 当 token 不可用,或当前无法可靠统计时,返回 null
  • 插件列表页只在该字段为数值时展示“可播 N 首”

状态码约定:

  • 401Authorization 缺失或 Bearer 格式非法
  • 200:Bearer 格式合法且已进入状态判断,具体状态写在 body 中

status 枚举:

  • active
  • expired
  • revoked
  • bound_to_other_client
  • token_not_found
  • client_id_missing

设计原因:

  • 业务接口继续严格返回 401
  • 状态接口允许把“为什么不可用”讲清楚,方便插件列表页展示
  • X-Music-Client-Id 缺失时,状态接口返回 200 + status=client_id_missing,而不是直接短路成 401

7.4 业务接口错误码

受保护业务接口继续使用 401,但建议响应 body 带明确 detail code

  • unauthorized
  • client_id_missing
  • token_not_found
  • token_expired
  • token_revoked
  • token_bound_to_other_client

这能让插件或调试日志更容易定位问题。

8. 管理命令设计

8.1 目标命令

本轮提供四个命令:

  • issue-token
  • list-tokens
  • revoke-token
  • unbind-token

8.2 推荐命令行形式

签发:

python -m music_server.tools.issue_token --days 90 --label iphone16

列出:

python -m music_server.tools.list_tokens

撤销:

python -m music_server.tools.revoke_token --token-id tok_01 --reason replaced

解绑:

python -m music_server.tools.unbind_token --token-id tok_01

8.3 命令语义

issue-token

  • 默认 --days 90
  • 输出 token_id、明文 token、expires_at
  • 明文 token 只在这一步展示

list-tokens

  • 默认列出未撤销 token
  • 至少显示:token_idlabelexpires_at、是否已绑定、绑定备注、最近使用时间、是否已撤销

revoke-token

  • 设置 revoked_at
  • 一旦撤销,不可恢复使用

unbind-token

  • 清空 bound_client_id
  • 清空 bound_client_label
  • 清空 bound_at
  • 不修改 expires_at
  • token 若未过期,下一次可重新绑定到新终端

9. MusicFree 插件设计

9.1 插件用户变量

插件用户变量保持最小集:

  • baseUrl
  • accessToken

不把 clientId 暴露为用户可编辑输入项,避免用户随手改掉后破坏绑定语义。

9.2 clientId 存储位置

clientIdMusicFree 客户端生成并持久化到插件私有元数据,而不是存到插件的 userVariables

  • userVariables 是面向用户可编辑的
  • clientId 应该是客户端私有运行时状态

建议在插件元数据存储中新增类似键位:

  • ${pluginPlatform}.runtimeClientId
  • ${pluginPlatform}.runtimeClientLabel

插件运行环境通过注入的 env 读取它,而不是自己写文件。

9.3 插件请求行为

插件对所有 Music_Server 请求统一附带:

  • Authorization: Bearer <accessToken>
  • X-Music-Client-Id: <runtimeClientId>
  • X-Music-Client-Label: <runtimeClientLabel> 可选

插件新增一个轻量状态查询方法,供客户端列表页调用:

  • 调用 GET /auth/v1/token-status
  • 原样返回服务端状态结果,包括 playableSongCount

10. MusicFree 客户端展示设计

10.1 展示位置

只在插件管理列表页展示,不扩散到更多页面。

目标位置:

  • src/pages/setting/settingTypes/pluginSetting/components/pluginItem.tsx

10.2 展示策略

只对 Music_Server 插件显示状态信息。推荐文案映射:

  • active 且剩余大于 1 天:主文案为 Token 剩余 89 天
  • active 且剩余不足 1 天:主文案为 Token 今日到期
  • expiredToken 已过期
  • revokedToken 已撤销
  • bound_to_other_clientToken 已绑定其他终端
  • token_not_foundToken 无效
  • 请求失败:Token 状态获取失败

当满足以下条件时:

  • status = active
  • playableSongCount 为数值

则在主文案后追加:

  • 可播 12345 首

组合后的推荐展示形态:

  • Token 剩余 89 天 · 可播 12345 首

若服务端返回:

  • valid = true
  • isCurrentClientBound = true

则可附加补充文案:

  • 已绑定当前终端

10.3 刷新时机

不做高频轮询,采用轻量刷新:

  • 打开插件列表页时刷新一次
  • 用户保存 accessToken 后刷新一次
  • 手动更新插件后刷新一次

这样能拿到足够新的状态,同时避免插件列表页每秒请求服务端。

11. 边界行为

11.1 已绑定 token 再被别的终端使用

结果:

  • 业务接口返回 401
  • 状态接口返回 200 + status=bound_to_other_client

11.2 token 到期

结果:

  • 所有业务接口拒绝
  • 状态页显示已过期
  • 只能重新签发新 token,不能靠刷新 clientId 恢复

11.3 token 被解绑

结果:

  • 旧终端下一次请求会因“未再次绑定”而进入重新首绑流程
  • 哪个终端先成功请求,token 就重新绑给谁
  • 解绑不延长 token 生命周期

11.4 token 被撤销

结果:

  • 所有终端全部失效
  • 即便原绑定终端也不能继续使用

11.5 用户只改 baseUrl,不改 token

如果切换到另一台 Music_Server

  • 新服务端把它当作独立 token 空间
  • token 是否可用只由新服务端自己的 access_tokens 决定

12. 测试与验收

12.1 Music_Server

新增测试覆盖:

  • token 签发后可被首绑
  • 已绑定同一 clientId 可继续访问
  • 不同 clientId 被拒绝
  • 过期 token 被拒绝
  • 撤销 token 被拒绝
  • unbind-token 后可重新首绑
  • GET /auth/v1/token-status 返回正确状态,并在有效 token 下带上 playableSongCount

12.2 MusicFree 插件

新增测试覆盖:

  • 请求头正确携带 Authorization
  • 请求头正确携带 X-Music-Client-Id
  • 状态接口调用正确,并能透传 playableSongCount
  • 缺少 baseUrlaccessToken 时给出明确错误

12.3 MusicFree 客户端

新增测试覆盖:

  • Music_Server 插件列表项显示 token 状态行
  • 不同状态映射为正确文案
  • playableSongCount 为数值时,列表项追加显示“可播 N 首”
  • 保存用户变量后会刷新状态
  • 插件私有 clientId 可持久化复用

12.4 验收标准

以下条件同时满足即视为完成:

  1. 新签发 token 默认 90 天有效
  2. 第一次在 MusicFree 配置后会自动绑定当前终端
  3. 同一个 token 在第二台终端上会被拒绝
  4. 清缓存后生成新 clientId,旧 token 不会自动恢复可用
  5. unbind-token 后,未过期 token 能重新绑定到新终端
  6. 插件管理列表页能看到剩余时间或失败原因;当 token 有效且统计成功时,还能看到可播歌曲数
  7. 业务接口不会因为状态展示需求而放松鉴权

13. 实施边界

本 spec 只覆盖:

  • D:\source\musicdl-catalog-sync-worktrees\Music_Server
  • D:\source\MusicFree

不回到旧仓库 D:\source\musicdl 实现这部分功能。

本轮完成后,下一步应进入实现计划阶段,再拆成:

  • Music_Server token 存储与认证
  • MusicFree 插件请求头与状态查询
  • MusicFree 客户端插件列表状态与可播歌曲数展示