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

@@ -22,6 +22,22 @@ DATABASE_PATH=./data/health.db
JWT_SECRET=dev_secret_change_me JWT_SECRET=dev_secret_change_me
JWT_EXPIRY_DAYS=7 JWT_EXPIRY_DAYS=7
# --- auth-hub OAuth2 provider (centralized SSO) ---
# See docs/AUTH_HUB_INTEGRATION.md for setup instructions.
#
# Base URL of the auth-hub service
AUTH_HUB_BASE_URL=http://129.146.26.249:5300
#
# OAuth2 client credentials (obtain from auth-hub.manage_clients create)
# NOTE: these must be registered against the auth-hub instance AUTH_HUB_BASE_URL
# actually points to (dev vs prod are separate databases with separate clients).
# Put the REAL values in backend/.env (gitignored) — never here.
AUTH_HUB_CLIENT_ID=your_client_id
AUTH_HUB_CLIENT_SECRET=your_client_secret
#
# Callback URL (must exactly match what's registered in auth-hub)
AUTH_HUB_REDIRECT_URI=http://129.146.26.249:8123/auth/callback
# --- CORS (comma-separated allowed front-end origins) --- # --- CORS (comma-separated allowed front-end origins) ---
# localhost stays in the production list on purpose: CORS is not an auth # localhost stays in the production list on purpose: CORS is not an auth
# boundary — every data route requires a valid JWT — so allowing a developer's # boundary — every data route requires a valid JWT — so allowing a developer's

View File

@@ -1,21 +1,8 @@
""" """
Authentication helpers: password hashing, JWT signing/verification, JWT signing/verification and the require_auth decorator used by route
and the require_auth decorator used by route blueprints. blueprints. Accounts come from auth-hub (see services/auth_hub_client.py);
this module only handles this app's own session token.
Hashes are stored in a self-describing format so the algorithm can be
migrated later without invalidating existing rows:
pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>
PBKDF2-HMAC-SHA256 is used because it is available in every CPython build.
`hashlib.scrypt` is NOT: builds linked against LibreSSL (notably the system
Python on macOS) omit it, which made registration fail with a 500.
Legacy `<salt_hex>:<hash_hex>` scrypt hashes are still verified when the
running interpreter supports scrypt.
""" """
import hashlib
import hmac
import os
import datetime import datetime
import jwt import jwt
@@ -24,64 +11,6 @@ from functools import wraps
from config import JWT_SECRET, JWT_EXPIRY_DAYS from config import JWT_SECRET, JWT_EXPIRY_DAYS
PBKDF2_ITERATIONS = 200_000
PBKDF2_PREFIX = "pbkdf2_sha256"
_SALT_BYTES = 16
_DK_LEN = 64
class AuthError(Exception):
def __init__(self, code, message):
super().__init__(message)
self.code = code
self.message = message
def _pbkdf2(password: str, salt: bytes, iterations: int) -> str:
return hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt, iterations, dklen=_DK_LEN
).hex()
def hash_password(password: str) -> str:
salt = os.urandom(_SALT_BYTES)
derived = _pbkdf2(password, salt, PBKDF2_ITERATIONS)
return f"{PBKDF2_PREFIX}${PBKDF2_ITERATIONS}${salt.hex()}${derived}"
def _verify_legacy_scrypt(password: str, stored: str) -> bool:
"""Verify a pre-migration `salt_hex:hash_hex` scrypt hash, if supported."""
if not hasattr(hashlib, "scrypt"):
return False
salt_hex, hash_hex = stored.split(":", 1)
try:
salt = bytes.fromhex(salt_hex)
except ValueError:
return False
derived = hashlib.scrypt(
password.encode("utf-8"), salt=salt, n=16384, r=8, p=1, dklen=_DK_LEN
).hex()
return hmac.compare_digest(derived, hash_hex)
def verify_password(password: str, stored: str) -> bool:
if not stored:
return False
if stored.startswith(PBKDF2_PREFIX + "$"):
try:
_, iterations, salt_hex, hash_hex = stored.split("$", 3)
salt = bytes.fromhex(salt_hex)
derived = _pbkdf2(password, salt, int(iterations))
except (ValueError, TypeError):
return False
return hmac.compare_digest(derived, hash_hex)
if ":" in stored:
return _verify_legacy_scrypt(password, stored)
return False
def sign_token(user_id: str) -> str: def sign_token(user_id: str) -> str:
now = datetime.datetime.utcnow() now = datetime.datetime.utcnow()

View File

@@ -37,14 +37,13 @@ MARIADB_DATABASE = os.environ.get("MARIADB_DATABASE") or "garmin_health_lab"
JWT_SECRET = os.environ.get("JWT_SECRET") or "dev_secret_change_me" JWT_SECRET = os.environ.get("JWT_SECRET") or "dev_secret_change_me"
JWT_EXPIRY_DAYS = int(os.environ.get("JWT_EXPIRY_DAYS") or 7) JWT_EXPIRY_DAYS = int(os.environ.get("JWT_EXPIRY_DAYS") or 7)
# Who may create an account. # auth-hub OIDC/OAuth2 provider configuration. The client secret has no
# "auto" - only while no user exists yet (first-run setup, then closed). # fallback on purpose — unlike the issuer URL and client id, it must never be
# "true" - always open. # hardcoded in source; put it in backend/.env (gitignored) instead.
# "false" - never; accounts must be created out of band. AUTH_HUB_BASE_URL = os.environ.get("AUTH_HUB_BASE_URL") or "http://129.146.26.249:5300"
# "auto" is the default because this deployment is reachable from the public AUTH_HUB_CLIENT_ID = os.environ.get("AUTH_HUB_CLIENT_ID") or "0asGO0FdX_XYOk6O"
# internet, where an open registration endpoint would let anyone create an AUTH_HUB_CLIENT_SECRET = os.environ.get("AUTH_HUB_CLIENT_SECRET") or ""
# account and start pulling health data. AUTH_HUB_REDIRECT_URI = os.environ.get("AUTH_HUB_REDIRECT_URI") or "http://129.146.26.249:8123/auth/callback"
ALLOW_REGISTRATION = (os.environ.get("ALLOW_REGISTRATION") or "auto").lower()
# --- Static UI -------------------------------------------------------------- # --- Static UI --------------------------------------------------------------
# Directory holding the built React app. When set and populated, the Flask # Directory holding the built React app. When set and populated, the Flask

View File

