[阶段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:
@@ -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<SyncStatus>('/garmin/status');
|
||||
return data;
|
||||
|
||||
Reference in New Issue
Block a user