16 KiB
Music_Server 单终端绑定 Token 与时效控制设计
日期:2026-04-20
状态:已确认设计,待实现计划
范围:Music_Server、MusicFree 插件、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. 设计结论
本轮采用以下方案:
Music_Server把 token 状态落到本地可写库player.db- token 不明文入库,只保存哈希
- 业务接口使用“Bearer + clientId”联合校验
- token 第一次被使用时自动首绑到当前
clientId - token 一旦绑定后,只允许同一
clientId继续使用 - token 过期或被撤销后立即失效,不做自动恢复
MusicFree客户端为插件维护稳定的随机clientId,插件只负责随请求发送MusicFree插件列表页通过新状态接口显示“剩余时间/绑定状态/可播歌曲数”
推荐原因:
- 对现有部署侵入最小,不需要额外数据库服务
- 绑定语义清楚,用户容易理解
- 清缓存后的行为也一致:会变成“另一台终端”,而不是偷偷重置有效期
- 服务端掌握最终状态判断,插件和客户端只展示,不各自发明规则
4. 系统边界
4.1 Music_Server
负责:
- token 的签发数据存储
- token 过期、撤销、绑定状态校验
- 首绑与续用时更新时间戳
- 对外提供 token 状态接口
- 从
catalog_read.db聚合当前至少有一个 active 文件位置的歌曲总数 - 为运维提供签发、列出、撤销、解绑命令
不负责:
- 识别物理设备真身
- 自动续期
- 为插件生成 UI 文案
4.2 MusicFree 插件
负责:
- 从运行环境拿到
baseUrl、accessToken、clientId - 给所有
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_Server 的 player.db。原因:
catalog_read.db是读模型快照,不适合存可写授权状态player.db已经承担Music_Server本地状态数据职责,继续放 token 合理且部署简单
5.2 access_tokens 表
新增表:access_tokens
建议字段:
token_id TEXT PRIMARY KEYtoken_hash TEXT NOT NULL UNIQUElabel TEXTissued_at TEXT NOT NULLexpires_at TEXT NOT NULLbound_client_id TEXTbound_client_label TEXTbound_at TEXTlast_seen_at TEXTrevoked_at TEXTrevoked_reason TEXT
字段语义:
token_id:运维侧稳定主键,供list/revoke/unbind使用token_hash:token 明文的哈希值,服务端不保存明文label:签发时填的备注,例如iphone16issued_at:签发时间expires_at:失效时间bound_client_id:当前已绑定终端 IDbound_client_label:终端备注,例如My iPhonebound_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>可选
其中:
clientId是MusicFree客户端为该插件实例持久化的随机 IDclientLabel是可选终端备注,不参与唯一性判断,只用于展示
6.2 校验顺序
服务端按以下顺序处理:
- 校验
Authorization是否存在且格式正确 - 校验
X-Music-Client-Id是否存在 - 根据 token 明文哈希查
access_tokens - 若 token 不存在,拒绝
- 若
revoked_at非空,拒绝 - 若当前时间超过
expires_at,拒绝 - 若
bound_client_id为空,则原子地绑定到当前clientId - 若
bound_client_id等于当前clientId,允许通过并更新last_seen_at - 若
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 输入
请求头与业务接口一致:
AuthorizationX-Music-Client-IdX-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 首”
状态码约定:
401:Authorization缺失或 Bearer 格式非法200:Bearer 格式合法且已进入状态判断,具体状态写在 body 中
status 枚举:
activeexpiredrevokedbound_to_other_clienttoken_not_foundclient_id_missing
设计原因:
- 业务接口继续严格返回
401 - 状态接口允许把“为什么不可用”讲清楚,方便插件列表页展示
X-Music-Client-Id缺失时,状态接口返回200 + status=client_id_missing,而不是直接短路成401
7.4 业务接口错误码
受保护业务接口继续使用 401,但建议响应 body 带明确 detail code:
unauthorizedclient_id_missingtoken_not_foundtoken_expiredtoken_revokedtoken_bound_to_other_client
这能让插件或调试日志更容易定位问题。
8. 管理命令设计
8.1 目标命令
本轮提供四个命令:
issue-tokenlist-tokensrevoke-tokenunbind-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_id、label、expires_at、是否已绑定、绑定备注、最近使用时间、是否已撤销
revoke-token
- 设置
revoked_at - 一旦撤销,不可恢复使用
unbind-token
- 清空
bound_client_id - 清空
bound_client_label - 清空
bound_at - 不修改
expires_at - token 若未过期,下一次可重新绑定到新终端
9. MusicFree 插件设计
9.1 插件用户变量
插件用户变量保持最小集:
baseUrlaccessToken
不把 clientId 暴露为用户可编辑输入项,避免用户随手改掉后破坏绑定语义。
9.2 clientId 存储位置
clientId 由 MusicFree 客户端生成并持久化到插件私有元数据,而不是存到插件的 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 今日到期expired:Token 已过期revoked:Token 已撤销bound_to_other_client:Token 已绑定其他终端token_not_found:Token 无效- 请求失败:
Token 状态获取失败
当满足以下条件时:
status = activeplayableSongCount为数值
则在主文案后追加:
可播 12345 首
组合后的推荐展示形态:
Token 剩余 89 天 · 可播 12345 首
若服务端返回:
valid = trueisCurrentClientBound = 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 - 缺少
baseUrl或accessToken时给出明确错误
12.3 MusicFree 客户端
新增测试覆盖:
Music_Server插件列表项显示 token 状态行- 不同状态映射为正确文案
playableSongCount为数值时,列表项追加显示“可播 N 首”- 保存用户变量后会刷新状态
- 插件私有
clientId可持久化复用
12.4 验收标准
以下条件同时满足即视为完成:
- 新签发 token 默认 90 天有效
- 第一次在
MusicFree配置后会自动绑定当前终端 - 同一个 token 在第二台终端上会被拒绝
- 清缓存后生成新
clientId,旧 token 不会自动恢复可用 unbind-token后,未过期 token 能重新绑定到新终端- 插件管理列表页能看到剩余时间或失败原因;当 token 有效且统计成功时,还能看到可播歌曲数
- 业务接口不会因为状态展示需求而放松鉴权
13. 实施边界
本 spec 只覆盖:
D:\source\musicdl-catalog-sync-worktrees\Music_ServerD:\source\MusicFree
不回到旧仓库 D:\source\musicdl 实现这部分功能。
本轮完成后,下一步应进入实现计划阶段,再拆成:
Music_Servertoken 存储与认证MusicFree插件请求头与状态查询MusicFree客户端插件列表状态与可播歌曲数展示