背景:命令行方案要求用户在电脑前开交互式终端,实际不可行。 改为在网页里完成 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>
161 lines
5.4 KiB
Python
161 lines
5.4 KiB
Python
"""
|
||
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],
|
||
)
|