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:
56
backend/tests/test_auth_hub_client.py
Normal file
56
backend/tests/test_auth_hub_client.py
Normal 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
|
||||
Reference in New Issue
Block a user