feat: REST API with API keys, QR login, and web dashboard (v0.2.0)

- REST API (FastAPI): all data endpoints, sync (foreground/background),
  coverage stats, interactive docs at /docs
- API key system: exchange Xiaomi credentials for mif_sk_* keys
  (multi-account contexts, last-used tracking, prefix revocation);
  static key via MI_FITNESS_API_KEY, admin endpoints via MI_FITNESS_ADMIN_KEY
- QR code login: reverse-engineered Xiaomi universal QR flow
  (sid=xiaomiio), auto-issues API key after scan confirmation
- Built-in web dashboard (Flask): visual console for all endpoints,
  key management, QR login; reverse proxy with server-side key relay
- Standalone testpage/ console (python app.py, no install needed)
- Fix: pin mcp<2 (SDK 2.x removed Server.list_tools, broke startup)
- CI: install [all,dev] extras; docs: full technical documentation
  and QR login implementation guide
This commit is contained in:
binglua committed 2026-09-03 22:28:31 +08:00
1 parent 7fcd06980f
commit 2dd32f1ee6
21 files changed
+8723 -165

No files matched your search

+209
View File
@@ -0,0 +1,209 @@
# 小米扫码登录实现方案
> 本文档只覆盖「扫码登录」这一部分:协议原理 → 服务端实现 → API 用法 → 安全与限制。
> 代码位置:`src/mi_fitness_mcp/api.py`(`/api/auth/qr/*` 三个端点);测试页入口:`mi-fitness-api-test`(扫码面板)。
> 协议来源:逆向小米账号通用扫码流程,参考 python-miio `cloud_qr.py`(MIT)。
---
## 1. 目标
用「手机扫码」替代「浏览器 F12 手抠 Cookie」来获取小米凭据 `userId + passToken`,扫码成功后服务端自动发放本平台的 API Key(`mif_sk_*`)。整个过程中小米凭据只出现在服务端,调用方全程只接触 API Key。
小米对第三方**密码登录**已加验证码,但**扫码流程不受影响**——这是选择扫码方案的核心原因。
## 2. 小米扫码协议(逆向所得)
整个协议只有两个云端端点,全部是 GET,无需任何预置凭据:
```
手机App 我们的服务端 小米账号服务
│ │ │
│ │ ① GET /longPolling/loginUrl │
│ │◄──────────────────────────────│
│ │ 返回 qr / lp / loginUrl/ticket│
│ │ │
│ ② 用户扫码→点确认 │ │
│◄───────────────────────│ (二维码 = 带 ticket 的URL) │
│──── 确认登录 ──────────────────────────────────────────►│
│ │ │
│ │ ③ GET {lp} 长轮询(挂起) │
│ │◄──────────────────────────────│
│ │ 返回 userId + passToken │
```
### 2.1 端点一:生成登录会话
```
GET https://account.xiaomi.com/longPolling/loginUrl
```
| 参数 | 值 | 说明 |
|---|---|---|
| `sid` | `xiaomiio` | **必须是这个值**,换 `miothealth` 会返回 10025 错误 |
| `callback` | `https://sts.api.io.mi.com/sts` | **必须是这个值**,其他值同样 10025 |
| `qs` | `%3Fsid%3Dxiaomiio%26_json%3Dtrue` | 即 `?sid=xiaomiio&_json=true` 的 URL 编码 |
| `_qrsize` | `480` | 二维码图片尺寸 |
| `_hasLogo` | `false` | 二维码中央不放 logo |
| `_locale` | `zh_CN` | 界面语言 |
| `_dc` | 当前毫秒时间戳 | 防缓存 |
响应体以固定前缀 `&&&START&&&` 开头(小米账号接口的通用 guard),剥掉后是 JSON:
```json
{
"qr": "https://account.xiaomi.com/pass/qr/login?ticket=lp_xxxx",
"loginUrl": "https://c3.account.xiaomi.com/longPolling/login?ticket=lp_xxxx",
"lp": "https://c3.lp.account.xiaomi.com/lp/s?k=lp_xxxx",
"timeout": "300"
}
```
| 字段 | 含义 |
|---|---|
| `qr` | 二维码图片的 URL(内容就是它自己,服务端需代抓 PNG) |
| `loginUrl` | 电脑端兜底:浏览器打开它 → 登录账号 → 点确认,效果等同扫码 |
| `lp` | 长轮询地址,用来等"用户已确认"的结果 |
| `timeout` | 本次登录会话有效期(秒),一般 300 |
三个 URL 共享同一个一次性 `ticket`(`lp_` 前缀),ticket 就是"这一次登录会话"的身份。
### 2.2 端点二:长轮询确认结果
```
GET {lp}
```
- 用户**尚未确认**:请求挂起(长轮询),十几秒后超时返回,继续轮询即可。
- 用户**已确认**:立即返回 HTTP 200,剥 `&&&START&&&` 前缀后:
```json
{"code": 0, "userId": 1234567890, "passToken": "V1:..."}
```
`userId` + `passToken` 与浏览器 Cookie 中手抠到的完全同种,且是**账号级**凭据(不绑定 xiaomiio 服务)。
### 2.3 关键坑(已修复并验证)
| 坑 | 现象 | 结论 |
|---|---|---|
| sid 不能用 `miothealth` | 返回 `{"code":10025,"desc":"Callback连接不合法"}` | 扫码入口只认 `sid=xiaomiio` + `callback=https://sts.api.io.mi.com/sts` 这一个组合 |
| passToken 是否绑服务 | 担心 xiaomiio 的 token 不能用于运动健康 | **不绑定**。拿到后走 `serviceLogin?sid=miothealth` 可正常换运动健康会话 |
| 响应前缀 | 直接 `json.loads` 报错 | 所有账号接口都要先剥 `&&&START&&&` |
| 长轮询挂起 | 超时/网络异常频繁 | 属正常节奏,捕获后继续轮询,不算失败 |
## 3. 服务端实现(api.py)
小米的流程是"一次长轮询阻塞到底",直接搬到 HTTP API 会让请求挂几分钟。实现上改造成**提交-轮询**模式,拆成三个端点:
### 3.1 状态机
```
POST /start 生成会话
│
▼
[waiting] ──poll 时调 lp 且收到 code=0+userId+passToken──► [confirmed](含 api_key)
│ │
└─── 超过 timeout+10 秒 ──► [expired] 之后 poll 永远返回 confirmed 结果
```
### 3.2 端点一:`POST /api/auth/qr/start?region=cn`
服务端向小米请求登录会话,把 `lp` / `loginUrl` / 二维码 PNG / 过期时间 / region 存入内存字典 `qr_sessions`(key 为 uuid `qr_token`),对外返回:
```json
{
"qr_token": "b5ca13315c1f4a62805a47916c82404a",
"qr_image": "/api/auth/qr/b5ca13315c1f4a62805a47916c82404a.png",
"login_url": "https://c3.account.xiaomi.com/longPolling/login?ticket=lp_...",
"expires_in": 300,
"instruction": "用小米运动健康/小米账号 App 扫码,或在浏览器打开 login_url 确认;然后轮询 /api/auth/qr/poll?token=..."
}
```
二维码 PNG 由服务端代抓并缓存进会话,浏览器拿到的是现成图片,不直连小米。
### 3.3 端点二:`GET /api/auth/qr/{token}.png`
返回会话中缓存的 PNG(480×480,实测约 15KB),前端 `<img src>` 直接显示。token 不存在返回 404。
### 3.4 端点三:`GET /api/auth/qr/poll?token=xxx`
每次调用最多阻塞约 15 秒(lp 单次挂起上限),无异常时返回三种状态之一:
| 返回 | 含义 | 后续动作 |
|---|---|---|
| `{"status":"waiting"}` | 未确认/网络抖动 | 前端隔几秒再调 |
| `{"status":"confirmed","api_key":"mif_sk_...","user_id":"..."}` | 登录成功,Key 已发放 | 前端存下 Key,此后带头调用 |
| `{"status":"expired"}` | 超 5 分钟未确认 | 重新调 start 生成新码 |
**confirmed 时服务端做的三件事**(全部在返回前完成):
1. 从 lp 结果提取 `userId` / `passToken`;
2. 用这套凭据真实构造 `MiFitnessCloudAdapter` 并 `connect()` 一次——验证 passToken 能换到运动健康的 serviceToken,避免发放无效 Key;验证失败则返回 `confirmed` 但 `api_key: null` 并附说明;
3. 凭据写入主库 `api_keys` 表(key / user_id / pass_token / region / created_at),发放 `mif_sk_<40位hex>`,完整 Key 仅此一次返回。
### 3.5 设计取舍
| 决策 | 理由 |
|---|---|
| 长轮询放进 poll 而不是 start | start 里阻塞等扫码会让 HTTP 请求挂 5 分钟,网关/浏览器易超时;改为前端每几秒调一次 poll,单次最多 15 秒 |
| `qr_sessions` 纯内存 | 存的是未完成登录的临时 ticket,天然短命,重启失效无副作用(重新生成即可),不值得持久化 |
| passToken 不进前端 | 扫码结果由服务端直接落库换 Key,浏览器全程只见 `mif_sk_*`,与 Key 体系安全模型一致 |
| 发 Key 前真实验证 | 防止把"扫码成功但服务受限"的凭据发出去,问题在发放时刻暴露而非首次调用时 |
| poll 的 confirmed 常驻内存 | 让前端可以重复查询结果;代价是进程重启后确认记录丢失(重新扫码即可) |
## 4. 凭据的后续链路(扫码之外)
扫码只解决"凭据从哪来"。拿到 `userId + passToken` 后的流程与手动配置完全一致:
```
passToken ──GET serviceLogin?sid=miothealth──► ssecurity(签名密钥) + serviceToken(会话Cookie)
│
└──► 健康数据接口(RC4 加密 + SHA1 签名,见主文档第 8 章)
```
即:扫码、`mi-fitness-mcp setup` 手动配置、`POST /api/auth/keys` 发放,三条路最终汇合到同一套凭据模型,扫码登录对 sync/query 全部端点透明生效。
## 5. 使用方法
### 5.1 测试页(推荐)
打开 `http://127.0.0.1:8322/` → 「扫码登录」面板 → 点「生成登录二维码」→ 手机小米账号/米家 App 扫码并确认 → 回页面点「轮询扫码状态」→ confirmed 后 Key 自动填入右上角请求头。
### 5.2 纯 API 调用
```bash
# 1. 生成二维码
curl -X POST "http://127.0.0.1:8321/api/auth/qr/start?region=cn"
# → {"qr_token":"...","qr_image":"/api/auth/qr/....png","login_url":"https://...","expires_in":300}
# 2. 展示二维码:浏览器打开 http://127.0.0.1:8321/api/auth/qr/{qr_token}.png
# 或让用户直接打开返回的 login_url 在网页上确认
# 3. 轮询(未确认返回 waiting,可循环调用)
curl "http://127.0.0.1:8321/api/auth/qr/poll?token={qr_token}"
# → {"status":"confirmed","api_key":"mif_sk_...","user_id":"..."}
```
### 5.3 鉴权要求
三个 `/api/auth/qr/*` 端点都受管理鉴权保护:
- 设置了环境变量 `MI_FITNESS_ADMIN_KEY`:必须带 `X-Admin-Key` 头;
- 未设置:仅允许 `127.0.0.1` 本机调用,非本机返回 403。
## 6. 安全说明
- **passToken 是长期凭据**:等同登录态,泄露后应退出并重新登录小米账号使其失效。本方案中它只在服务端内存与 `api_keys` 表中出现一次。
- **扫码 = 把账号登录到你的服务器**:`/api/auth/qr/start` 绝不能无鉴权暴露在局域网/公网。
- **ticket 一次性且 5 分钟过期**:小米侧控制,无法延长;过期重新生成即可。
- **内存态会话**:服务重启丢失所有未完成的扫码会话,无持久化泄露面。
## 7. 已验证与已知边界
| 状态 | 项 |
|---|---|
| ✅ 已验证 | 会话生成、二维码 PNG 有效(480×480)、poll 未扫码返回 waiting、10025 问题修复、Key 发放→使用→吊销全链路 |
| ⏳ 需人工 | 用手机实际扫码走完 confirmed 全流程(扫码登录的本意,无法程序化替代) |
| 已知限制 | 二维码 5 分钟过期;confirmed 记录仅存内存;协议为逆向所得,小米改版(如 ticket 格式变化)会静默失效,需重新抓包对照 |