[阶段5.2] 两步验证改到网页端完成,手机上即可绑定 Garmin

背景:命令行方案要求用户在电脑前开交互式终端,实际不可行。
改为在网页里完成 MFA,手机也能操作。

难点:garth 索取验证码走的是 *阻塞回调*,0.4.46 没有
"发起登录 -> 返回句柄 -> 稍后续接" 的接口,登录必须一直挂着。
而 gunicorn 跑多个 worker,验证码请求不一定落到挂着登录的那个 worker。

方案:登录跑在后台线程里,停在 prompt_mfa 内轮询数据库;
浏览器用另一个请求把验证码写进同一行。**汇合点是数据库而非进程内存**,
所以哪个 worker 收到验证码都能送达。

- 新增 garmin_mfa_sessions 表(不存密码,密码只活在等待线程的内存里)
- services/garmin_auth.py:start_login / submit_code / cancel
  状态机 starting -> awaiting_code -> finishing -> done|failed
- 超时 5 分钟自动放弃,会话 1 小时后清理
- 会话按 user_id 校验,他人拿到 session id 也读不到、提交不了

接口:
- POST   /api/garmin/login        发起登录,202 返回 session
- GET    /api/garmin/login-status 轮询状态
- POST   /api/garmin/mfa          提交验证码
- DELETE /api/garmin/login        取消

前端 DataSync 改为三步:
- 未绑定 -> 输密码「绑定 Garmin 账号」
- 需要验证码 -> 弹出 6 位验证码输入框(inputMode=numeric、
  autoComplete=one-time-code,手机可直接从短信自动填充)
- 已绑定 -> 只剩「立即同步」,不再要密码

tests/test_garmin_mfa.py (20 通过):
- stub 的 prompt_mfa 按 garth 的真实方式同步阻塞调用
- 关键用例:验证码直接写进数据库行也能被挂起的线程取到
  (模拟验证码落到另一个 worker)
- 无 MFA 的账号不经验证码直接完成
- 验证码错误 / 密码错误 / 等待超时 各自失败并给出原因
- 取消后挂起线程立即释放,不空转到超时
- 密码不出现在会话行里
- 跨用户读取和提交均被拒

全量: 271 passed

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-08-23 20:11:45 +08:00
parent af0604bce4
commit bb774c332b
8 changed files with 801 additions and 54 deletions

View File

@@ -4,6 +4,7 @@ from flask import Blueprint, request, g, jsonify
from auth import require_auth
from db import query_one
from services import garmin as garmin_svc
from services import garmin_auth
bp = Blueprint("garmin", __name__)
@@ -46,6 +47,66 @@ def auth_status():
return jsonify({"hasToken": garmin_svc.has_token(g.user_id)})
@bp.route("/login", methods=["POST"])
@require_auth
def login():
"""Begin an interactive Garmin login.
Returns immediately with a session id; the login continues in the
background and parks if Garmin asks for a two-factor code. Poll
/login-status and post the code to /mfa.
"""
data = request.get_json(silent=True) or {}
password = data.get("garminPassword") or ""
if not password:
return jsonify({"error": "请提供 Garmin 密码"}), 400
garmin_email = (data.get("garminEmail") or "").strip()
if not garmin_email:
user = query_one("SELECT garmin_email FROM users WHERE id = ?", [g.user_id])
garmin_email = (user or {}).get("garmin_email") or ""
if not garmin_email:
return jsonify({"error": "缺少 Garmin 邮箱"}), 400
session_id = garmin_auth.start_login(g.user_id, garmin_email, password)
return jsonify({"session": session_id, "status": "starting"}), 202
@bp.route("/login-status", methods=["GET"])
@require_auth
def login_status():
session_id = request.args.get("session") or ""
row = garmin_auth.get_session(session_id, g.user_id)
if not row:
return jsonify({"error": "登录会话不存在或已过期"}), 404
return jsonify({
"session": row["id"],
"status": row["status"],
"error": row["error"],
})
@bp.route("/mfa", methods=["POST"])
@require_auth
def submit_mfa():
data = request.get_json(silent=True) or {}
session_id = (data.get("session") or "").strip()
code = (data.get("code") or "").strip()
if not session_id or not code:
return jsonify({"error": "session 与 code 均为必填"}), 400
ok, message = garmin_auth.submit_code(session_id, g.user_id, code)
return jsonify({"ok": ok, "message": message}), (200 if ok else 400)
@bp.route("/login", methods=["DELETE"])
@require_auth
def cancel_login():
session_id = request.args.get("session") or ""
garmin_auth.cancel(session_id, g.user_id)
return jsonify({"ok": True})
@bp.route("/status", methods=["GET"])
@require_auth
def status():