Files
GarminHealthLab/backend/services/garmin_auth.py
ericwyuan bb774c332b [阶段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>
2026-08-23 20:11:45 +08:00

161 lines
5.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Interactive Garmin login with two-factor auth, driven from the web UI.
The problem this solves: garth asks for the MFA code through a *blocking*
callback in the middle of `sso.login`. There is no "start login, return a
handle, resume later" API in garth 0.4.46, so the login has to stay alive
while the code is fetched.
Shape of the solution:
* the login runs in a background thread and parks inside `prompt_mfa`
* the browser posts the code in a separate request
* the two meet through a row in `garmin_mfa_sessions`, not process memory,
because gunicorn runs several workers and the code request will not
reliably land on the worker holding the parked login
The Garmin password never leaves the waiting thread — it is not stored.
Statuses:
starting - thread launched, login not yet at the MFA step
awaiting_code - parked in prompt_mfa, waiting for the browser
finishing - code received, completing the login
done - tokens saved
failed - see `error`
"""
import datetime
import threading
import time
import uuid
from config import DB_TYPE
from db import execute, query_one
from services import garmin as garmin_svc
# How long the parked login waits for a code before giving up. Garmin codes
# expire in 30 minutes, but holding a thread that long is wasteful; the user
# can simply start again.
CODE_WAIT_SECONDS = 300
POLL_INTERVAL_SECONDS = 2
# Sessions older than this are cleared out whenever a new one starts.
SESSION_TTL_MINUTES = 60
def _now():
return datetime.datetime.utcnow().isoformat(timespec="seconds")
def _set(session_id, status, **fields):
sets = ["status = ?", "updated_at = ?"]
params = [status, _now()]
for k, v in fields.items():
sets.append(f"{k} = ?")
params.append(v)
params.append(session_id)
execute(f"UPDATE garmin_mfa_sessions SET {', '.join(sets)} WHERE id = ?", params)
def get_session(session_id, user_id=None):
row = query_one("SELECT * FROM garmin_mfa_sessions WHERE id = ?", [session_id])
if not row:
return None
# A session id from one account must never address another's login.
if user_id and row["user_id"] != user_id:
return None
return row
def _cleanup(user_id):
cutoff = (
datetime.datetime.utcnow() - datetime.timedelta(minutes=SESSION_TTL_MINUTES)
).isoformat(timespec="seconds")
execute(
"DELETE FROM garmin_mfa_sessions WHERE user_id = ? AND created_at < ?",
[user_id, cutoff],
)
def _wait_for_code(session_id):
"""Block until the browser posts a code. Runs inside garth's prompt_mfa."""
_set(session_id, "awaiting_code")
deadline = time.time() + CODE_WAIT_SECONDS
while time.time() < deadline:
row = query_one(
"SELECT code FROM garmin_mfa_sessions WHERE id = ?", [session_id]
)
if row is None:
raise RuntimeError("登录会话已被取消")
if row["code"]:
_set(session_id, "finishing")
return row["code"]
time.sleep(POLL_INTERVAL_SECONDS)
raise TimeoutError("等待验证码超时,请重新发起登录")
def _run_login(session_id, user_id, garmin_email, password, is_cn, import_garmin):
try:
Garmin = import_garmin()
client = Garmin(is_cn=is_cn)
# Calling garth directly is what allows the MFA prompt to be replaced;
# Garmin.login() hardcodes the stdin one.
client.garth.login(
garmin_email, password, prompt_mfa=lambda: _wait_for_code(session_id)
)
garmin_svc.save_token(user_id, client.garth.dumps(), garmin_email)
_set(session_id, "done", code=None)
except Exception as e: # noqa: BLE001 - surfaced to the user via the row
_set(session_id, "failed", error=f"{type(e).__name__}: {e}"[:500], code=None)
def start_login(user_id, garmin_email, password, import_garmin=None, is_cn=None):
"""Kick off a login in the background. Returns the session id."""
_cleanup(user_id)
session_id = str(uuid.uuid4())
execute(
"INSERT INTO garmin_mfa_sessions (id, user_id, status, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?)",
[session_id, user_id, "starting", _now(), _now()],
)
thread = threading.Thread(
target=_run_login,
args=(
session_id,
user_id,
garmin_email,
password,
garmin_svc._is_cn() if is_cn is None else is_cn,
import_garmin or garmin_svc._import_garmin,
),
daemon=True,
)
thread.start()
return session_id
def submit_code(session_id, user_id, code):
"""Hand a code to the parked login. Returns (ok, message)."""
row = get_session(session_id, user_id)
if not row:
return False, "登录会话不存在或已过期"
if row["status"] == "done":
return False, "登录已完成"
if row["status"] == "failed":
return False, row["error"] or "登录已失败,请重新发起"
if row["status"] not in ("awaiting_code", "starting"):
return False, f"当前状态无法提交验证码({row['status']}"
execute(
"UPDATE garmin_mfa_sessions SET code = ?, updated_at = ? WHERE id = ?",
[str(code).strip(), _now(), session_id],
)
return True, "验证码已提交"
def cancel(session_id, user_id):
execute(
"DELETE FROM garmin_mfa_sessions WHERE id = ? AND user_id = ?",
[session_id, user_id],
)