Files
mi-fitness-health/docs/QR_LOGIN.md
T
binglua 2dd32f1ee6 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
2026-09-03 22:28:31 +08:00

10 KiB
Raw Blame History

小米扫码登录实现方案

本文档只覆盖「扫码登录」这一部分:协议原理 → 服务端实现 → 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:

{
  "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&&& 前缀后:
{"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),对外返回:

{
  "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 调用

# 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 格式变化)会静默失效,需重新抓包对照