Files
GarminHealthLab/backend/garmin_login.py
ericwyuan bb774c332b [阶段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>
2026-08-23 20:11:45 +08:00

110 lines
3.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
"""
One-time interactive Garmin login.
Garmin accounts with two-factor auth cannot be logged into by the web service:
the library asks for the MFA code on stdin, and a gunicorn worker has none —
the attempt fails with "EOFError: EOF when reading a line".
This script does that login in a terminal, where a code can actually be typed,
and stores the resulting OAuth tokens in the database. Every later sync loads
those tokens and skips the login entirely. They stay valid for roughly a year;
re-run this when a sync starts reporting an expired session.
Usage (on the NAS):
cd ~/apps/garmin-health-lab/backend
.venv/bin/python garmin_login.py
Add --email to pick an account when more than one is registered.
"""
import argparse
import getpass
import sys
import db
from services import garmin as garmin_svc
def pick_user(email=None):
if email:
row = db.query_one("SELECT id, email, garmin_email FROM users WHERE email = ?",
[email])
if not row:
sys.exit(f"找不到账号: {email}")
return row
rows = db.query_all("SELECT id, email, garmin_email FROM users ORDER BY created_at")
if not rows:
sys.exit("数据库里还没有账号,请先在网页上注册。")
if len(rows) == 1:
return rows[0]
print("有多个账号,请选择:")
for i, r in enumerate(rows, 1):
print(f" {i}) {r['email']} (Garmin: {r['garmin_email']})")
choice = input("序号: ").strip()
try:
return rows[int(choice) - 1]
except (ValueError, IndexError):
sys.exit("选择无效")
def main():
parser = argparse.ArgumentParser(description="一次性 Garmin 登录,保存令牌")
parser.add_argument("--email", help="要绑定的本站账号邮箱")
parser.add_argument("--garmin-email", help="Garmin 账号邮箱(默认用注册时填的)")
args = parser.parse_args()
db.init_db()
user = pick_user(args.email)
garmin_email = args.garmin_email or user["garmin_email"]
print(f"本站账号 : {user['email']}")
print(f"Garmin : {garmin_email}")
print()
if garmin_svc.has_token(user["id"]):
if input("已存在登录令牌,要覆盖吗?[y/N] ").strip().lower() != "y":
return
password = getpass.getpass("Garmin 密码: ")
if not password:
sys.exit("密码不能为空")
Garmin = garmin_svc._import_garmin()
client = Garmin(email=garmin_email, password=password, is_cn=garmin_svc._is_cn())
def ask_mfa():
# garth's built-in prompt is a bare English input() that is easy to
# miss in the surrounding output, so this replaces it with something
# unmistakable.
print("\n" + "=" * 52)
print(" 账号开启了两步验证,请查收短信/邮件中的验证码")
print("=" * 52)
while True:
code = input(" 验证码6 位数字): ").strip()
if code:
return code
print(" 验证码不能为空,请重新输入。")
print("\n正在登录……")
try:
# Call garth directly rather than Garmin.login(): only this path lets
# the MFA prompt be replaced. The two lines afterwards are what
# Garmin.login() would otherwise populate.
client.garth.login(garmin_email, password, prompt_mfa=ask_mfa)
client.display_name = client.garth.profile["displayName"]
client.full_name = client.garth.profile["fullName"]
except Exception as e:
sys.exit(f"\n登录失败: {type(e).__name__}: {e}")
garmin_svc.save_token(user["id"], client.garth.dumps(), garmin_email)
print(f"\n登录成功:{client.display_name}")
print("令牌已保存到数据库,之后网页上的同步不再需要密码或验证码。")
print("令牌大约一年后过期,届时重跑本脚本即可。")
if __name__ == "__main__":
main()