From bb774c332b6eff2a5013c97810acf6b98cdefd23 Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Sun, 23 Aug 2026 20:11:45 +0800 Subject: [PATCH] =?UTF-8?q?[=E9=98=B6=E6=AE=B55.2]=20=E4=B8=A4=E6=AD=A5?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E6=94=B9=E5=88=B0=E7=BD=91=E9=A1=B5=E7=AB=AF?= =?UTF-8?q?=E5=AE=8C=E6=88=90=EF=BC=8C=E6=89=8B=E6=9C=BA=E4=B8=8A=E5=8D=B3?= =?UTF-8?q?=E5=8F=AF=E7=BB=91=E5=AE=9A=20Garmin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:命令行方案要求用户在电脑前开交互式终端,实际不可行。 改为在网页里完成 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 --- backend/db.py | 17 ++ backend/garmin_login.py | 24 ++- backend/routes/garmin.py | 61 +++++++ backend/services/garmin_auth.py | 160 ++++++++++++++++++ backend/tests/test_garmin_mfa.py | 282 +++++++++++++++++++++++++++++++ client/src/pages/DataSync.css | 34 ++++ client/src/pages/DataSync.tsx | 237 ++++++++++++++++++++------ client/src/services/api.ts | 40 +++++ 8 files changed, 801 insertions(+), 54 deletions(-) create mode 100644 backend/services/garmin_auth.py create mode 100644 backend/tests/test_garmin_mfa.py diff --git a/backend/db.py b/backend/db.py index c173dbb..dd1a898 100644 --- a/backend/db.py +++ b/backend/db.py @@ -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. diff --git a/backend/garmin_login.py b/backend/garmin_login.py index d798b3a..52488ea 100644 --- a/backend/garmin_login.py +++ b/backend/garmin_login.py @@ -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}") diff --git a/backend/routes/garmin.py b/backend/routes/garmin.py index 453771d..8b4b8c1 100644 --- a/backend/routes/garmin.py +++ b/backend/routes/garmin.py @@ -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(): diff --git a/backend/services/garmin_auth.py b/backend/services/garmin_auth.py new file mode 100644 index 0000000..bf6f297 --- /dev/null +++ b/backend/services/garmin_auth.py @@ -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], + ) diff --git a/backend/tests/test_garmin_mfa.py b/backend/tests/test_garmin_mfa.py new file mode 100644 index 0000000..437d505 --- /dev/null +++ b/backend/tests/test_garmin_mfa.py @@ -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 diff --git a/client/src/pages/DataSync.css b/client/src/pages/DataSync.css index d468392..682e8cc 100644 --- a/client/src/pages/DataSync.css +++ b/client/src/pages/DataSync.css @@ -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; +} diff --git a/client/src/pages/DataSync.tsx b/client/src/pages/DataSync.tsx index b577c7c..cc73d1b 100644 --- a/client/src/pages/DataSync.tsx +++ b/client/src/pages/DataSync.tsx @@ -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(null); - const [garminPassword, setGarminPassword] = useState(''); const [hasToken, setHasToken] = useState(null); - const [mfaNeeded, setMfaNeeded] = useState(false); + + // Garmin login (only needed until a token is stored) + const [password, setPassword] = useState(''); + const [session, setSession] = useState(null); + const [loginState, setLoginState] = useState(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(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 (
@@ -109,49 +204,91 @@ function DataSync() { )} -
- {hasToken === false && ( + {/* Step 1 — link the Garmin account, once. */} + {hasToken === false && !session && ( +
setGarminPassword(e.target.value)} + value={password} + onChange={(e) => setPassword(e.target.value)} placeholder="••••••••" autoComplete="current-password" - disabled={busy} + disabled={loading} />

- 密码在库中只以哈希形式保存、无法还原,因此每次同步都需要重新输入。 - 它仅用于本次向 Garmin 登录,不会被再次存储。 + 只需绑定一次。登录成功后保存的是 Garmin 授权令牌(有效期约一年), + 密码不会被存储。若账号开启了两步验证,下一步会让你填验证码。

- )} + +
+ )} - {hasToken === true && ( -

- 已保存 Garmin 登录令牌,同步无需再输入密码。 -

- )} + {/* Step 2 — the two-factor code. */} + {session && ( +
+ {loginState === 'starting' && !codeSubmitted && ( +

正在连接 Garmin…

+ )} - - + {awaitingCode && ( +
+

输入验证码

+

+ Garmin 已向你的手机或邮箱发送了 6 位验证码,请在下方填写。 +

+
+ setCode(e.target.value)} + placeholder="6 位数字" + className="code-input" + disabled={loading || codeSubmitted} + autoFocus + /> +
+
+ + +
+
+ )} - {mfaNeeded && ( -
-

该账号开启了两步验证

-

- 网页端无法接收验证码。请在 NAS 上执行一次下面的命令,按提示输入验证码, - 令牌保存后本页的同步就不再需要密码或验证码(有效期约一年): -

-
-{`ssh -p 2222 ericwyuan@192.168.50.64
-cd ~/apps/garmin-health-lab/backend
-.venv/bin/python garmin_login.py`}
-            
+ {loginState === 'finishing' && ( +

验证通过,正在完成登录…

+ )} +
+ )} + + {/* Step 3 — sync, once linked. */} + {hasToken === true && ( +
+

已绑定 Garmin 账号,同步无需密码。

+
)} @@ -164,7 +301,7 @@ cd ~/apps/garmin-health-lab/backend
  • 每次同步获取最近 7 天的每日汇总与运动记录
  • 同一天重复同步会更新原有记录,不会产生重复数据
  • 数据保存在本机数据库,不经过第三方服务
  • -
  • 需要先安装 garminconnect 库才能执行真实同步
  • +
  • Garmin 授权令牌约一年过期,届时重新绑定一次即可
  • diff --git a/client/src/services/api.ts b/client/src/services/api.ts index b532241..e5a827f 100644 --- a/client/src/services/api.ts +++ b/client/src/services/api.ts @@ -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('/garmin/status'); return data;