[阶段5.2] 两步验证改到网页端完成,手机上即可绑定 Garmin

背景:命令行方案要求用户在电脑前开交互式终端,实际不可行。
改为在网页里完成 MFA,手机也能操作。

难点:garth 索取验证码走的是 *阻塞回调*,0.4.46 没有
"发起登录 -> 返回句柄 -> 稍后续接" 的接口,登录必须一直挂着。
而 gunicorn 跑多个 worker,验证码请求不一定落到挂着登录的那个 worker。

方案:登录跑在后台线程里,停在 prompt_mfa 内轮询数据库;
浏览器用另一个请求把验证码写进同一行。**汇合点是数据库而非进程内存**,
所以哪个 worker 收到验证码都能送达。

- 新增 garmin_mfa_sessions 表(不存密码,密码只活在等待线程的内存里)
- services/garmin_auth.py:start_login / submit_code / cancel
  状态机 starting -> awaiting_code -> finishing -> done|failed
- 超时 5 分钟自动放弃,会话 1 小时后清理
- 会话按 user_id 校验,他人拿到 session id 也读不到、提交不了

接口:
- POST   /api/garmin/login        发起登录,202 返回 session
- GET    /api/garmin/login-status 轮询状态
- POST   /api/garmin/mfa          提交验证码
- DELETE /api/garmin/login        取消

前端 DataSync 改为三步:
- 未绑定 -> 输密码「绑定 Garmin 账号」
- 需要验证码 -> 弹出 6 位验证码输入框(inputMode=numeric、
  autoComplete=one-time-code,手机可直接从短信自动填充)
- 已绑定 -> 只剩「立即同步」,不再要密码

tests/test_garmin_mfa.py (20 通过):
- stub 的 prompt_mfa 按 garth 的真实方式同步阻塞调用
- 关键用例:验证码直接写进数据库行也能被挂起的线程取到
  (模拟验证码落到另一个 worker)
- 无 MFA 的账号不经验证码直接完成
- 验证码错误 / 密码错误 / 等待超时 各自失败并给出原因
- 取消后挂起线程立即释放,不空转到超时
- 密码不出现在会话行里
- 跨用户读取和提交均被拒

全量: 271 passed

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-08-23 20:11:45 +08:00
parent af0604bce4
commit bb774c332b
8 changed files with 801 additions and 54 deletions

View File

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