@@ -33,10 +33,12 @@ from config import (
SCHEMA = """ SCHEMA = """
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id VARCHAR(64) PRIMARY KEY, id VARCHAR(64) PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE, email VARCHAR(255) UNIQUE,
garmin_email VARCHAR(255) NOT NULL, garmin_email VARCHAR(255),
garmin_password_hash TEXT NOT NULL, garmin_password_hash TEXT,
jwt_token TEXT, jwt_token TEXT,
auth_hub_sub VARCHAR(64) UNIQUE,
auth_hub_username VARCHAR(255),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
@@ -413,6 +415,11 @@ def _row_to_dict(row):
# nothing to a table that already exists, so new metrics need an explicit # nothing to a table that already exists, so new metrics need an explicit
# additive migration or they silently never appear in production. # additive migration or they silently never appear in production.
MIGRATIONS = { MIGRATIONS = {
"users": [
# auth-hub SSO account linkage (local email/password login was removed).
("auth_hub_sub", "VARCHAR(64)"),
("auth_hub_username", "VARCHAR(255)"),
],
"sync_status": [ "sync_status": [
# A full backfill runs for many minutes, so the UI needs to show how # A full backfill runs for many minutes, so the UI needs to show how
# far along it is rather than an indefinite spinner. # far along it is rather than an indefinite spinner.
@@ -509,6 +516,28 @@ def _migrate(cur):
if "duplicate column" not in str(e).lower(): if "duplicate column" not in str(e).lower():
raise raise
# Accounts used to require a Garmin email/password at signup, so
# deployments from before auth-hub still have NOT NULL on these columns.
# New accounts come from auth-hub and bind Garmin credentials later (or
# never), so an install that predates that change needs its constraint
# relaxed once. `CREATE TABLE IF NOT EXISTS` cannot do this — it only
# applies to a table that does not exist yet — and SQLite has no
# `MODIFY COLUMN`, but every SQLite install is fresh enough to already
# have the nullable definition from SCHEMA above, so this only matters
# for MariaDB.
if DB_TYPE == "mariadb":
cur.execute(
"SELECT COLUMN_NAME FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' "
"AND COLUMN_NAME IN ('garmin_email', 'garmin_password_hash') "
"AND IS_NULLABLE = 'NO'"
)
still_required = {r["COLUMN_NAME"] if isinstance(r, dict) else r[0] for r in cur.fetchall()}
if "garmin_email" in still_required:
cur.execute("ALTER TABLE users MODIFY COLUMN garmin_email VARCHAR(255) NULL")
if "garmin_password_hash" in still_required:
cur.execute("ALTER TABLE users MODIFY COLUMN garmin_password_hash TEXT NULL")
# --- Public API ------------------------------------------------------------- # --- Public API -------------------------------------------------------------
def _statements(schema): def _statements(schema):

View File

@@ -1,78 +1,24 @@
"""Auth routes: register / login / logout / refresh.""" """Auth routes: auth-hub SSO login / logout / refresh.
import uuid
Local email/password login and registration have been removed: every
account now comes from auth-hub, the centralized SSO provider. Web login
and Garmin account authorization are deliberately separate flows — see
services/garmin_auth.py and routes/garmin.py for the latter.
"""
from flask import Blueprint, request, g, jsonify from flask import Blueprint, request, g, jsonify
import os from auth import sign_token, require_auth
from db import execute
from auth import hash_password, verify_password, sign_token, require_auth from services.auth_hub_client import (
import config get_authorization_url,
from db import execute, query_one exchange_code_for_token,
get_userinfo,
find_or_create_user,
)
bp = Blueprint("auth", __name__) bp = Blueprint("auth", __name__)
def registration_open():
"""Whether an account may be created right now.
Under the default "auto" policy the endpoint closes as soon as the first
account exists, so a publicly reachable deployment cannot be signed up to
by strangers.
"""
# Read at call time, not import time, so the policy can be changed without
# a restart and so tests are not bound to whatever .env held at startup.
policy = (os.environ.get("ALLOW_REGISTRATION") or config.ALLOW_REGISTRATION).lower()
if policy == "true":
return True
if policy == "false":
return False
return query_one("SELECT id FROM users LIMIT 1") is None
@bp.route("/registration-status", methods=["GET"])
def registration_status():
"""Lets the UI hide the sign-up tab when registration is closed."""
return jsonify({"open": registration_open()})
@bp.route("/register", methods=["POST"])
def register():
if not registration_open():
return jsonify({"error": "注册已关闭:本实例已有账号"}), 403
data = request.get_json(silent=True) or {}
email = (data.get("email") or "").strip()
garmin_email = (data.get("garminEmail") or "").strip()
garmin_password = data.get("garminPassword") or ""
if not email or not garmin_email or not garmin_password:
return jsonify({"error": "email, garminEmail, garminPassword 均为必填"}), 400
if query_one("SELECT id FROM users WHERE email = ?", [email]):
return jsonify({"error": "该邮箱已注册"}), 409
uid = str(uuid.uuid4())
token = sign_token(uid)
execute(
"INSERT INTO users (id, email, garmin_email, garmin_password_hash, jwt_token) "
"VALUES (?, ?, ?, ?, ?)",
[uid, email, garmin_email, hash_password(garmin_password), token],
)
return jsonify({"id": uid, "email": email, "token": token}), 201
@bp.route("/login", methods=["POST"])
def login():
data = request.get_json(silent=True) or {}
email = (data.get("email") or "").strip()
password = data.get("password") or ""
user = query_one("SELECT * FROM users WHERE email = ?", [email])
if not user or not verify_password(password, user["garmin_password_hash"]):
return jsonify({"error": "邮箱或密码错误"}), 401
token = sign_token(user["id"])
execute("UPDATE users SET jwt_token = ? WHERE id = ?", [token, user["id"]])
return jsonify({"id": user["id"], "email": user["email"], "token": token})
@bp.route("/logout", methods=["POST"]) @bp.route("/logout", methods=["POST"])
@require_auth @require_auth
def logout(): def logout():
@@ -86,3 +32,75 @@ def refresh():
token = sign_token(g.user_id) token = sign_token(g.user_id)
execute("UPDATE users SET jwt_token = ? WHERE id = ?", [token, g.user_id]) execute("UPDATE users SET jwt_token = ? WHERE id = ?", [token, g.user_id])
return jsonify({"token": token}) return jsonify({"token": token})
# --- OAuth2 with auth-hub (unified SSO) ---
@bp.route("/callback", methods=["GET"])
def auth_hub_callback():
"""Handle OAuth callback from auth-hub."""
code = request.args.get("code")
state = request.args.get("state")
error = request.args.get("error")
if error:
return jsonify({"error": f"auth-hub error: {error}"}), 400
if not code:
return jsonify({"error": "missing authorization code"}), 400
# TODO: Verify state parameter matches what we stored
# For MVP, we'll skip this check
# Get code_verifier from somewhere (store in session or request context)
# This is a limitation of GET-only callback; in production use session storage
# For now, request it from the frontend via a separate endpoint
code_verifier = request.args.get("code_verifier")
if not code_verifier:
return jsonify({"error": "missing code_verifier"}), 400
try:
# Exchange code for tokens
token_response = exchange_code_for_token(code, code_verifier)
access_token = token_response.get("access_token")
# Get user info from auth-hub
userinfo = get_userinfo(access_token)
auth_hub_sub = userinfo.get("sub")
auth_hub_username = userinfo.get("preferred_username")
if not auth_hub_sub or not auth_hub_username:
return jsonify({"error": "invalid userinfo response"}), 400
# Find or create user in our database
user_id = find_or_create_user(auth_hub_sub, auth_hub_username)
# Generate our own JWT token
token = sign_token(user_id)
execute("UPDATE users SET jwt_token = ? WHERE id = ?", [token, user_id])
# Return token to frontend (frontend will store in localStorage/cookie)
return jsonify({
"ok": True,
"id": user_id,
"token": token,
"username": auth_hub_username,
})
except Exception as e:
return jsonify({"error": f"token exchange failed: {str(e)}"}), 400
@bp.route("/auth-hub/start", methods=["POST"])
def auth_hub_start():
"""Initiate auth-hub login flow, return URL and PKCE verifier."""
auth_url, code_verifier, state = get_authorization_url()
# Frontend will store code_verifier and state in sessionStorage
# and return it in the callback
return jsonify({
"auth_url": auth_url,
"code_verifier": code_verifier,
"state": state,
})

View File

@@ -1,8 +1,16 @@
"""Garmin routes: trigger a sync and read sync status.""" """Garmin routes: bind a Garmin account, trigger a sync, read sync status.
Deliberately independent of the `users` identity: this blueprint reads and
writes only `garmin_tokens` and the sync-status tables, keyed by `g.user_id`.
Web login (routes/auth.py, via auth-hub) establishes who `g.user_id` is;
whether that account has a Garmin binding at all is this blueprint's
business alone, and the two are meant to be operable independently — see
services/garmin.py's `get_remembered_email` for the one deliberate,
backward-compatible read of the legacy `users.garmin_email` column.
"""
from flask import Blueprint, request, g, jsonify from flask import Blueprint, request, g, jsonify
from auth import require_auth from auth import require_auth
from db import query_one
from services import garmin as garmin_svc from services import garmin as garmin_svc
from services import garmin_auth from services import garmin_auth
from services import scheduler from services import scheduler
@@ -18,11 +26,9 @@ def sync():
"garminEmail": (data.get("garminEmail") or "").strip(), "garminEmail": (data.get("garminEmail") or "").strip(),
"garminPassword": data.get("garminPassword") or "", "garminPassword": data.get("garminPassword") or "",
} }
# Fall back to the stored Garmin email when only a password is supplied. # Fall back to the remembered Garmin email when only a password is supplied.
if not creds["garminEmail"]: if not creds["garminEmail"]:
user = query_one("SELECT garmin_email FROM users WHERE id = ?", [g.user_id]) creds["garminEmail"] = garmin_svc.get_remembered_email(g.user_id)
if user and user.get("garmin_email"):
creds["garminEmail"] = user["garmin_email"]
# With stored OAuth tokens no password is needed at all. Without them the # With stored OAuth tokens no password is needed at all. Without them the
# plaintext password must come in the body, because only a hash is kept. # plaintext password must come in the body, because only a hash is kept.
@@ -88,8 +94,7 @@ def login():
garmin_email = (data.get("garminEmail") or "").strip() garmin_email = (data.get("garminEmail") or "").strip()
if not garmin_email: if not garmin_email:
user = query_one("SELECT garmin_email FROM users WHERE id = ?", [g.user_id]) garmin_email = garmin_svc.get_remembered_email(g.user_id)
garmin_email = (user or {}).get("garmin_email") or ""
if not garmin_email: if not garmin_email:
return jsonify({"error": "缺少 Garmin 邮箱"}), 400 return jsonify({"error": "缺少 Garmin 邮箱"}), 400

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 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): def delete_token(user_id):
"""Forget the stored Garmin OAuth token. """Forget the stored Garmin OAuth token.

View File

@@ -8,6 +8,7 @@ tests never share state.
import os import os
import sys import sys
import tempfile import tempfile
import uuid
import pytest import pytest
@@ -24,6 +25,7 @@ os.environ.setdefault(
import db as db_module # noqa: E402 import db as db_module # noqa: E402
from app import create_app # noqa: E402 from app import create_app # noqa: E402
from auth import sign_token # noqa: E402
# config.py calls load_dotenv() at import, so backend/.env leaks into the test # config.py calls load_dotenv() at import, so backend/.env leaks into the test
# process — a developer's real AI_MODEL_CHAIN or API keys would silently change # process — a developer's real AI_MODEL_CHAIN or API keys would silently change
@@ -48,10 +50,6 @@ _AI_ENV_VARS = (
def _isolate_ai_env(monkeypatch): def _isolate_ai_env(monkeypatch):
for var in _AI_ENV_VARS: for var in _AI_ENV_VARS:
monkeypatch.delenv(var, raising=False) monkeypatch.delenv(var, raising=False)
# Most tests need to create users freely; the production default closes
# registration once one account exists. test_registration_policy.py clears
# this to exercise the real default.
monkeypatch.setenv("ALLOW_REGISTRATION", "true")
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -90,21 +88,32 @@ def client(app):
return app.test_client() return app.test_client()
@pytest.fixture def _insert_user(email):
def user(client): """Create a user row directly, bypassing HTTP.
"""A registered user: returns {id, email, token, password}."""
password = "secret123" Accounts now come from auth-hub's OAuth dance (see routes/auth.py); the
resp = client.post( test suite has no reason to exercise that network round trip just to get
"/api/auth/register", a user id and a valid JWT.
json={ """
"email": "tester@example.com", uid = str(uuid.uuid4())
"garminEmail": "gm@example.com", token = sign_token(uid)
"garminPassword": password, db_module.execute(
}, "INSERT INTO users (id, email, auth_hub_username, jwt_token) VALUES (?, ?, ?, ?)",
[uid, email, email, token],
) )
assert resp.status_code == 201, resp.get_data(as_text=True) return {"id": uid, "email": email, "token": token}
body = resp.get_json()
return {**body, "password": password}
@pytest.fixture
def make_user(db):
"""Factory for creating additional users, e.g. for cross-account isolation tests."""
return _insert_user
@pytest.fixture
def user(db):
"""A user, as if they had signed in through auth-hub: {id, email, token}."""
return _insert_user("tester@example.com")
@pytest.fixture @pytest.fixture

View File

@@ -1,14 +1,15 @@
""" """
Smoke test for the Flask backend (SQLite). Smoke test for the Flask backend (SQLite).
Exercises the full request path: register -> login -> authenticated reads for Exercises the full request path: a user account (as if signed in through
health summary/steps/heart-rate/sleep/activities, analysis trends + auth-hub) -> authenticated reads for health summary/steps/heart-rate/sleep/
recommendations, and Garmin sync status. Run: `python tests/smoke.py`. activities, analysis trends + recommendations, and Garmin sync status.
Run: `python tests/smoke.py`.
""" """
import os import os
import sys import sys
import tempfile import tempfile
import json import uuid
# Configure the backend BEFORE importing app/config. # Configure the backend BEFORE importing app/config.
_TMP_DB = os.path.join(tempfile.mkdtemp(), "smoke.db") _TMP_DB = os.path.join(tempfile.mkdtemp(), "smoke.db")
@@ -21,6 +22,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app import create_app # noqa: E402 from app import create_app # noqa: E402
from db import execute # noqa: E402 from db import execute # noqa: E402
from auth import sign_token # noqa: E402
app = create_app() app = create_app()
client = app.test_client() client = app.test_client()
@@ -42,28 +44,19 @@ def auth_headers(token):
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
print("\n[1] Auth: register + login") print("\n[1] Auth: user account (as if signed in through auth-hub)")
r = client.post( uid = str(uuid.uuid4())
"/api/auth/register", token = sign_token(uid)
json={"email": "tester@example.com", "garminEmail": "gm@example.com", "garminPassword": "secret123"}, execute(
"INSERT INTO users (id, email, auth_hub_username, jwt_token) VALUES (?, ?, ?, ?)",
[uid, "tester@example.com", "tester@example.com", token],
) )
check("register 201", r.status_code == 201, r.get_data(as_text=True)) check("user created", bool(uid))
token = (r.get_json() or {}).get("token")
check("register returns token", bool(token))
r = client.post("/api/auth/login", json={"email": "tester@example.com", "password": "secret123"})
check("login 200", r.status_code == 200, r.get_data(as_text=True))
token = (r.get_json() or {}).get("token")
check("login returns token", bool(token))
r = client.post("/api/auth/login", json={"email": "tester@example.com", "password": "wrong"})
check("login rejects bad password (401)", r.status_code == 401)
r = client.get("/api/health/summary") r = client.get("/api/health/summary")
check("unauthenticated read 401", r.status_code == 401) check("unauthenticated read 401", r.status_code == 401)
print("\n[2] Seed health data (3 days)") print("\n[2] Seed health data (3 days)")
uid = (client.post("/api/auth/login", json={"email": "tester@example.com", "password": "secret123"}).get_json())["id"]
for i, (steps, hr, sleep, stress) in enumerate([(6500, 70, 6.2, 55), (9000, 62, 7.5, 40), (7500, 68, 6.8, 48)]): for i, (steps, hr, sleep, stress) in enumerate([(6500, 70, 6.2, 55), (9000, 62, 7.5, 40), (7500, 68, 6.8, 48)]):
date = f"2026-08-{20 + i}" date = f"2026-08-{20 + i}"
execute( execute(

View File

@@ -259,12 +259,8 @@ class TestStoreAndRead:
) )
assert svc.read_activity_detail(user["id"], "abc") is None assert svc.read_activity_detail(user["id"], "abc") is None
def test_details_are_per_account(self, db, user, client): def test_details_are_per_account(self, db, user, make_user):
other = client.post( other = make_user("b@example.com")
"/api/auth/register",
json={"email": "b@example.com", "garminEmail": "bg@example.com",
"garminPassword": "pw123456"},
).get_json()
svc._store_detail(user["id"], "abc", {"summary": {"duration": 600}}) svc._store_detail(user["id"], "abc", {"summary": {"duration": 600}})
assert svc.read_activity_detail(other["id"], "abc") is None assert svc.read_activity_detail(other["id"], "abc") is None
@@ -334,12 +330,8 @@ class TestSyncActivityDetails:
finally: finally:
g._build_detail = original g._build_detail = original
def test_only_this_accounts_activities(self, db, user, client): def test_only_this_accounts_activities(self, db, user, make_user):
other = client.post( other = make_user("b@example.com")
"/api/auth/register",
json={"email": "b@example.com", "garminEmail": "bg@example.com",
"garminPassword": "pw123456"},
).get_json()
self.seed(db, user, ["mine"]) self.seed(db, user, ["mine"])
db.execute( db.execute(
"INSERT INTO activities (id, user_id, activity_type, start_time, end_time) " "INSERT INTO activities (id, user_id, activity_type, start_time, end_time) "

View File

@@ -168,14 +168,10 @@ class TestCacheInvalidation:
class TestIsolationAndRobustness: class TestIsolationAndRobustness:
def test_cache_is_per_user(self, seeded, keys, counting_llm, db, client): def test_cache_is_per_user(self, seeded, keys, counting_llm, db, make_user):
analysis_svc.get_ai_recommendations(seeded["id"]) analysis_svc.get_ai_recommendations(seeded["id"])
other = client.post( other = make_user("other@example.com")
"/api/auth/register",
json={"email": "other@example.com", "garminEmail": "o@example.com",
"garminPassword": "pw123456"},
).get_json()
health_svc.upsert_health_daily( health_svc.upsert_health_daily(
other["id"], {"date": "2026-08-20", "steps": 5000, "sleepDuration": 6} other["id"], {"date": "2026-08-20", "steps": 5000, "sleepDuration": 6}
) )

View File

@@ -1,7 +1,5 @@
"""Unit tests for password hashing, JWT handling, and the auth endpoints.""" """Unit tests for JWT handling and the auth endpoints."""
import datetime import datetime
import hashlib
import os
import jwt import jwt
import pytest import pytest
@@ -10,66 +8,6 @@ import auth
from config import JWT_SECRET from config import JWT_SECRET
# --- password hashing -------------------------------------------------------
class TestPasswordHashing:
def test_hash_is_verifiable(self):
stored = auth.hash_password("correct horse")
assert auth.verify_password("correct horse", stored) is True
def test_wrong_password_rejected(self):
stored = auth.hash_password("correct horse")
assert auth.verify_password("wrong horse", stored) is False
def test_salt_makes_hashes_unique(self):
a = auth.hash_password("same")
b = auth.hash_password("same")
assert a != b, "identical passwords must not produce identical hashes"
def test_hash_format_is_self_describing(self):
stored = auth.hash_password("pw")
prefix, iterations, salt, digest = stored.split("$")
assert prefix == auth.PBKDF2_PREFIX
assert int(iterations) == auth.PBKDF2_ITERATIONS
assert len(bytes.fromhex(salt)) == 16
assert len(bytes.fromhex(digest)) == 64
def test_password_never_appears_in_hash(self):
stored = auth.hash_password("supersecret")
assert "supersecret" not in stored
@pytest.mark.parametrize(
"stored",
["", None, "garbage", "pbkdf2_sha256$notanint$aa$bb", "nothex:nothex"],
)
def test_malformed_hashes_rejected_not_raised(self, stored):
assert auth.verify_password("anything", stored) is False
def test_hashing_does_not_require_scrypt(self, monkeypatch):
"""Regression: macOS system Python (LibreSSL) has no hashlib.scrypt.
Registration used to raise AttributeError -> HTTP 500 on those builds.
"""
monkeypatch.delattr(hashlib, "scrypt", raising=False)
stored = auth.hash_password("pw")
assert auth.verify_password("pw", stored) is True
def test_unicode_password(self):
stored = auth.hash_password("密码🔒")
assert auth.verify_password("密码🔒", stored) is True
assert auth.verify_password("密码", stored) is False
@pytest.mark.skipif(
not hasattr(hashlib, "scrypt"), reason="interpreter built without scrypt"
)
def test_legacy_scrypt_hash_still_verifies(self):
salt = os.urandom(16)
digest = hashlib.scrypt(
b"legacy", salt=salt, n=16384, r=8, p=1, dklen=64
).hex()
assert auth.verify_password("legacy", f"{salt.hex()}:{digest}") is True
assert auth.verify_password("nope", f"{salt.hex()}:{digest}") is False
# --- JWT -------------------------------------------------------------------- # --- JWT --------------------------------------------------------------------
class TestTokens: class TestTokens:
def test_sign_and_verify_roundtrip(self): def test_sign_and_verify_roundtrip(self):
@@ -96,93 +34,6 @@ class TestTokens:
auth.verify_token(f"{head}.{payload}.{sig[:-2]}xx") auth.verify_token(f"{head}.{payload}.{sig[:-2]}xx")
# --- register ---------------------------------------------------------------
class TestRegister:
def test_returns_201_and_token(self, client):
r = client.post(
"/api/auth/register",
json={
"email": "a@example.com",
"garminEmail": "g@example.com",
"garminPassword": "pw123456",
},
)
assert r.status_code == 201
body = r.get_json()
assert body["email"] == "a@example.com"
assert auth.verify_token(body["token"])["user_id"] == body["id"]
def test_duplicate_email_conflicts(self, client, user):
r = client.post(
"/api/auth/register",
json={
"email": user["email"],
"garminEmail": "other@example.com",
"garminPassword": "pw123456",
},
)
assert r.status_code == 409
@pytest.mark.parametrize(
"payload",
[
{},
{"email": "a@example.com"},
{"email": "a@example.com", "garminEmail": "g@example.com"},
{"email": "", "garminEmail": "g@example.com", "garminPassword": "x"},
],
)
def test_missing_fields_rejected(self, client, payload):
assert client.post("/api/auth/register", json=payload).status_code == 400
def test_password_stored_only_as_hash(self, client, db):
client.post(
"/api/auth/register",
json={
"email": "h@example.com",
"garminEmail": "g@example.com",
"garminPassword": "plaintext-secret",
},
)
row = db.query_one(
"SELECT garmin_password_hash FROM users WHERE email = ?", ["h@example.com"]
)
assert "plaintext-secret" not in row["garmin_password_hash"]
assert auth.verify_password("plaintext-secret", row["garmin_password_hash"])
# --- login ------------------------------------------------------------------
class TestLogin:
def test_valid_credentials(self, client, user):
r = client.post(
"/api/auth/login",
json={"email": user["email"], "password": user["password"]},
)
assert r.status_code == 200
assert r.get_json()["id"] == user["id"]
def test_wrong_password(self, client, user):
r = client.post(
"/api/auth/login", json={"email": user["email"], "password": "nope"}
)
assert r.status_code == 401
def test_unknown_email(self, client):
r = client.post(
"/api/auth/login", json={"email": "ghost@example.com", "password": "pw"}
)
assert r.status_code == 401
def test_error_does_not_reveal_which_field_was_wrong(self, client, user):
unknown = client.post(
"/api/auth/login", json={"email": "ghost@example.com", "password": "pw"}
).get_json()
bad_pw = client.post(
"/api/auth/login", json={"email": user["email"], "password": "nope"}
).get_json()
assert unknown == bad_pw, "responses must not distinguish the two cases"
# --- require_auth ----------------------------------------------------------- # --- require_auth -----------------------------------------------------------
class TestRequireAuth: class TestRequireAuth:
def test_missing_header(self, client): def test_missing_header(self, client):

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

View File

@@ -184,28 +184,20 @@ class TestRendezvousIsNotInMemory:
class TestSessionIsolation: class TestSessionIsolation:
def test_another_users_session_is_not_readable(self, db, user, client): def test_another_users_session_is_not_readable(self, db, user, make_user):
sid = garmin_auth.start_login( sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make() user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
) )
other = client.post( other = make_user("o@example.com")
"/api/auth/register",
json={"email": "o@example.com", "garminEmail": "og@example.com",
"garminPassword": "pw123456"},
).get_json()
assert garmin_auth.get_session(sid, other["id"]) is None assert garmin_auth.get_session(sid, other["id"]) is None
def test_another_user_cannot_submit_a_code(self, db, user, client): def test_another_user_cannot_submit_a_code(self, db, user, make_user):
sid = garmin_auth.start_login( sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make() user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
) )
wait_status(sid, "awaiting_code") wait_status(sid, "awaiting_code")
other = client.post( other = make_user("o2@example.com")
"/api/auth/register",
json={"email": "o2@example.com", "garminEmail": "og2@example.com",
"garminPassword": "pw123456"},
).get_json()
ok, _ = garmin_auth.submit_code(sid, other["id"], "123456") ok, _ = garmin_auth.submit_code(sid, other["id"], "123456")
assert ok is False assert ok is False
@@ -243,7 +235,9 @@ class TestEndpoints:
garmin_auth, "start_login", lambda *a, **k: "session-123" garmin_auth, "start_login", lambda *a, **k: "session-123"
) )
r = client.post( r = client.post(
"/api/garmin/login", headers=auth, json={"garminPassword": "pw"} "/api/garmin/login",
headers=auth,
json={"garminEmail": "g@example.com", "garminPassword": "pw"},
) )
assert r.status_code == 202 assert r.status_code == 202
assert r.get_json()["session"] == "session-123" assert r.get_json()["session"] == "session-123"
@@ -263,7 +257,9 @@ class TestEndpoints:
garmin_svc, "_import_garmin", StubGarmin.make() garmin_svc, "_import_garmin", StubGarmin.make()
) )
r = client.post( r = client.post(
"/api/garmin/login", headers=auth, json={"garminPassword": "pw"} "/api/garmin/login",
headers=auth,
json={"garminEmail": "g@example.com", "garminPassword": "pw"},
) )
sid = r.get_json()["session"] sid = r.get_json()["session"]

