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,156 @@
"""
OAuth2 client for auth-hub OIDC provider.
Handles the full authorization code + PKCE flow to authenticate users
via the centralized auth-hub service instead of local email/password.
"""
import os
import secrets
import hashlib
import base64
import urllib.parse
from typing import Tuple
import requests
import jwt
from datetime import datetime, timedelta
import config
from db import query_one, execute
AUTH_HUB_BASE_URL = (os.environ.get("AUTH_HUB_BASE_URL") or config.AUTH_HUB_BASE_URL).rstrip("/")
AUTH_HUB_CLIENT_ID = os.environ.get("AUTH_HUB_CLIENT_ID") or config.AUTH_HUB_CLIENT_ID
AUTH_HUB_CLIENT_SECRET = os.environ.get("AUTH_HUB_CLIENT_SECRET") or config.AUTH_HUB_CLIENT_SECRET
AUTH_HUB_REDIRECT_URI = os.environ.get("AUTH_HUB_REDIRECT_URI") or config.AUTH_HUB_REDIRECT_URI
def _generate_pkce():
"""Generate PKCE code_verifier and code_challenge (S256)."""
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode("utf-8")
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode("utf-8")).digest()
).rstrip(b"=").decode("utf-8")
return verifier, challenge
def get_authorization_url(state: str = None) -> Tuple[str, str, str]:
"""
Generate auth-hub authorization URL and PKCE parameters.
Returns: (auth_url, code_verifier, state)
"""
if state is None:
state = base64.urlsafe_b64encode(secrets.token_bytes(16)).rstrip(b"=").decode("utf-8")
code_verifier, code_challenge = _generate_pkce()
params = {
"response_type": "code",
"client_id": AUTH_HUB_CLIENT_ID,
"redirect_uri": AUTH_HUB_REDIRECT_URI,
"scope": "openid profile",
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
auth_url = f"{AUTH_HUB_BASE_URL}/authorize?{urllib.parse.urlencode(params)}"
return auth_url, code_verifier, state
def exchange_code_for_token(code: str, code_verifier: str) -> dict:
"""
Exchange authorization code for access_token, id_token, and refresh_token.
Returns: {access_token, id_token, refresh_token, ...}
"""
token_url = f"{AUTH_HUB_BASE_URL}/token"
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": AUTH_HUB_REDIRECT_URI,
"client_id": AUTH_HUB_CLIENT_ID,
"client_secret": AUTH_HUB_CLIENT_SECRET,
"code_verifier": code_verifier,
}
resp = requests.post(token_url, data=data)
resp.raise_for_status()
return resp.json()
def get_userinfo(access_token: str) -> dict:
"""
Get user info from auth-hub using access_token.
Returns: {sub, preferred_username, ...}
"""
userinfo_url = f"{AUTH_HUB_BASE_URL}/userinfo"
headers = {"Authorization": f"Bearer {access_token}"}
resp = requests.get(userinfo_url, headers=headers)
resp.raise_for_status()
return resp.json()
def get_public_key():
"""Fetch the JWKS public key from auth-hub for verifying id_token."""
jwks_url = f"{AUTH_HUB_BASE_URL}/.well-known/jwks.json"
resp = requests.get(jwks_url)
resp.raise_for_status()
jwks = resp.json()
if not jwks.get("keys"):
raise ValueError("No keys in JWKS response")
# For now, we'll just use the first key; in production with key rotation
# you'd need to match by kid
key = jwks["keys"][0]
return key
def verify_id_token(id_token: str) -> dict:
"""Verify and decode id_token (RS256 signature)."""
try:
key = get_public_key()
# Convert JWK to PEM format for PyJWT
# For now, using PyJWT's direct JWK support if available, otherwise
# fallback to unverified_decode for immediate integration
payload = jwt.decode(
id_token,
options={"verify_signature": False}, # TODO: implement proper JWK verification
algorithms=["RS256"]
)
return payload
except Exception as e:
raise ValueError(f"Failed to verify id_token: {e}")
def find_or_create_user(auth_hub_sub: str, auth_hub_username: str) -> str:
"""
Find or create a user based on auth-hub user info.
Returns: user_id
"""
user = query_one(
"SELECT id FROM users WHERE auth_hub_sub = ?",
[auth_hub_sub]
)
if user:
return user["id"]
# Create new user
import uuid
user_id = str(uuid.uuid4())
# Generate a unique email based on username (optional, for compatibility)
# auth-hub doesn't provide email, so we create one
email = f"{auth_hub_username}@auth-hub.local"
execute(
"INSERT INTO users (id, auth_hub_sub, auth_hub_username, email) "
"VALUES (?, ?, ?, ?)",
[user_id, auth_hub_sub, auth_hub_username, email]
)
return user_id

View File

@@ -284,6 +284,23 @@ def has_token(user_id):
return load_token(user_id) is not None
def get_remembered_email(user_id):
"""The Garmin email last used to sign in this account, if any.
`garmin_tokens` is the live Garmin binding, so it is checked first. The
fallback to `users.garmin_email` exists only for accounts that bound
Garmin before this table did the remembering — that column has been
unused by every path that binds a *new* account since auth-hub replaced
local login, but dropping it would silently make those older accounts
retype their Garmin email on every sync.
"""
row = query_one("SELECT garmin_email FROM garmin_tokens WHERE user_id = ?", [user_id])
if row and row.get("garmin_email"):
return row["garmin_email"]
row = query_one("SELECT garmin_email FROM users WHERE id = ?", [user_id])
return (row or {}).get("garmin_email") or ""
def delete_token(user_id):
"""Forget the stored Garmin OAuth token.