feat(auth): 接入 auth-hub 统一登录,网页登录与 Garmin 同步彻底分离

网页身份改由 auth-hub 做 OAuth2 + PKCE 单点登录,本地邮箱/密码登录与注册整条链路删除
(routes/auth.py、auth.py 的密码哈希、config.py 的 ALLOW_REGISTRATION)。Garmin 账号绑定/
同步保持完全独立、可选:routes/garmin.py 不再直接查 users 表,Garmin 邮箱回退统一走新增
的 services/garmin.py::get_remembered_email()(优先读 garmin_tokens 当前绑定,兼容早期账号
落在 users.garmin_email 的历史值),彻底把「你是谁」和「你绑没绑 Garmin」两件事拆开。

- db.py: users 表新增 auth_hub_sub/auth_hub_username,MIGRATIONS 补上这两列(此前遗漏导致
  已存在的生产 MariaDB 表永远不会自动加列);同时把历史遗留的 garmin_email/
  garmin_password_hash NOT NULL 约束在线迁移为可空,因为新账号不再在注册时收集这些字段。
- routes/auth.py: 修掉 /callback 路由重复拼接 /api/auth 前缀导致 404 的 bug。
- client: LoginPage 去掉本地登录/注册标签页,只保留 auth-hub 统一登录;登录成功/失败后都
  用 history.replaceState 清理地址栏,修掉 Framework7 browserHistory 读取
  /auth/callback?code=... 导致「找不到页面」的问题。
- 新增 test_auth_hub_client.py 锁定 find_or_create_user 按 auth_hub_sub 幂等——生产上曾经因为
  这个函数在没有该测试保护时被测试触发,误建过一个空账号,靠手工核对 health_data 计数才发现。
- 生产 auth-hub 侧另行为该项目注册了正式 client(未随本次提交变更,凭证只存在服务器 .env)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-08-31 23:12:17 +08:00
parent 7e51223eb9
commit 9503fca370
24 changed files with 592 additions and 1007 deletions

View File

@@ -0,0 +1,56 @@
"""
Tests for the auth-hub account-linking logic.
This is the one place a login bug can silently orphan a real account: if
`find_or_create_user` ever created a fresh row for a `auth_hub_sub` it had
already seen, the same person logging in twice would end up owning two
disconnected accounts — the second with none of the health data synced
under the first. That exact bug shipped once already (a login test created
a real duplicate in production before this file existed), so it is worth
locking down explicitly rather than only trusting `find_or_create_user`'s
docstring.
"""
from services.auth_hub_client import find_or_create_user
class TestFindOrCreateUser:
def test_first_login_creates_a_user(self, db):
uid = find_or_create_user("42", "alice")
row = db.query_one("SELECT * FROM users WHERE id = ?", [uid])
assert row["auth_hub_sub"] == "42"
assert row["auth_hub_username"] == "alice"
def test_repeat_login_returns_the_same_user_not_a_duplicate(self, db):
first = find_or_create_user("42", "alice")
second = find_or_create_user("42", "alice")
assert first == second
assert db.query_one(
"SELECT COUNT(*) AS n FROM users WHERE auth_hub_sub = ?", ["42"]
)["n"] == 1
def test_different_sub_gets_a_different_user(self, db):
alice = find_or_create_user("42", "alice")
bob = find_or_create_user("43", "bob")
assert alice != bob
def test_a_username_change_upstream_does_not_split_the_account(self, db):
"""auth-hub identifies accounts by `sub`; `preferred_username` can be
renamed there without that being treated as a new local account."""
first = find_or_create_user("42", "alice")
second = find_or_create_user("42", "alice_renamed")
assert first == second
def test_links_to_a_pre_existing_account_with_that_sub(self, db):
"""The legacy migration path: an account created before auth-hub
existed gets its auth_hub_sub set once (by an operator, or a future
self-service linking flow), and every login after that must resolve
to that same row rather than minting a new one."""
import uuid
legacy_id = str(uuid.uuid4())
db.execute(
"INSERT INTO users (id, email, auth_hub_sub, auth_hub_username) "
"VALUES (?, ?, ?, ?)",
[legacy_id, "legacy@example.com", "42", "alice"],
)
assert find_or_create_user("42", "alice") == legacy_id