用户问:限流了,重新输账号密码验证码换个新令牌行不行。
不行,而且是最糟的一种试法。`garth.login()` 和 `refresh_oauth2()` 打的是
同一个 SSO 端点,流程还更重;限流按**账号**计(不是按 IP、按 UA),换设备
换网络都绕不开;而窗口内每次尝试都会把窗口往后推。
而这正是被卡住时第一个会去试的操作,代码里却只有 `_connect` 的刷新有闸门,
重新绑定那条路照发不误。
- start_login 在 sso 冷却窗口内直接拒绝,不建会话行、不碰网络
- 错误信息说清三件事:为什么现在不试、什么时候恢复、换设备没用
- 路由返 429(请求本身没毛病,是该晚点再来)并带 retryAfterSeconds
- 数据端点的 429 不参与拦截,force 可以推翻
前端补上 UI:报错文案早先承诺了「同步页选择强制重试」,但那个按钮不存在。
现在只在被冷却拒绝之后才出现,样式刻意做得不像第二个「开始同步」——它是给
估算失准时的出口,不是随手可点的第二选择。
顺带修一个正要被我引入的 bug:`onClick={syncHistory}` 会把 MouseEvent 当成
force 传进去,等于每次点开始同步都跳过冷却。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
222 lines
8.1 KiB
Python
222 lines
8.1 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)
|
||
|
||
|
||
class LoginRateLimited(Exception):
|
||
"""Refused before contacting Garmin, because a login 429 is still active."""
|
||
|
||
|
||
def retry_after_seconds(user_id):
|
||
"""Seconds until the login cooldown lapses, or None."""
|
||
blocked = garmin_svc.sso_cooldown(user_id)
|
||
if not blocked:
|
||
return None
|
||
return max(0, int((blocked - datetime.datetime.utcnow()).total_seconds()))
|
||
|
||
|
||
def start_login(user_id, garmin_email, password, import_garmin=None,
|
||
is_cn=None, force=False):
|
||
"""Kick off a login in the background. Returns the session id.
|
||
|
||
Refuses while a login 429 is still in its window. Re-binding is the
|
||
obvious thing to try when syncing is blocked — "just get a fresh token" —
|
||
but it goes through `garth.login()`, which is the *same* SSO endpoint that
|
||
is doing the blocking, by a heavier path than the token refresh. The limit
|
||
is keyed to the account, so a new password entry, a new device or a new
|
||
network reaches the same wall, and each attempt pushes the window out.
|
||
|
||
`force` overrules the recorded deadline, which is this app's own 24h guess
|
||
rather than anything Garmin stated.
|
||
"""
|
||
if force:
|
||
garmin_svc.clear_rate_limit(user_id)
|
||
else:
|
||
blocked = garmin_svc.sso_cooldown(user_id)
|
||
if blocked:
|
||
minutes = int(
|
||
(blocked - datetime.datetime.utcnow()).total_seconds() // 60
|
||
)
|
||
raise LoginRateLimited(
|
||
"Garmin 正在限制该账号的登录请求。重新绑定走的是同一个登录接口,"
|
||
f"现在重试只会延长封锁。预计 {blocked.isoformat(timespec='minutes')} "
|
||
f"UTC 之后恢复(约 {minutes} 分钟)。"
|
||
"限流按账号计算,换设备或换网络都绕不开。"
|
||
)
|
||
|
||
_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],
|
||
)
|