[阶段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

@@ -98,6 +98,23 @@ CREATE TABLE IF NOT EXISTS garmin_tokens (
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- Rendezvous for the interactive MFA login.
-- garth asks for the code through a *blocking* callback, so the login parks in
-- a background thread while the code arrives in a separate HTTP request that
-- may land on a different gunicorn worker. The handoff therefore goes through
-- the database rather than process memory.
-- Holds no password: that stays in the waiting thread's memory only.
CREATE TABLE IF NOT EXISTS garmin_mfa_sessions (
id VARCHAR(64) PRIMARY KEY,
user_id VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
code VARCHAR(16),
error TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- One cached LLM answer per user. Generating one takes minutes against a
-- large reasoning model, which is far too slow to sit in a page load, so the
-- result is stored and reused until the underlying data changes.

View File

@@ -74,11 +74,27 @@ def main():
Garmin = garmin_svc._import_garmin()
client = Garmin(email=garmin_email, password=password, is_cn=garmin_svc._is_cn())
print("\n正在登录……若账号开启了两步验证,稍后会提示输入验证码。")
def ask_mfa():
# garth's built-in prompt is a bare English input() that is easy to
# miss in the surrounding output, so this replaces it with something
# unmistakable.
print("\n" + "=" * 52)
print(" 账号开启了两步验证,请查收短信/邮件中的验证码")
print("=" * 52)
while True:
code = input(" 验证码6 位数字): ").strip()
if code:
return code
print(" 验证码不能为空,请重新输入。")
print("\n正在登录……")
try:
# No tokenstore: this is the interactive path that mints the tokens.
# garth's default MFA prompt reads stdin, which works here.
client.login()
# Call garth directly rather than Garmin.login(): only this path lets
# the MFA prompt be replaced. The two lines afterwards are what
# Garmin.login() would otherwise populate.
client.garth.login(garmin_email, password, prompt_mfa=ask_mfa)
client.display_name = client.garth.profile["displayName"]
client.full_name = client.garth.profile["fullName"]
except Exception as e:
sys.exit(f"\n登录失败: {type(e).__name__}: {e}")

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():

View File

@@ -0,0 +1,160 @@
"""
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],
)

View File

@@ -0,0 +1,282 @@
"""
Unit tests for the web-driven two-factor Garmin login.
No network and no real library: a stub Garmin/garth pair stands in, and its
`prompt_mfa` callback is invoked exactly the way garth invokes it — blocking,
mid-login — because that blocking behaviour is the whole reason this flow
needs a background thread and a database rendezvous.
"""
import time
import pytest
from services import garmin as garmin_svc
from services import garmin_auth
def wait_for(predicate, timeout=8.0, interval=0.05):
"""Poll until the background thread reaches the expected state."""
deadline = time.time() + timeout
while time.time() < deadline:
value = predicate()
if value:
return value
time.sleep(interval)
return None
def status_of(session_id):
row = garmin_auth.get_session(session_id)
return row["status"] if row else None
def wait_status(session_id, *wanted, timeout=8.0):
return wait_for(lambda: status_of(session_id) in wanted and status_of(session_id),
timeout=timeout)
class StubGarth:
profile = {"displayName": "Tester", "fullName": "Test User"}
def __init__(self, needs_mfa=True, accept_code="123456", fail_login=False):
self.needs_mfa = needs_mfa
self.accept_code = accept_code
self.fail_login = fail_login
self.seen_code = None
self.logged_in_with = None
def login(self, email, password, prompt_mfa=None):
self.logged_in_with = (email, password)
if self.fail_login:
raise RuntimeError("401 Unauthorized")
if self.needs_mfa:
# garth calls this synchronously, in the middle of the login.
self.seen_code = prompt_mfa()
if self.seen_code != self.accept_code:
raise RuntimeError("验证码错误")
def dumps(self):
return "token-blob"
class StubGarmin:
"""Class factory: `make()` returns something usable as `Garmin`."""
last = None
@classmethod
def make(cls, **garth_kwargs):
def factory(is_cn=False, **_):
instance = cls()
instance.garth = StubGarth(**garth_kwargs)
cls.last = instance
return instance
return lambda: factory
@pytest.fixture(autouse=True)
def _fast_polling(monkeypatch):
"""Keep the rendezvous poll short so tests stay quick."""
monkeypatch.setattr(garmin_auth, "POLL_INTERVAL_SECONDS", 0.05)
monkeypatch.setattr(garmin_auth, "CODE_WAIT_SECONDS", 5)
class TestMfaFlow:
def test_login_parks_waiting_for_a_code(self, db, user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
assert wait_status(sid, "awaiting_code") == "awaiting_code"
def test_submitting_the_code_completes_the_login(self, db, user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
wait_status(sid, "awaiting_code")
ok, _ = garmin_auth.submit_code(sid, user["id"], "123456")
assert ok is True
assert wait_status(sid, "done", "failed") == "done"
def test_token_is_saved_on_success(self, db, user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
wait_status(sid, "awaiting_code")
garmin_auth.submit_code(sid, user["id"], "123456")
wait_status(sid, "done", "failed")
assert garmin_svc.has_token(user["id"]) is True
assert garmin_svc.load_token(user["id"]) == "token-blob"
def test_the_code_reaches_garth(self, db, user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
wait_status(sid, "awaiting_code")
garmin_auth.submit_code(sid, user["id"], "123456")
wait_status(sid, "done", "failed")
assert StubGarmin.last.garth.seen_code == "123456"
def test_account_without_mfa_completes_without_a_code(self, db, user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw",
import_garmin=StubGarmin.make(needs_mfa=False),
)
assert wait_status(sid, "done", "failed") == "done"
assert garmin_svc.has_token(user["id"]) is True
def test_wrong_code_fails_with_a_reason(self, db, user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
wait_status(sid, "awaiting_code")
garmin_auth.submit_code(sid, user["id"], "000000")
assert wait_status(sid, "done", "failed") == "failed"
assert "验证码错误" in garmin_auth.get_session(sid)["error"]
assert garmin_svc.has_token(user["id"]) is False
def test_bad_password_fails_before_any_code(self, db, user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "wrong",
import_garmin=StubGarmin.make(fail_login=True),
)
assert wait_status(sid, "failed") == "failed"
assert "401" in garmin_auth.get_session(sid)["error"]
def test_timeout_when_no_code_arrives(self, db, user, monkeypatch):
monkeypatch.setattr(garmin_auth, "CODE_WAIT_SECONDS", 0.2)
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
assert wait_status(sid, "failed") == "failed"
assert "超时" in garmin_auth.get_session(sid)["error"]
class TestRendezvousIsNotInMemory:
"""The handoff must survive the code arriving on a different worker, so it
goes through the database rather than process memory."""
def test_code_written_directly_to_the_row_is_picked_up(self, db, user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
wait_status(sid, "awaiting_code")
# Exactly what another worker's request would do: write the row.
db.execute(
"UPDATE garmin_mfa_sessions SET code = ? WHERE id = ?", ["123456", sid]
)
assert wait_status(sid, "done", "failed") == "done"
def test_cancelling_releases_the_parked_thread(self, db, user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
wait_status(sid, "awaiting_code")
garmin_auth.cancel(sid, user["id"])
# The row is gone, so the waiting thread must stop rather than spin
# until its timeout.
assert wait_for(lambda: garmin_auth.get_session(sid) is None)
class TestSessionIsolation:
def test_another_users_session_is_not_readable(self, db, user, client):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
other = client.post(
"/api/auth/register",
json={"email": "o@example.com", "garminEmail": "og@example.com",
"garminPassword": "pw123456"},
).get_json()
assert garmin_auth.get_session(sid, other["id"]) is None
def test_another_user_cannot_submit_a_code(self, db, user, client):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
wait_status(sid, "awaiting_code")
other = client.post(
"/api/auth/register",
json={"email": "o2@example.com", "garminEmail": "og2@example.com",
"garminPassword": "pw123456"},
).get_json()
ok, _ = garmin_auth.submit_code(sid, other["id"], "123456")
assert ok is False
def test_unknown_session_is_refused(self, db, user):
ok, msg = garmin_auth.submit_code("no-such-session", user["id"], "123456")
assert ok is False
assert "不存在" in msg
class TestPasswordHandling:
def test_password_is_never_written_to_the_session_row(self, db, user):
sid = garmin_auth.start_login(
user["id"], "hunter2@example.com", "SuperSecret123",
import_garmin=StubGarmin.make(),
)
wait_status(sid, "awaiting_code")
row = garmin_auth.get_session(sid)
assert "SuperSecret123" not in str(dict(row))
class TestEndpoints:
def test_all_require_auth(self, client):
assert client.post("/api/garmin/login", json={}).status_code == 401
assert client.get("/api/garmin/login-status?session=x").status_code == 401
assert client.post("/api/garmin/mfa", json={}).status_code == 401
def test_login_requires_a_password(self, client, auth):
r = client.post("/api/garmin/login", headers=auth, json={})
assert r.status_code == 400
def test_login_returns_a_session(self, client, auth, monkeypatch):
monkeypatch.setattr(
garmin_auth, "start_login", lambda *a, **k: "session-123"
)
r = client.post(
"/api/garmin/login", headers=auth, json={"garminPassword": "pw"}
)
assert r.status_code == 202
assert r.get_json()["session"] == "session-123"
def test_status_of_unknown_session_is_404(self, client, auth):
assert client.get(
"/api/garmin/login-status?session=nope", headers=auth
).status_code == 404
def test_mfa_requires_both_fields(self, client, auth):
assert client.post(
"/api/garmin/mfa", headers=auth, json={"session": "x"}
).status_code == 400
def test_full_flow_through_http(self, client, auth, user, db, monkeypatch):
monkeypatch.setattr(
garmin_svc, "_import_garmin", StubGarmin.make()
)
r = client.post(
"/api/garmin/login", headers=auth, json={"garminPassword": "pw"}
)
sid = r.get_json()["session"]
assert wait_status(sid, "awaiting_code") == "awaiting_code"
assert client.get(
f"/api/garmin/login-status?session={sid}", headers=auth
).get_json()["status"] == "awaiting_code"
r = client.post(
"/api/garmin/mfa", headers=auth, json={"session": sid, "code": "123456"}
)
assert r.status_code == 200
assert wait_status(sid, "done", "failed") == "done"
assert client.get("/api/garmin/auth-status", headers=auth).get_json()[
"hasToken"] is True

View File

@@ -165,3 +165,37 @@
margin: 0;
font-family: Menlo, Monaco, Consolas, 'Courier New', monospace;
}
.mfa-card {
border-color: #667eea;
background: #f7f8ff;
}
.code-input {
font-size: 1.5rem;
letter-spacing: 0.35em;
text-align: center;
font-family: Menlo, Monaco, Consolas, monospace;
}
.mfa-buttons {
display: flex;
gap: 0.75rem;
align-items: center;
}
.btn-plain {
background: none;
border: 1px solid #ddd;
color: #777;
padding: 0.6rem 1.1rem;
border-radius: 6px;
cursor: pointer;
font-family: inherit;
font-size: 0.95rem;
}
.btn-plain:hover {
border-color: #bbb;
color: #555;
}

View File

@@ -1,58 +1,152 @@
import React, { useCallback, useEffect, useState } from 'react';
import { apiClient, errorMessage, SyncStatus } from '../services/api';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
apiClient, errorMessage, GarminLoginStatus, SyncStatus,
} from '../services/api';
import './DataSync.css';
const POLL_MS = 2000;
function DataSync() {
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
const [garminPassword, setGarminPassword] = useState('');
const [hasToken, setHasToken] = useState<boolean | null>(null);
const [mfaNeeded, setMfaNeeded] = useState(false);
// Garmin login (only needed until a token is stored)
const [password, setPassword] = useState('');
const [session, setSession] = useState<string | null>(null);
const [loginState, setLoginState] = useState<GarminLoginStatus | null>(null);
const [code, setCode] = useState('');
const [codeSubmitted, setCodeSubmitted] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const pollRef = useRef<number | null>(null);
const stopPolling = useCallback(() => {
if (pollRef.current) {
window.clearInterval(pollRef.current);
pollRef.current = null;
}
}, []);
const loadSyncStatus = useCallback(async () => {
try {
setSyncStatus(await apiClient.getGarminSyncStatus());
} catch (err) {
// A failed status poll should not blank the page; the sync button
// remains usable and will surface its own errors.
// A failed status poll should not blank the page.
console.error('Failed to load sync status:', err);
}
}, []);
useEffect(() => {
loadSyncStatus();
apiClient
.getGarminAuthStatus()
.then(setHasToken)
.catch(() => setHasToken(false));
}, [loadSyncStatus]);
apiClient.getGarminAuthStatus().then(setHasToken).catch(() => setHasToken(false));
return stopPolling;
}, [loadSyncStatus, stopPolling]);
const handleSync = async (e: React.FormEvent) => {
// --- Garmin login -------------------------------------------------------
const startLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setMessage('');
if (!hasToken && !garminPassword) {
if (!password) {
setError('请输入 Garmin 密码');
return;
}
setLoading(true);
setMfaNeeded(false);
try {
const result = await apiClient.syncGarminData(garminPassword || undefined);
const sid = await apiClient.startGarminLogin(password);
// The password is only ever needed for this one request.
setPassword('');
setSession(sid);
setLoginState('starting');
setCodeSubmitted(false);
beginPolling(sid);
} catch (err: any) {
setError(errorMessage(err, '登录失败'));
} finally {
setLoading(false);
}
};
const beginPolling = (sid: string) => {
stopPolling();
pollRef.current = window.setInterval(async () => {
try {
const { status, error: loginError } = await apiClient.getGarminLoginStatus(sid);
setLoginState(status);
if (status === 'done') {
stopPolling();
setSession(null);
setHasToken(true);
setMessage('Garmin 登录成功,之后同步不再需要密码或验证码。');
} else if (status === 'failed') {
stopPolling();
setSession(null);
setCodeSubmitted(false);
setError(loginError || '登录失败,请重试');
}
} catch (err: any) {
stopPolling();
setSession(null);
setError(errorMessage(err, '登录状态查询失败'));
}
}, POLL_MS);
};
const submitCode = async (e: React.FormEvent) => {
e.preventDefault();
if (!session || !code.trim()) return;
setError('');
setLoading(true);
try {
const { ok, message: msg } = await apiClient.submitGarminMfa(session, code.trim());
if (ok) {
setCodeSubmitted(true);
setCode('');
} else {
setError(msg);
}
} catch (err: any) {
setError(errorMessage(err, '验证码提交失败'));
} finally {
setLoading(false);
}
};
const cancelLogin = async () => {
if (session) {
try {
await apiClient.cancelGarminLogin(session);
} catch {
// Cancelling is best-effort; the session expires on its own anyway.
}
}
stopPolling();
setSession(null);
setLoginState(null);
setCode('');
setCodeSubmitted(false);
};
// --- sync ---------------------------------------------------------------
const handleSync = async () => {
setError('');
setMessage('');
setLoading(true);
try {
const result = await apiClient.syncGarminData();
if (result.status === 'success') {
const acts = result.activitiesSynced ?? 0;
setMessage(`同步完成:${result.recordsSynced} 天数据、${acts} 条运动记录`);
} else {
setError(result.message);
if (result.mfaRequired) setMfaNeeded(true);
if (result.mfaRequired) setHasToken(false);
}
// Clear the password as soon as the request is done — it is only ever
// held in memory for the duration of the call.
setGarminPassword('');
loadSyncStatus();
} catch (err: any) {
setError(errorMessage(err, '同步失败'));
@@ -68,6 +162,7 @@ function DataSync() {
};
const busy = loading || syncStatus?.status === 'syncing';
const awaitingCode = loginState === 'awaiting_code' || codeSubmitted;
return (
<div className="page">
@@ -109,49 +204,91 @@ function DataSync() {
)}
</section>
<form className="sync-actions" onSubmit={handleSync}>
{hasToken === false && (
{/* Step 1 — link the Garmin account, once. */}
{hasToken === false && !session && (
<form className="sync-actions" onSubmit={startLogin}>
<div className="form-group">
<label htmlFor="garmin-password">Garmin </label>
<input
id="garmin-password"
type="password"
value={garminPassword}
onChange={(e) => setGarminPassword(e.target.value)}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
autoComplete="current-password"
disabled={busy}
disabled={loading}
/>
<p className="field-hint">
Garmin
Garmin
</p>
</div>
)}
{hasToken === true && (
<p className="field-hint">
Garmin
</p>
)}
<button type="submit" className="btn btn-primary btn-large" disabled={busy}>
{busy ? '正在同步…' : '立即同步'}
<button type="submit" className="btn btn-primary btn-large" disabled={loading}>
{loading ? '正在连接…' : '绑定 Garmin 账号'}
</button>
</form>
)}
{mfaNeeded && (
<div className="info-box">
<h4></h4>
<p style={{ margin: '0 0 0.75rem', color: '#555', lineHeight: 1.8 }}>
NAS
{/* Step 2 — the two-factor code. */}
{session && (
<section className="status-card mfa-card">
{loginState === 'starting' && !codeSubmitted && (
<p className="placeholder"> Garmin</p>
)}
{awaitingCode && (
<form onSubmit={submitCode}>
<h3></h3>
<p className="field-hint" style={{ marginBottom: '1rem' }}>
Garmin 6
</p>
<pre className="cmd">
{`ssh -p 2222 ericwyuan@192.168.50.64
cd ~/apps/garmin-health-lab/backend
.venv/bin/python garmin_login.py`}
</pre>
<div className="form-group">
<input
id="mfa-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={10}
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="6 位数字"
className="code-input"
disabled={loading || codeSubmitted}
autoFocus
/>
</div>
<div className="mfa-buttons">
<button
type="submit"
className="btn btn-primary"
disabled={loading || codeSubmitted || !code.trim()}
>
{codeSubmitted ? '正在验证…' : '提交验证码'}
</button>
<button type="button" className="btn btn-plain" onClick={cancelLogin}>
</button>
</div>
</form>
)}
{loginState === 'finishing' && (
<p className="placeholder"></p>
)}
</section>
)}
{/* Step 3 — sync, once linked. */}
{hasToken === true && (
<div className="sync-actions">
<p className="field-hint"> Garmin </p>
<button
onClick={handleSync}
className="btn btn-primary btn-large"
disabled={busy}
>
{busy ? '正在同步…' : '立即同步'}
</button>
</div>
)}
@@ -164,7 +301,7 @@ cd ~/apps/garmin-health-lab/backend
<li> 7 </li>
<li></li>
<li></li>
<li> <code>garminconnect</code> </li>
<li>Garmin </li>
</ul>
</section>
</div>

View File

@@ -64,6 +64,13 @@ export interface AiRecommendations {
};
}
export type GarminLoginStatus =
| 'starting'
| 'awaiting_code'
| 'finishing'
| 'done'
| 'failed';
export interface ModelInfo {
id: string;
model: string;
@@ -192,6 +199,39 @@ class ApiClient {
return data.hasToken;
}
/**
* Start an interactive Garmin login. Returns a session id; the login runs
* in the background and parks if Garmin asks for a two-factor code.
*/
async startGarminLogin(garminPassword: string, garminEmail?: string) {
const { data } = await this.client.post<{ session: string }>('/garmin/login', {
garminPassword,
...(garminEmail ? { garminEmail } : {}),
});
return data.session;
}
async getGarminLoginStatus(session: string) {
const { data } = await this.client.get<{
session: string;
status: GarminLoginStatus;
error: string | null;
}>('/garmin/login-status', { params: { session } });
return data;
}
async submitGarminMfa(session: string, code: string) {
const { data } = await this.client.post<{ ok: boolean; message: string }>(
'/garmin/mfa',
{ session, code }
);
return data;
}
async cancelGarminLogin(session: string) {
await this.client.delete('/garmin/login', { params: { session } });
}
async getGarminSyncStatus() {
const { data } = await this.client.get<SyncStatus>('/garmin/status');
return data;