[阶段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:
@@ -98,6 +98,23 @@ CREATE TABLE IF NOT EXISTS garmin_tokens (
|
|||||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
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
|
-- 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
|
-- 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.
|
-- result is stored and reused until the underlying data changes.
|
||||||
|
|||||||
@@ -74,11 +74,27 @@ def main():
|
|||||||
Garmin = garmin_svc._import_garmin()
|
Garmin = garmin_svc._import_garmin()
|
||||||
client = Garmin(email=garmin_email, password=password, is_cn=garmin_svc._is_cn())
|
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:
|
try:
|
||||||
# No tokenstore: this is the interactive path that mints the tokens.
|
# Call garth directly rather than Garmin.login(): only this path lets
|
||||||
# garth's default MFA prompt reads stdin, which works here.
|
# the MFA prompt be replaced. The two lines afterwards are what
|
||||||
client.login()
|
# 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:
|
except Exception as e:
|
||||||
sys.exit(f"\n登录失败: {type(e).__name__}: {e}")
|
sys.exit(f"\n登录失败: {type(e).__name__}: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from flask import Blueprint, request, g, jsonify
|
|||||||
from auth import require_auth
|
from auth import require_auth
|
||||||
from db import query_one
|
from db import query_one
|
||||||
from services import garmin as garmin_svc
|
from services import garmin as garmin_svc
|
||||||
|
from services import garmin_auth
|
||||||
|
|
||||||
bp = Blueprint("garmin", __name__)
|
bp = Blueprint("garmin", __name__)
|
||||||
|
|
||||||
@@ -46,6 +47,66 @@ def auth_status():
|
|||||||
return jsonify({"hasToken": garmin_svc.has_token(g.user_id)})
|
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"])
|
@bp.route("/status", methods=["GET"])
|
||||||
@require_auth
|
@require_auth
|
||||||
def status():
|
def status():
|
||||||
|
|||||||
160
backend/services/garmin_auth.py
Normal file
160
backend/services/garmin_auth.py
Normal 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],
|
||||||
|
)
|
||||||
282
backend/tests/test_garmin_mfa.py
Normal file
282
backend/tests/test_garmin_mfa.py
Normal 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
|
||||||
@@ -165,3 +165,37 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: Menlo, Monaco, Consolas, 'Courier New', monospace;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,58 +1,152 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { apiClient, errorMessage, SyncStatus } from '../services/api';
|
import {
|
||||||
|
apiClient, errorMessage, GarminLoginStatus, SyncStatus,
|
||||||
|
} from '../services/api';
|
||||||
import './DataSync.css';
|
import './DataSync.css';
|
||||||
|
|
||||||
|
const POLL_MS = 2000;
|
||||||
|
|
||||||
function DataSync() {
|
function DataSync() {
|
||||||
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
|
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
|
||||||
const [garminPassword, setGarminPassword] = useState('');
|
|
||||||
const [hasToken, setHasToken] = useState<boolean | null>(null);
|
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 [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [message, setMessage] = 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 () => {
|
const loadSyncStatus = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setSyncStatus(await apiClient.getGarminSyncStatus());
|
setSyncStatus(await apiClient.getGarminSyncStatus());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A failed status poll should not blank the page; the sync button
|
// A failed status poll should not blank the page.
|
||||||
// remains usable and will surface its own errors.
|
|
||||||
console.error('Failed to load sync status:', err);
|
console.error('Failed to load sync status:', err);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSyncStatus();
|
loadSyncStatus();
|
||||||
apiClient
|
apiClient.getGarminAuthStatus().then(setHasToken).catch(() => setHasToken(false));
|
||||||
.getGarminAuthStatus()
|
return stopPolling;
|
||||||
.then(setHasToken)
|
}, [loadSyncStatus, stopPolling]);
|
||||||
.catch(() => setHasToken(false));
|
|
||||||
}, [loadSyncStatus]);
|
|
||||||
|
|
||||||
const handleSync = async (e: React.FormEvent) => {
|
// --- Garmin login -------------------------------------------------------
|
||||||
|
const startLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
setMessage('');
|
setMessage('');
|
||||||
|
if (!password) {
|
||||||
if (!hasToken && !garminPassword) {
|
|
||||||
setError('请输入 Garmin 密码');
|
setError('请输入 Garmin 密码');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMfaNeeded(false);
|
|
||||||
try {
|
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') {
|
if (result.status === 'success') {
|
||||||
const acts = result.activitiesSynced ?? 0;
|
const acts = result.activitiesSynced ?? 0;
|
||||||
setMessage(`同步完成:${result.recordsSynced} 天数据、${acts} 条运动记录`);
|
setMessage(`同步完成:${result.recordsSynced} 天数据、${acts} 条运动记录`);
|
||||||
} else {
|
} else {
|
||||||
setError(result.message);
|
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();
|
loadSyncStatus();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(errorMessage(err, '同步失败'));
|
setError(errorMessage(err, '同步失败'));
|
||||||
@@ -68,6 +162,7 @@ function DataSync() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const busy = loading || syncStatus?.status === 'syncing';
|
const busy = loading || syncStatus?.status === 'syncing';
|
||||||
|
const awaitingCode = loginState === 'awaiting_code' || codeSubmitted;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
@@ -109,49 +204,91 @@ function DataSync() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<form className="sync-actions" onSubmit={handleSync}>
|
{/* Step 1 — link the Garmin account, once. */}
|
||||||
{hasToken === false && (
|
{hasToken === false && !session && (
|
||||||
|
<form className="sync-actions" onSubmit={startLogin}>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="garmin-password">Garmin 密码</label>
|
<label htmlFor="garmin-password">Garmin 密码</label>
|
||||||
<input
|
<input
|
||||||
id="garmin-password"
|
id="garmin-password"
|
||||||
type="password"
|
type="password"
|
||||||
value={garminPassword}
|
value={password}
|
||||||
onChange={(e) => setGarminPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
disabled={busy}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
<p className="field-hint">
|
<p className="field-hint">
|
||||||
密码在库中只以哈希形式保存、无法还原,因此每次同步都需要重新输入。
|
只需绑定一次。登录成功后保存的是 Garmin 授权令牌(有效期约一年),
|
||||||
它仅用于本次向 Garmin 登录,不会被再次存储。
|
密码不会被存储。若账号开启了两步验证,下一步会让你填验证码。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<button type="submit" className="btn btn-primary btn-large" disabled={loading}>
|
||||||
|
{loading ? '正在连接…' : '绑定 Garmin 账号'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasToken === true && (
|
{/* Step 2 — the two-factor code. */}
|
||||||
<p className="field-hint">
|
{session && (
|
||||||
已保存 Garmin 登录令牌,同步无需再输入密码。
|
<section className="status-card mfa-card">
|
||||||
</p>
|
{loginState === 'starting' && !codeSubmitted && (
|
||||||
)}
|
<p className="placeholder">正在连接 Garmin…</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<button type="submit" className="btn btn-primary btn-large" disabled={busy}>
|
{awaitingCode && (
|
||||||
{busy ? '正在同步…' : '立即同步'}
|
<form onSubmit={submitCode}>
|
||||||
</button>
|
<h3>输入验证码</h3>
|
||||||
</form>
|
<p className="field-hint" style={{ marginBottom: '1rem' }}>
|
||||||
|
Garmin 已向你的手机或邮箱发送了 6 位验证码,请在下方填写。
|
||||||
|
</p>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
{mfaNeeded && (
|
{loginState === 'finishing' && (
|
||||||
<div className="info-box">
|
<p className="placeholder">验证通过,正在完成登录…</p>
|
||||||
<h4>该账号开启了两步验证</h4>
|
)}
|
||||||
<p style={{ margin: '0 0 0.75rem', color: '#555', lineHeight: 1.8 }}>
|
</section>
|
||||||
网页端无法接收验证码。请在 NAS 上执行一次下面的命令,按提示输入验证码,
|
)}
|
||||||
令牌保存后本页的同步就不再需要密码或验证码(有效期约一年):
|
|
||||||
</p>
|
{/* Step 3 — sync, once linked. */}
|
||||||
<pre className="cmd">
|
{hasToken === true && (
|
||||||
{`ssh -p 2222 ericwyuan@192.168.50.64
|
<div className="sync-actions">
|
||||||
cd ~/apps/garmin-health-lab/backend
|
<p className="field-hint">已绑定 Garmin 账号,同步无需密码。</p>
|
||||||
.venv/bin/python garmin_login.py`}
|
<button
|
||||||
</pre>
|
onClick={handleSync}
|
||||||
|
className="btn btn-primary btn-large"
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy ? '正在同步…' : '立即同步'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -164,7 +301,7 @@ cd ~/apps/garmin-health-lab/backend
|
|||||||
<li>每次同步获取最近 7 天的每日汇总与运动记录</li>
|
<li>每次同步获取最近 7 天的每日汇总与运动记录</li>
|
||||||
<li>同一天重复同步会更新原有记录,不会产生重复数据</li>
|
<li>同一天重复同步会更新原有记录,不会产生重复数据</li>
|
||||||
<li>数据保存在本机数据库,不经过第三方服务</li>
|
<li>数据保存在本机数据库,不经过第三方服务</li>
|
||||||
<li>需要先安装 <code>garminconnect</code> 库才能执行真实同步</li>
|
<li>Garmin 授权令牌约一年过期,届时重新绑定一次即可</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,6 +64,13 @@ export interface AiRecommendations {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GarminLoginStatus =
|
||||||
|
| 'starting'
|
||||||
|
| 'awaiting_code'
|
||||||
|
| 'finishing'
|
||||||
|
| 'done'
|
||||||
|
| 'failed';
|
||||||
|
|
||||||
export interface ModelInfo {
|
export interface ModelInfo {
|
||||||
id: string;
|
id: string;
|
||||||
model: string;
|
model: string;
|
||||||
@@ -192,6 +199,39 @@ class ApiClient {
|
|||||||
return data.hasToken;
|
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() {
|
async getGarminSyncStatus() {
|
||||||
const { data } = await this.client.get<SyncStatus>('/garmin/status');
|
const { data } = await this.client.get<SyncStatus>('/garmin/status');
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
Reference in New Issue
Block a user