""" 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): # Don't even attempt if we're in a rate-limit cooldown: slamming Garmin's # login endpoint just extends the throttle. Fail fast with a clear message so # the user isn't left watching a spinner that will only 429 anyway. if garmin_svc.rate_limited_until(user_id): _set( session_id, "failed", error="Garmin 仍在限制请求频率,请等待冷却窗口结束后再登录。" "反复点击登录会越撞越久,令牌本身没有失效。", ) return 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 # A 429 during login extends the cooldown the same way a sync 429 does, # so the scheduler and future logins back off instead of re-hammering and # keeping Garmin's throttle alive forever. if garmin_svc._is_rate_limited(e): garmin_svc._note_rate_limit(user_id) error = ( "Garmin 返回 429 限流(登录接口)。已自动退避 24 小时," "请等待冷却窗口结束后再试——反复尝试会越撞越久。" ) else: error = f"{type(e).__name__}: {e}"[:500] _set(session_id, "failed", error=error, 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], )