实测记录:昨天为了验证令牌写回的修复,我发了一次 1 天同步,把 rate_limited_until 从 09-04T00:41 推到了 09-04T15:26——一次尝试,延长十五小时。 Garmin 的登录/换令牌端点和数据端点是两套规则: - 数据端点的 429:本地冷却只是估算,过一会儿发个真实请求正是确认它有没有解除 的唯一办法,没解除也不吃亏 - 登录端点的 429:窗口内每次尝试都把窗口往后推。这也是这个账号一直卡着出不来 的原因——每次同步都去换一次令牌,每次都把封锁续上 所以: - sync_status 加 rate_limit_source 列,记住 429 是哪个端点给的 - 只有 source=sso 且窗口未过时,才拒绝**刷新令牌**这一个动作 这个闸门刻意做得很窄,因为上一次的教训是「一刀切的闸门会让健康账号白白闲置」: - 只拦刷新,不拦同步。库里的令牌只要还没过期,冷却期内照常同步 - 数据端点的 429 不参与判断——为了一次指标调用把账号锁一天,比问题本身更糟 - 用户可以推翻它:同步请求带 force 就先清掉冷却记录。那个截止时间是我们自己 猜的 24 小时,不是佳明说的,所以必须能被推翻 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
183 lines
6.5 KiB
Python
183 lines
6.5 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):
|
||
# 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, "sso")
|
||
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],
|
||
)
|