[阶段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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user