网页身份改由 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>
157 lines
4.7 KiB
Python
157 lines
4.7 KiB
Python
"""
|
|
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
|