From af0604bce4eeb8466edd6866b62ec5e85b5300c1 Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Sun, 23 Aug 2026 19:57:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(garmin):=20=E4=B8=A4=E6=AD=A5=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E8=B4=A6=E5=8F=B7=E5=90=8C=E6=AD=A5=E6=8A=A5=20EOFErr?= =?UTF-8?q?or=EF=BC=8C=E6=94=B9=E7=94=A8=E4=BB=A4=E7=89=8C=E7=99=BB?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象:网页触发同步报 "EOF when reading a line"。 原因:garth 的默认 MFA 提示是 input(),向 stdin 索取验证码。 gunicorn worker 没有 stdin,于是抛出 EOFError——错误信息本身 完全没提到 MFA,看不出该做什么。 方案:把"输验证码"和"日常同步"拆开。 - 新增 garmin_tokens 表存 garth 令牌(Client.dumps/loads 序列化) - garmin_login.py:在终端里跑一次,可正常输入验证码, 成功后令牌存库 - _connect() 优先加载令牌并 refresh_oauth2(),命中则完全跳过登录, 既不需要密码也不需要验证码(令牌有效期约一年) - 无令牌且密码登录撞上 MFA 时,抛 MFARequired 并给出具体该执行 哪条命令,而不是把 EOFError 原样抛给用户 接口: - GET /api/garmin/auth-status 返回是否已有令牌 - /api/garmin/sync 在已有令牌时不再强制要求密码 前端: - 有令牌时隐藏密码输入框,提示无需密码 - 同步返回 mfaRequired 时,展示需要在 NAS 上执行的具体命令 - 同步请求超时放宽到 180s(一周的天数 + 运动是多次上游调用) - 成功消息补上运动记录条数 tests (test_garmin_sync.py 新增 12 条,共 35): - 令牌存取、覆盖不累积、按用户隔离 - 有令牌时绝不调用 login() - MFA 的 EOFError 转成带操作指引的 MFARequired - 普通 401 不会被误标成 mfaRequired - 无令牌且无密码时给出明确拒绝 NAS 真机: 252 passed Co-Authored-By: Claude Haiku 4.5 --- backend/db.py | 14 ++++ backend/garmin_login.py | 93 +++++++++++++++++++++ backend/routes/garmin.py | 13 ++- backend/services/garmin.py | 89 +++++++++++++++++--- backend/tests/test_garmin_sync.py | 130 +++++++++++++++++++++++++++++- client/src/pages/DataSync.css | 12 +++ client/src/pages/DataSync.tsx | 66 +++++++++++---- client/src/services/api.ts | 31 +++++-- 8 files changed, 410 insertions(+), 38 deletions(-) create mode 100644 backend/garmin_login.py diff --git a/backend/db.py b/backend/db.py index 3ffbdbe..c173dbb 100644 --- a/backend/db.py +++ b/backend/db.py @@ -84,6 +84,20 @@ CREATE TABLE IF NOT EXISTS sync_status ( FOREIGN KEY (user_id) REFERENCES users(id) ); +-- Garmin OAuth tokens, obtained once through an interactive login. +-- Garmin accounts with two-factor auth cannot be logged into unattended: the +-- library asks for an MFA code on stdin, which a gunicorn worker does not +-- have. Storing the resulting tokens lets every later sync skip the login +-- entirely (they stay valid for roughly a year). +CREATE TABLE IF NOT EXISTS garmin_tokens ( + user_id VARCHAR(64) PRIMARY KEY, + token TEXT NOT NULL, + garmin_email VARCHAR(255), + 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 new file mode 100644 index 0000000..d798b3a --- /dev/null +++ b/backend/garmin_login.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python +""" +One-time interactive Garmin login. + +Garmin accounts with two-factor auth cannot be logged into by the web service: +the library asks for the MFA code on stdin, and a gunicorn worker has none — +the attempt fails with "EOFError: EOF when reading a line". + +This script does that login in a terminal, where a code can actually be typed, +and stores the resulting OAuth tokens in the database. Every later sync loads +those tokens and skips the login entirely. They stay valid for roughly a year; +re-run this when a sync starts reporting an expired session. + +Usage (on the NAS): + cd ~/apps/garmin-health-lab/backend + .venv/bin/python garmin_login.py + +Add --email to pick an account when more than one is registered. +""" +import argparse +import getpass +import sys + +import db +from services import garmin as garmin_svc + + +def pick_user(email=None): + if email: + row = db.query_one("SELECT id, email, garmin_email FROM users WHERE email = ?", + [email]) + if not row: + sys.exit(f"找不到账号: {email}") + return row + + rows = db.query_all("SELECT id, email, garmin_email FROM users ORDER BY created_at") + if not rows: + sys.exit("数据库里还没有账号,请先在网页上注册。") + if len(rows) == 1: + return rows[0] + + print("有多个账号,请选择:") + for i, r in enumerate(rows, 1): + print(f" {i}) {r['email']} (Garmin: {r['garmin_email']})") + choice = input("序号: ").strip() + try: + return rows[int(choice) - 1] + except (ValueError, IndexError): + sys.exit("选择无效") + + +def main(): + parser = argparse.ArgumentParser(description="一次性 Garmin 登录,保存令牌") + parser.add_argument("--email", help="要绑定的本站账号邮箱") + parser.add_argument("--garmin-email", help="Garmin 账号邮箱(默认用注册时填的)") + args = parser.parse_args() + + db.init_db() + user = pick_user(args.email) + garmin_email = args.garmin_email or user["garmin_email"] + + print(f"本站账号 : {user['email']}") + print(f"Garmin : {garmin_email}") + print() + + if garmin_svc.has_token(user["id"]): + if input("已存在登录令牌,要覆盖吗?[y/N] ").strip().lower() != "y": + return + + password = getpass.getpass("Garmin 密码: ") + if not password: + sys.exit("密码不能为空") + + Garmin = garmin_svc._import_garmin() + client = Garmin(email=garmin_email, password=password, is_cn=garmin_svc._is_cn()) + + 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() + except Exception as e: + sys.exit(f"\n登录失败: {type(e).__name__}: {e}") + + garmin_svc.save_token(user["id"], client.garth.dumps(), garmin_email) + + print(f"\n登录成功:{client.display_name}") + print("令牌已保存到数据库,之后网页上的同步不再需要密码或验证码。") + print("令牌大约一年后过期,届时重跑本脚本即可。") + + +if __name__ == "__main__": + main() diff --git a/backend/routes/garmin.py b/backend/routes/garmin.py index fe7afa4..453771d 100644 --- a/backend/routes/garmin.py +++ b/backend/routes/garmin.py @@ -22,9 +22,9 @@ def sync(): if user and user.get("garmin_email"): creds["garminEmail"] = user["garmin_email"] - # The stored Garmin password is only kept as a hash, so it cannot be - # recovered. A live sync requires the plaintext password in the body. - if not creds["garminPassword"]: + # With stored OAuth tokens no password is needed at all. Without them the + # plaintext password must come in the body, because only a hash is kept. + if not creds["garminPassword"] and not garmin_svc.has_token(g.user_id): return ( jsonify({ "status": "error", @@ -39,6 +39,13 @@ def sync(): return jsonify(result) +@bp.route("/auth-status", methods=["GET"]) +@require_auth +def auth_status(): + """Whether a stored token exists, so the UI knows to ask for a password.""" + return jsonify({"hasToken": garmin_svc.has_token(g.user_id)}) + + @bp.route("/status", methods=["GET"]) @require_auth def status(): diff --git a/backend/services/garmin.py b/backend/services/garmin.py index b5fbee5..06dfbe5 100644 --- a/backend/services/garmin.py +++ b/backend/services/garmin.py @@ -65,20 +65,84 @@ def get_sync_status(user_id): } -def _connect(creds): - """Log in to Garmin Connect. Separated so tests can substitute a client.""" +class MFARequired(RuntimeError): + """Raised when a password login needs a code this process cannot obtain.""" + + +def _is_cn(): + # Selects Garmin's China service, a separate backend with separate + # accounts. This project tracks an international account. + return (os.environ.get("GARMIN_IS_CN") or "").lower() in ("1", "true", "yes") + + +def _import_garmin(): try: from garminconnect import Garmin except ImportError: raise RuntimeError( "GARMIN_LIB_MISSING: 请先运行 `pip install garminconnect` 以启用同步" ) + return Garmin - # is_cn selects Garmin's China service, which is a different backend with - # separate accounts. This project tracks an international account. - is_cn = (os.environ.get("GARMIN_IS_CN") or "").lower() in ("1", "true", "yes") - client = Garmin(email=creds["garminEmail"], password=creds["garminPassword"], is_cn=is_cn) - client.login() + +def load_token(user_id): + row = query_one("SELECT token FROM garmin_tokens WHERE user_id = ?", [user_id]) + return row["token"] if row else None + + +def save_token(user_id, token, garmin_email=None): + cols = ["user_id", "token", "garmin_email", "updated_at"] + placeholders = ", ".join(["?"] * len(cols)) + if DB_TYPE == "mariadb": + updates = ", ".join(f"{c}=VALUES({c})" for c in cols if c != "user_id") + sql = (f"INSERT INTO garmin_tokens ({', '.join(cols)}) VALUES ({placeholders}) " + f"ON DUPLICATE KEY UPDATE {updates}") + else: + updates = ", ".join(f"{c}=excluded.{c}" for c in cols if c != "user_id") + sql = (f"INSERT INTO garmin_tokens ({', '.join(cols)}) VALUES ({placeholders}) " + f"ON CONFLICT(user_id) DO UPDATE SET {updates}") + execute(sql, [user_id, token, garmin_email, + datetime.datetime.utcnow().isoformat(timespec="seconds")]) + + +def has_token(user_id): + return load_token(user_id) is not None + + +def _connect(creds, user_id=None): + """Obtain a logged-in Garmin client. + + Prefers stored OAuth tokens: an account with two-factor auth cannot be + logged into from a web worker, because the library asks for the code on + stdin and there is none (the failure surfaces as + "EOFError: EOF when reading a line"). Tokens are minted once by + `garmin_login.py`, which runs in a terminal where a code can be typed. + """ + Garmin = _import_garmin() + client = Garmin(is_cn=_is_cn()) + + token = load_token(user_id) if user_id else None + if token: + client.garth.loads(token) + # Populates display_name/unit_system and proves the token still works. + client.garth.refresh_oauth2() + client.display_name = client.garth.profile["displayName"] + return client + + if not creds.get("garminPassword"): + raise RuntimeError("缺少 Garmin 密码,且未找到已保存的登录令牌") + + client.username = creds["garminEmail"] + client.password = creds["garminPassword"] + try: + client.login() + except EOFError as e: + # garth's default MFA prompt calls input(); under gunicorn stdin is + # closed, so it raises EOFError rather than anything descriptive. + raise MFARequired( + "该 Garmin 账号开启了两步验证,无法在服务端直接登录。" + "请在 NAS 上执行一次 `python garmin_login.py` 完成验证并保存令牌。" + ) from e return client @@ -194,12 +258,17 @@ def sync_data(user_id, creds, days=None, client=None): _set_sync_status(user_id, "syncing", now, records_synced=0) try: - client = client or _connect(creds) + client = client or _connect(creds, user_id) except Exception as e: message = str(e) _set_sync_status(user_id, "error", now, records_synced=0, last_error=message) - return {"status": "error", "recordsSynced": 0, "message": message, - "lastSyncTime": now} + return { + "status": "error", + "recordsSynced": 0, + "message": message, + "mfaRequired": isinstance(e, MFARequired), + "lastSyncTime": now, + } today = datetime.date.today() start_date = (today - datetime.timedelta(days=days - 1)).isoformat() diff --git a/backend/tests/test_garmin_sync.py b/backend/tests/test_garmin_sync.py index f3fe63b..ed2732a 100644 --- a/backend/tests/test_garmin_sync.py +++ b/backend/tests/test_garmin_sync.py @@ -218,7 +218,7 @@ class TestPartialAndTotalFailure: assert out["recordsSynced"] == 0 def test_login_failure_is_reported(self, db, user, monkeypatch): - def boom(_creds): + def boom(_creds, _uid=None): raise RuntimeError("401 Unauthorized") monkeypatch.setattr(garmin_svc, "_connect", boom) @@ -280,3 +280,131 @@ class TestEndpoint: r = client.get("/api/garmin/status", headers=auth) assert r.status_code == 200 assert r.get_json()["status"] == "idle" + + +class TestTokenStore: + """Tokens are what make unattended sync possible on an MFA-protected + account: the web worker has no stdin to type a code into.""" + + def test_absent_before_any_login(self, db, user): + assert garmin_svc.has_token(user["id"]) is False + + def test_saved_token_round_trips(self, db, user): + garmin_svc.save_token(user["id"], "token-blob", "g@example.com") + assert garmin_svc.has_token(user["id"]) is True + assert garmin_svc.load_token(user["id"]) == "token-blob" + + def test_re_login_replaces_rather_than_accumulates(self, db, user): + garmin_svc.save_token(user["id"], "first", "g@example.com") + garmin_svc.save_token(user["id"], "second", "g@example.com") + + rows = db.query_all( + "SELECT * FROM garmin_tokens WHERE user_id = ?", [user["id"]] + ) + assert len(rows) == 1 + assert garmin_svc.load_token(user["id"]) == "second" + + def test_tokens_are_per_user(self, db, user, client): + garmin_svc.save_token(user["id"], "mine", "g@example.com") + other = client.post( + "/api/auth/register", + json={"email": "o@example.com", "garminEmail": "og@example.com", + "garminPassword": "pw123456"}, + ).get_json() + assert garmin_svc.has_token(other["id"]) is False + + +class TestMfaHandling: + def test_eof_from_the_mfa_prompt_becomes_an_actionable_error( + self, db, user, monkeypatch + ): + """garth's default MFA prompt calls input(); with no stdin that raises + a bare EOFError, which says nothing about what to do about it.""" + class StubGarth: + def loads(self, s): pass + def refresh_oauth2(self): pass + + class StubGarmin: + def __init__(self, *a, **k): + self.garth = StubGarth() + self.username = None + self.password = None + + def login(self, *a, **k): + raise EOFError("EOF when reading a line") + + monkeypatch.setattr(garmin_svc, "_import_garmin", lambda: StubGarmin) + with pytest.raises(garmin_svc.MFARequired, match="两步验证"): + garmin_svc._connect(CREDS, user["id"]) + + def test_sync_flags_mfa_so_the_ui_can_explain(self, db, user, monkeypatch): + def boom(_creds, _uid=None): + raise garmin_svc.MFARequired("需要两步验证") + + monkeypatch.setattr(garmin_svc, "_connect", boom) + out = garmin_svc.sync_data(user["id"], CREDS, days=1) + + assert out["status"] == "error" + assert out["mfaRequired"] is True + + def test_ordinary_failures_are_not_flagged_as_mfa(self, db, user, monkeypatch): + def boom(_creds, _uid=None): + raise RuntimeError("401 Unauthorized") + + monkeypatch.setattr(garmin_svc, "_connect", boom) + assert garmin_svc.sync_data(user["id"], CREDS, days=1)["mfaRequired"] is False + + def test_stored_token_is_used_instead_of_logging_in(self, db, user, monkeypatch): + garmin_svc.save_token(user["id"], "saved-blob", "g@example.com") + loaded = {} + + class StubGarth: + profile = {"displayName": "Tester"} + + def loads(self, s): + loaded["blob"] = s + + def refresh_oauth2(self): + loaded["refreshed"] = True + + class StubGarmin: + def __init__(self, *a, **k): + self.garth = StubGarth() + + def login(self, *a, **k): + raise AssertionError("must not log in when a token exists") + + monkeypatch.setattr(garmin_svc, "_import_garmin", lambda: StubGarmin) + garmin_svc._connect({}, user["id"]) + + assert loaded["blob"] == "saved-blob" + assert loaded["refreshed"] is True + + def test_no_token_and_no_password_is_refused_clearly(self, db, user, monkeypatch): + class StubGarmin: + def __init__(self, *a, **k): + self.garth = None + + monkeypatch.setattr(garmin_svc, "_import_garmin", lambda: StubGarmin) + with pytest.raises(RuntimeError, match="缺少 Garmin 密码"): + garmin_svc._connect({}, user["id"]) + + +class TestAuthStatusEndpoint: + def test_requires_auth(self, client): + assert client.get("/api/garmin/auth-status").status_code == 401 + + def test_reports_false_then_true(self, client, auth, user, db): + assert client.get("/api/garmin/auth-status", headers=auth).get_json()[ + "hasToken"] is False + garmin_svc.save_token(user["id"], "blob", "g@example.com") + assert client.get("/api/garmin/auth-status", headers=auth).get_json()[ + "hasToken"] is True + + def test_sync_without_password_allowed_once_a_token_exists( + self, client, auth, user, db + ): + """The password field exists only because no token is stored yet.""" + garmin_svc.save_token(user["id"], "blob", "g@example.com") + r = client.post("/api/garmin/sync", headers=auth, json={}) + assert r.status_code != 400 diff --git a/client/src/pages/DataSync.css b/client/src/pages/DataSync.css index ce83b6e..d468392 100644 --- a/client/src/pages/DataSync.css +++ b/client/src/pages/DataSync.css @@ -153,3 +153,15 @@ max-width: 100%; } } + +.cmd { + background: #2d2d33; + color: #e6e6e6; + padding: 0.9rem 1rem; + border-radius: 6px; + font-size: 0.82rem; + line-height: 1.7; + overflow-x: auto; + margin: 0; + font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; +} diff --git a/client/src/pages/DataSync.tsx b/client/src/pages/DataSync.tsx index a20fd56..b577c7c 100644 --- a/client/src/pages/DataSync.tsx +++ b/client/src/pages/DataSync.tsx @@ -5,6 +5,8 @@ import './DataSync.css'; function DataSync() { const [syncStatus, setSyncStatus] = useState(null); const [garminPassword, setGarminPassword] = useState(''); + const [hasToken, setHasToken] = useState(null); + const [mfaNeeded, setMfaNeeded] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [message, setMessage] = useState(''); @@ -21,6 +23,10 @@ function DataSync() { useEffect(() => { loadSyncStatus(); + apiClient + .getGarminAuthStatus() + .then(setHasToken) + .catch(() => setHasToken(false)); }, [loadSyncStatus]); const handleSync = async (e: React.FormEvent) => { @@ -28,18 +34,21 @@ function DataSync() { setError(''); setMessage(''); - if (!garminPassword) { + if (!hasToken && !garminPassword) { setError('请输入 Garmin 密码'); return; } setLoading(true); + setMfaNeeded(false); try { - const result = await apiClient.syncGarminData(garminPassword); + const result = await apiClient.syncGarminData(garminPassword || undefined); if (result.status === 'success') { - setMessage(`同步完成,新增/更新 ${result.recordsSynced} 天数据`); + const acts = result.activitiesSynced ?? 0; + setMessage(`同步完成:${result.recordsSynced} 天数据、${acts} 条运动记录`); } else { setError(result.message); + if (result.mfaRequired) setMfaNeeded(true); } // Clear the password as soon as the request is done — it is only ever // held in memory for the duration of the call. @@ -101,28 +110,51 @@ function DataSync() {
-
- - setGarminPassword(e.target.value)} - placeholder="••••••••" - autoComplete="current-password" - disabled={busy} - /> + {hasToken === false && ( +
+ + setGarminPassword(e.target.value)} + placeholder="••••••••" + autoComplete="current-password" + disabled={busy} + /> +

+ 密码在库中只以哈希形式保存、无法还原,因此每次同步都需要重新输入。 + 它仅用于本次向 Garmin 登录,不会被再次存储。 +

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

- 密码在库中只以哈希形式保存、无法还原,因此每次同步都需要重新输入。 - 它仅用于本次向 Garmin 登录,不会被再次存储。 + 已保存 Garmin 登录令牌,同步无需再输入密码。

-
+ )}
+ {mfaNeeded && ( +
+

该账号开启了两步验证

+

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

+
+{`ssh -p 2222 ericwyuan@192.168.50.64
+cd ~/apps/garmin-health-lab/backend
+.venv/bin/python garmin_login.py`}
+            
+
+ )} + {error &&
{error}
} {message &&
{message}
} diff --git a/client/src/services/api.ts b/client/src/services/api.ts index 1fd36eb..b532241 100644 --- a/client/src/services/api.ts +++ b/client/src/services/api.ts @@ -32,7 +32,10 @@ export interface SyncStatus { export interface SyncResult { status: 'success' | 'error'; recordsSynced: number; + activitiesSynced?: number; message: string; + /** Set when the account has two-factor auth and no token is stored yet. */ + mfaRequired?: boolean; lastSyncTime: string; } @@ -164,17 +167,31 @@ class ApiClient { // --- garmin --- /** - * The backend stores only a hash of the Garmin password, so a live sync - * needs the plaintext password supplied here each time. + * With a stored OAuth token no password is needed. Without one, the + * plaintext password must be supplied because only a hash is kept — and an + * MFA-protected account cannot log in this way at all (see garmin_login.py). */ - async syncGarminData(garminPassword: string, garminEmail?: string) { - const { data } = await this.client.post('/garmin/sync', { - garminPassword, - ...(garminEmail ? { garminEmail } : {}), - }); + async syncGarminData(garminPassword?: string, garminEmail?: string) { + const { data } = await this.client.post( + '/garmin/sync', + { + ...(garminPassword ? { garminPassword } : {}), + ...(garminEmail ? { garminEmail } : {}), + }, + // Pulling a week of days plus activities is many upstream calls. + { timeout: 180_000 } + ); return data; } + /** Whether a stored Garmin token exists (then sync needs no password). */ + async getGarminAuthStatus() { + const { data } = await this.client.get<{ hasToken: boolean }>( + '/garmin/auth-status' + ); + return data.hasToken; + } + async getGarminSyncStatus() { const { data } = await this.client.get('/garmin/status'); return data;