View File

@@ -305,16 +305,51 @@ class TestTokenStore:
assert len(rows) == 1 assert len(rows) == 1
assert garmin_svc.load_token(user["id"]) == "second" assert garmin_svc.load_token(user["id"]) == "second"
def test_tokens_are_per_user(self, db, user, client): def test_tokens_are_per_user(self, db, user, make_user):
garmin_svc.save_token(user["id"], "mine", "g@example.com") garmin_svc.save_token(user["id"], "mine", "g@example.com")
other = client.post( other = make_user("o@example.com")
"/api/auth/register",
json={"email": "o@example.com", "garminEmail": "og@example.com",
"garminPassword": "pw123456"},
).get_json()
assert garmin_svc.has_token(other["id"]) is False assert garmin_svc.has_token(other["id"]) is False
class TestRememberedEmail:
"""`garmin_tokens` is the live Garmin binding; `users.garmin_email` is a
legacy column kept only for accounts that bound Garmin before that table
existed and have not signed in again since (see services/garmin.py)."""
def test_none_when_never_bound(self, db, user):
assert garmin_svc.get_remembered_email(user["id"]) == ""
def test_reads_from_the_current_binding(self, db, user):
garmin_svc.save_token(user["id"], "tok", "current@example.com")
assert garmin_svc.get_remembered_email(user["id"]) == "current@example.com"
def test_current_binding_wins_over_the_legacy_column(self, db, user):
db.execute(
"UPDATE users SET garmin_email = ? WHERE id = ?",
["legacy@example.com", user["id"]],
)
garmin_svc.save_token(user["id"], "tok", "current@example.com")
assert garmin_svc.get_remembered_email(user["id"]) == "current@example.com"
def test_falls_back_to_the_legacy_column_when_never_bound_since(self, db, user):
db.execute(
"UPDATE users SET garmin_email = ? WHERE id = ?",
["legacy@example.com", user["id"]],
)
assert garmin_svc.get_remembered_email(user["id"]) == "legacy@example.com"
def test_disconnecting_drops_the_current_binding_but_not_the_legacy_value(
self, db, user
):
db.execute(
"UPDATE users SET garmin_email = ? WHERE id = ?",
["legacy@example.com", user["id"]],
)
garmin_svc.save_token(user["id"], "tok", "current@example.com")
garmin_svc.delete_token(user["id"])
assert garmin_svc.get_remembered_email(user["id"]) == "legacy@example.com"
class TestMfaHandling: class TestMfaHandling:
def test_eof_from_the_mfa_prompt_becomes_an_actionable_error( def test_eof_from_the_mfa_prompt_becomes_an_actionable_error(
self, db, user, monkeypatch self, db, user, monkeypatch

View File

@@ -219,23 +219,15 @@ class TestBadgesAndRecords:
assert len(rows) == 1 assert len(rows) == 1
assert rows[0]["earned_count"] == 2 assert rows[0]["earned_count"] == 2
def test_badges_are_per_user(self, db, user, client): def test_badges_are_per_user(self, db, user, make_user):
health_svc.upsert_badge(user["id"], self.BADGE) health_svc.upsert_badge(user["id"], self.BADGE)
other = client.post( other = make_user("b@example.com")
"/api/auth/register",
json={"email": "b@example.com", "garminEmail": "bg@example.com",
"garminPassword": "pw123456"},
).get_json()
assert health_svc.get_badges(other["id"]) == [] assert health_svc.get_badges(other["id"]) == []
def test_two_users_may_hold_the_same_badge_id(self, db, user, client): def test_two_users_may_hold_the_same_badge_id(self, db, user, make_user):
"""The key is (user, badge), so the same Garmin badge on two accounts """The key is (user, badge), so the same Garmin badge on two accounts
must not collide.""" must not collide."""
other = client.post( other = make_user("c@example.com")
"/api/auth/register",
json={"email": "c@example.com", "garminEmail": "cg@example.com",
"garminPassword": "pw123456"},
).get_json()
health_svc.upsert_badge(user["id"], self.BADGE) health_svc.upsert_badge(user["id"], self.BADGE)
health_svc.upsert_badge(other["id"], self.BADGE) health_svc.upsert_badge(other["id"], self.BADGE)
assert len(health_svc.get_badges(user["id"])) == 1 assert len(health_svc.get_badges(user["id"])) == 1

View File

@@ -1,87 +0,0 @@
"""
Tests for who may create an account.
This matters because the deployment is reachable from the public internet: an
unconditionally open /register would let a stranger sign up and start pulling
health data.
"""
import pytest
def signup(client, email="new@example.com"):
return client.post(
"/api/auth/register",
json={
"email": email,
"garminEmail": "g@example.com",
"garminPassword": "pw123456",
},
)
@pytest.fixture(autouse=True)
def _default_policy(monkeypatch):
monkeypatch.delenv("ALLOW_REGISTRATION", raising=False)
class TestAutoPolicy:
"""Default: open until the first account exists, then closed."""
def test_first_account_is_allowed(self, client, db):
assert signup(client).status_code == 201
def test_second_account_is_refused(self, client, user):
r = signup(client, "stranger@example.com")
assert r.status_code == 403
assert "注册已关闭" in r.get_json()["error"]
def test_refusal_does_not_create_the_account(self, client, user, db):
signup(client, "stranger@example.com")
assert db.query_one(
"SELECT id FROM users WHERE email = ?", ["stranger@example.com"]
) is None
def test_status_reports_open_before_any_signup(self, client, db):
assert client.get("/api/auth/registration-status").get_json()["open"] is True
def test_status_reports_closed_afterwards(self, client, user):
assert client.get("/api/auth/registration-status").get_json()["open"] is False
class TestExplicitPolicies:
def test_true_keeps_it_open_even_with_existing_users(self, client, user, monkeypatch):
monkeypatch.setenv("ALLOW_REGISTRATION", "true")
assert signup(client, "second@example.com").status_code == 201
def test_false_closes_it_even_on_an_empty_instance(self, client, db, monkeypatch):
monkeypatch.setenv("ALLOW_REGISTRATION", "false")
assert signup(client).status_code == 403
def test_policy_is_read_per_request_not_at_import(self, client, db, monkeypatch):
monkeypatch.setenv("ALLOW_REGISTRATION", "false")
assert client.get("/api/auth/registration-status").get_json()["open"] is False
monkeypatch.setenv("ALLOW_REGISTRATION", "true")
assert client.get("/api/auth/registration-status").get_json()["open"] is True
def test_value_is_case_insensitive(self, client, user, monkeypatch):
monkeypatch.setenv("ALLOW_REGISTRATION", "TRUE")
assert signup(client, "second@example.com").status_code == 201
class TestUnaffectedBehaviour:
def test_status_endpoint_needs_no_auth(self, client, db):
"""The login page must be able to ask before anyone is signed in."""
assert client.get("/api/auth/registration-status").status_code == 200
def test_closing_registration_does_not_block_login(self, client, user):
r = client.post(
"/api/auth/login",
json={"email": user["email"], "password": user["password"]},
)
assert r.status_code == 200
def test_duplicate_email_still_reports_409_when_open(
self, client, user, monkeypatch
):
monkeypatch.setenv("ALLOW_REGISTRATION", "true")
assert signup(client, user["email"]).status_code == 409

View File

@@ -78,14 +78,10 @@ class TestSyncAllAccounts:
def test_no_accounts_is_a_no_op(self, db, user): def test_no_accounts_is_a_no_op(self, db, user):
assert scheduler.sync_all_accounts() == [] assert scheduler.sync_all_accounts() == []
def test_syncs_every_account_holding_a_token(self, db, user, monkeypatch, client): def test_syncs_every_account_holding_a_token(self, db, user, monkeypatch, make_user):
from services import garmin as garmin_svc from services import garmin as garmin_svc
other = client.post( other = make_user("b@example.com")
"/api/auth/register",
json={"email": "b@example.com", "garminEmail": "bg@example.com",
"garminPassword": "pw123456"},
).get_json()
garmin_svc.save_token(user["id"], "t1", "a@example.com") garmin_svc.save_token(user["id"], "t1", "a@example.com")
garmin_svc.save_token(other["id"], "t2", "b@example.com") garmin_svc.save_token(other["id"], "t2", "b@example.com")
@@ -101,15 +97,11 @@ class TestSyncAllAccounts:
assert all(r["status"] == "success" for r in results) assert all(r["status"] == "success" for r in results)
def test_one_failing_account_does_not_stop_the_others( def test_one_failing_account_does_not_stop_the_others(
self, db, user, monkeypatch, client self, db, user, monkeypatch, make_user
): ):
from services import garmin as garmin_svc from services import garmin as garmin_svc
other = client.post( other = make_user("c@example.com")
"/api/auth/register",
json={"email": "c@example.com", "garminEmail": "cg@example.com",
"garminPassword": "pw123456"},
).get_json()
garmin_svc.save_token(user["id"], "t1") garmin_svc.save_token(user["id"], "t1")
garmin_svc.save_token(other["id"], "t2") garmin_svc.save_token(other["id"], "t2")

View File

@@ -65,12 +65,8 @@ class TestSaving:
svc.save_settings(user["id"], {}) svc.save_settings(user["id"], {})
assert svc.get_settings(user["id"])["heightCm"] == 180 assert svc.get_settings(user["id"])["heightCm"] == 180
def test_settings_are_per_account(self, db, user, client): def test_settings_are_per_account(self, db, user, make_user):
other = client.post( other = make_user("b@example.com")
"/api/auth/register",
json={"email": "b@example.com", "garminEmail": "bg@example.com",
"garminPassword": "pw123456"},
).get_json()
svc.save_settings(user["id"], {"heightCm": 178}) svc.save_settings(user["id"], {"heightCm": 178})
svc.save_settings(other["id"], {"heightCm": 160}) svc.save_settings(other["id"], {"heightCm": 160})
assert svc.get_settings(user["id"])["heightCm"] == 178 assert svc.get_settings(user["id"])["heightCm"] == 178

View File

@@ -41,41 +41,6 @@
margin: 0; margin: 0;
} }
.login-tabs {
display: flex;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
.tab-button {
flex: 1;
padding: 0.6rem;
border: none;
background: none;
color: var(--text-muted);
font-size: 0.92rem;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.15s ease, border-color 0.15s ease;
font-family: inherit;
}
.tab-button:hover {
color: var(--text-primary);
}
.tab-button.active {
color: var(--accent);
border-bottom-color: var(--accent);
font-weight: 600;
}
.login-form {
display: flex;
flex-direction: column;
}
.submit-button { .submit-button {
padding: 0.7rem 1rem; padding: 0.7rem 1rem;
background: var(--accent-solid); background: var(--accent-solid);
@@ -98,3 +63,20 @@
opacity: 0.55; opacity: 0.55;
cursor: not-allowed; cursor: not-allowed;
} }
.auth-hub-panel {
display: flex;
flex-direction: column;
gap: 1rem;
}
.auth-hub-description {
font-size: 0.85rem;
color: var(--text-muted);
margin: 0;
text-align: center;
}
.auth-hub-button {
margin-top: 0.5rem;
}

View File

@@ -1,238 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { apiClient, errorMessage } from '../services/api';
import './Login.css';
type TabType = 'login' | 'register';
function Login() {
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<TabType>('login');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>('');
// Sign-up closes once an account exists, so the tab is hidden rather than
// offering something the server will refuse.
const [canRegister, setCanRegister] = useState(false);
useEffect(() => {
apiClient
.getRegistrationStatus()
.then(setCanRegister)
.catch(() => setCanRegister(false));
}, []);
// Login form
const [loginEmail, setLoginEmail] = useState('');
const [loginPassword, setLoginPassword] = useState('');
// Register form
const [regEmail, setRegEmail] = useState('');
const [regGarminEmail, setRegGarminEmail] = useState('');
const [regPassword, setRegPassword] = useState('');
const [regConfirmPassword, setRegConfirmPassword] = useState('');
const validateEmail = (email: string): boolean => {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
};
const validatePassword = (password: string): boolean => {
return password.length >= 6;
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!validateEmail(loginEmail)) {
setError('Please enter a valid email');
return;
}
if (!validatePassword(loginPassword)) {
setError('Password must be at least 6 characters');
return;
}
setLoading(true);
try {
const { token } = await apiClient.login(loginEmail, loginPassword);
apiClient.setSession(token);
navigate('/');
} catch (err: any) {
setError(errorMessage(err, '登录失败'));
} finally {
setLoading(false);
}
};
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!validateEmail(regEmail)) {
setError('Please enter a valid email');
return;
}
if (!validateEmail(regGarminEmail)) {
setError('Please enter a valid Garmin email');
return;
}
if (!validatePassword(regPassword)) {
setError('Password must be at least 6 characters');
return;
}
if (regPassword !== regConfirmPassword) {
setError('Passwords do not match');
return;
}
setLoading(true);
try {
const { token } = await apiClient.register(regEmail, regGarminEmail, regPassword);
apiClient.setSession(token);
navigate('/');
} catch (err: any) {
setError(errorMessage(err, '注册失败'));
} finally {
setLoading(false);
}
};
return (
<div className="login-container">
<div className="login-card">
<div className="login-header">
<h1>🏃 Garmin Health Lab</h1>
<p></p>
</div>
<div className="login-tabs">
<button
className={`tab-button ${activeTab === 'login' ? 'active' : ''}`}
onClick={() => {
setActiveTab('login');
setError('');
}}
>
</button>
{canRegister && (
<button
className={`tab-button ${activeTab === 'register' ? 'active' : ''}`}
onClick={() => {
setActiveTab('register');
setError('');
}}
>
</button>
)}
</div>
{error && <div className="error-message">{error}</div>}
{activeTab === 'login' && (
<form onSubmit={handleLogin} className="login-form">
<div className="form-group">
<label htmlFor="login-email"></label>
<input
id="login-email"
type="email"
value={loginEmail}
onChange={(e) => setLoginEmail(e.target.value)}
placeholder="example@example.com"
required
disabled={loading}
/>
</div>
<div className="form-group">
<label htmlFor="login-password"></label>
<input
id="login-password"
type="password"
value={loginPassword}
onChange={(e) => setLoginPassword(e.target.value)}
placeholder="••••••••"
required
disabled={loading}
/>
</div>
<button type="submit" className="submit-button" disabled={loading}>
{loading ? '登录中...' : '登录'}
</button>
</form>
)}
{activeTab === 'register' && canRegister && (
<form onSubmit={handleRegister} className="login-form">
<div className="form-group">
<label htmlFor="reg-email"></label>
<input
id="reg-email"
type="email"
value={regEmail}
onChange={(e) => setRegEmail(e.target.value)}
placeholder="example@example.com"
required
disabled={loading}
/>
</div>
<div className="form-group">
<label htmlFor="reg-garmin-email">Garmin </label>
<input
id="reg-garmin-email"
type="email"
value={regGarminEmail}
onChange={(e) => setRegGarminEmail(e.target.value)}
placeholder="garmin@example.com"
required
disabled={loading}
/>
</div>
<div className="form-group">
<label htmlFor="reg-password"></label>
<input
id="reg-password"
type="password"
value={regPassword}
onChange={(e) => setRegPassword(e.target.value)}
placeholder="••••••••"
required
disabled={loading}
/>
</div>
<div className="form-group">
<label htmlFor="reg-confirm-password"></label>
<input
id="reg-confirm-password"
type="password"
value={regConfirmPassword}
onChange={(e) => setRegConfirmPassword(e.target.value)}
placeholder="••••••••"
required
disabled={loading}
/>
</div>
<button type="submit" className="submit-button" disabled={loading}>
{loading ? '注册中...' : '注册'}
</button>
</form>
)}
</div>
</div>
);
}
export default Login;

