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:
156
backend/services/auth_hub_client.py
Normal file
156
backend/services/auth_hub_client.py
Normal 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
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user