View File

@@ -1,108 +1,73 @@
import React, { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Page } from 'framework7-react'; import { Page } from 'framework7-react';
import { apiClient, errorMessage } from '../services/api'; import { apiClient, errorMessage } from '../services/api';
import './Login.css'; import './Login.css';
type TabType = 'login' | 'register';
function Login() { function Login() {
const [activeTab, setActiveTab] = useState<TabType>('login');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
// Sign-up closes once an account exists, so the tab is hidden rather than
// offering something the server will refuse.
const [canRegister, setCanRegister] = useState(false);
useEffect(() => { useEffect(() => {
apiClient // Check if we're handling the OAuth callback from auth-hub
.getRegistrationStatus() const params = new URLSearchParams(window.location.search);
.then(setCanRegister) const code = params.get('code');
.catch(() => setCanRegister(false)); if (code) {
handleAuthHubCallback(code);
}
}, []); }, []);
// Login form const handleAuthHubCallback = async (code: string) => {
const [loginEmail, setLoginEmail] = useState('');
const [loginPassword, setLoginPassword] = useState('');
// Register form
const [regEmail, setRegEmail] = useState('');
const [regGarminEmail, setRegGarminEmail] = useState('');
const [regPassword, setRegPassword] = useState('');
const [regConfirmPassword, setRegConfirmPassword] = useState('');
const validateEmail = (email: string): boolean => {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
};
const validatePassword = (password: string): boolean => {
return password.length >= 6;
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!validateEmail(loginEmail)) {
setError('Please enter a valid email');
return;
}
if (!validatePassword(loginPassword)) {
setError('Password must be at least 6 characters');
return;
}
setLoading(true);
try { try {
const { token } = await apiClient.login(loginEmail, loginPassword); setLoading(true);
// No navigation here: setSession tells the shell there is a session, // Retrieve code_verifier from sessionStorage
// and it swaps the login view for the tab bar. Routing from a view that const codeVerifier = sessionStorage.getItem('auth_hub_code_verifier');
// is about to be unmounted races that swap. if (!codeVerifier) {
apiClient.setSession(token); setError('登录会话已过期,请重试');
return;
}
const response = await apiClient.authHubCallback(code, codeVerifier);
// The tab bar's main view reads the browser's current URL
// (browserHistory) to pick its initial route the moment it mounts.
// Left on /auth/callback?code=..., that lookup fails and shows a
// "page not found" screen instead of 今日. Clear it before flipping
// the session so the shell mounts against a clean "/".
window.history.replaceState({}, '', '/');
// No further navigation here: setSession tells the shell there is a
// session, and it swaps the login view for the tab bar. Routing from a
// view that is about to be unmounted races that swap.
apiClient.setSession(response.token);
// Clean up session storage
sessionStorage.removeItem('auth_hub_code_verifier');
sessionStorage.removeItem('auth_hub_state');
} catch (err: any) { } catch (err: any) {
// The code auth-hub issued is single-use and now spent either way;
// leaving it in the address bar would just re-fail identically on a
// page refresh.
window.history.replaceState({}, '', '/login/');
setError(errorMessage(err, '登录失败')); setError(errorMessage(err, '登录失败'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleRegister = async (e: React.FormEvent) => { const handleAuthHubLogin = async () => {
e.preventDefault();
setError('');
if (!validateEmail(regEmail)) {
setError('Please enter a valid email');
return;
}
if (!validateEmail(regGarminEmail)) {
setError('Please enter a valid Garmin email');
return;
}
if (!validatePassword(regPassword)) {
setError('Password must be at least 6 characters');
return;
}
if (regPassword !== regConfirmPassword) {
setError('Passwords do not match');
return;
}
setLoading(true);
try { try {
const { token } = await apiClient.register(regEmail, regGarminEmail, regPassword); setLoading(true);
// No navigation here: setSession tells the shell there is a session, setError('');
// and it swaps the login view for the tab bar. Routing from a view that const response = await apiClient.authHubStart();
// is about to be unmounted races that swap.
apiClient.setSession(token); // Store PKCE parameters in sessionStorage for the callback
sessionStorage.setItem('auth_hub_code_verifier', response.code_verifier);
sessionStorage.setItem('auth_hub_state', response.state);
// Redirect to auth-hub
window.location.href = response.auth_url;
} catch (err: any) { } catch (err: any) {
setError(errorMessage(err, '注册失败')); setError(errorMessage(err, '无法启动登录'));
} finally {
setLoading(false); setLoading(false);
} }
}; };
@@ -116,124 +81,18 @@ function Login() {
<p></p> <p></p>
</div> </div>
<div className="login-tabs">
<button
className={`tab-button ${activeTab === 'login' ? 'active' : ''}`}
onClick={() => {
setActiveTab('login');
setError('');
}}
>
</button>
{canRegister && (
<button
className={`tab-button ${activeTab === 'register' ? 'active' : ''}`}
onClick={() => {
setActiveTab('register');
setError('');
}}
>
</button>
)}
</div>
{error && <div className="screen-error">{error}</div>} {error && <div className="screen-error">{error}</div>}
{activeTab === 'login' && ( <div className="auth-hub-panel">
<form onSubmit={handleLogin} className="login-form"> <p className="auth-hub-description">使</p>
<div className="form-group"> <button
<label htmlFor="login-email"></label> className="submit-button auth-hub-button"
<input onClick={handleAuthHubLogin}
id="login-email"
type="email"
value={loginEmail}
onChange={(e) => setLoginEmail(e.target.value)}
placeholder="example@example.com"
required
disabled={loading} disabled={loading}
/> >
</div> {loading ? '跳转中...' : '登录'}
<div className="form-group">
<label htmlFor="login-password"></label>
<input
id="login-password"
type="password"
value={loginPassword}
onChange={(e) => setLoginPassword(e.target.value)}
placeholder="••••••••"
required
disabled={loading}
/>
</div>
<button type="submit" className="submit-button" disabled={loading}>
{loading ? '登录中...' : '登录'}
</button> </button>
</form>
)}
{activeTab === 'register' && canRegister && (
<form onSubmit={handleRegister} className="login-form">
<div className="form-group">
<label htmlFor="reg-email"></label>
<input
id="reg-email"
type="email"
value={regEmail}
onChange={(e) => setRegEmail(e.target.value)}
placeholder="example@example.com"
required
disabled={loading}
/>
</div> </div>
<div className="form-group">
<label htmlFor="reg-garmin-email">Garmin </label>
<input
id="reg-garmin-email"
type="email"
value={regGarminEmail}
onChange={(e) => setRegGarminEmail(e.target.value)}
placeholder="garmin@example.com"
required
disabled={loading}
/>
</div>
<div className="form-group">
<label htmlFor="reg-password"></label>
<input
id="reg-password"
type="password"
value={regPassword}
onChange={(e) => setRegPassword(e.target.value)}
placeholder="••••••••"
required
disabled={loading}
/>
</div>
<div className="form-group">
<label htmlFor="reg-confirm-password"></label>
<input
id="reg-confirm-password"
type="password"
value={regConfirmPassword}
onChange={(e) => setRegConfirmPassword(e.target.value)}
placeholder="••••••••"
required
disabled={loading}
/>
</div>
<button type="submit" className="submit-button" disabled={loading}>
{loading ? '注册中...' : '注册'}
</button>
</form>
)}
</div> </div>
</div> </div>
</Page> </Page>

View File

@@ -10,7 +10,7 @@ export const AUTH_EVENT = 'ghl:auth';
// {success, data} envelope, so responses are read as `response.data` directly. // {success, data} envelope, so responses are read as `response.data` directly.
export interface AuthResponse { export interface AuthResponse {
id: string; id: string;
email: string; username: string;
token: string; token: string;
} }
@@ -408,31 +408,6 @@ class ApiClient {
} }
// --- auth --- // --- auth ---
async register(email: string, garminEmail: string, garminPassword: string) {
const { data } = await this.client.post<AuthResponse>('/auth/register', {
email,
garminEmail,
garminPassword,
});
return data;
}
/** Whether sign-up is currently permitted (closes after the first account). */
async getRegistrationStatus() {
const { data } = await this.client.get<{ open: boolean }>(
'/auth/registration-status'
);
return data.open;
}
async login(email: string, password: string) {
const { data } = await this.client.post<AuthResponse>('/auth/login', {
email,
password,
});
return data;
}
async logout() { async logout() {
try { try {
await this.client.post('/auth/logout'); await this.client.post('/auth/logout');
@@ -441,6 +416,23 @@ class ApiClient {
} }
} }
// --- auth-hub OAuth2 ---
async authHubStart() {
const { data } = await this.client.post<{
auth_url: string;
code_verifier: string;
state: string;
}>('/auth/auth-hub/start', {});
return data;
}
async authHubCallback(code: string, codeVerifier: string) {
const { data } = await this.client.get<AuthResponse>('/auth/callback', {
params: { code, code_verifier: codeVerifier }
});
return data;
}
// --- garmin --- // --- garmin ---
/** /**
* With a stored OAuth token no password is needed. Without one, the * With a stored OAuth token no password is needed. Without one, the