From 9503fca370070d7603551c07c865b2ef65e0b6cf Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Mon, 31 Aug 2026 23:12:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E6=8E=A5=E5=85=A5=20auth-hub=20?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E7=99=BB=E5=BD=95=EF=BC=8C=E7=BD=91=E9=A1=B5?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E4=B8=8E=20Garmin=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=BD=BB=E5=BA=95=E5=88=86=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 网页身份改由 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 --- backend/.env.example | 16 ++ backend/auth.py | 77 +------ backend/config.py | 15 +- backend/db.py | 35 ++- backend/routes/auth.py | 154 +++++++------ backend/routes/garmin.py | 21 +- backend/services/auth_hub_client.py | 156 +++++++++++++ backend/services/garmin.py | 17 ++ backend/tests/conftest.py | 45 ++-- backend/tests/smoke.py | 33 ++- backend/tests/test_activity_detail.py | 16 +- backend/tests/test_ai_cache.py | 8 +- backend/tests/test_auth.py | 151 +----------- backend/tests/test_auth_hub_client.py | 56 +++++ backend/tests/test_garmin_mfa.py | 24 +- backend/tests/test_garmin_sync.py | 47 +++- backend/tests/test_health.py | 16 +- backend/tests/test_registration_policy.py | 87 ------- backend/tests/test_scheduler.py | 16 +- backend/tests/test_settings.py | 8 +- client/src/pages/Login.css | 52 ++--- client/src/pages/Login.tsx | 238 ------------------- client/src/pages/LoginPage.tsx | 267 +++++----------------- client/src/services/api.ts | 44 ++-- 24 files changed, 592 insertions(+), 1007 deletions(-) create mode 100644 backend/services/auth_hub_client.py create mode 100644 backend/tests/test_auth_hub_client.py delete mode 100644 backend/tests/test_registration_policy.py delete mode 100644 client/src/pages/Login.tsx diff --git a/backend/.env.example b/backend/.env.example index dab166e..1725d5d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -22,6 +22,22 @@ DATABASE_PATH=./data/health.db JWT_SECRET=dev_secret_change_me 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) --- # 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 diff --git a/backend/auth.py b/backend/auth.py index c03b2ef..bdd9cdb 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -1,21 +1,8 @@ """ -Authentication helpers: password hashing, JWT signing/verification, -and the require_auth decorator used by route blueprints. - -Hashes are stored in a self-describing format so the algorithm can be -migrated later without invalidating existing rows: - - pbkdf2_sha256$$$ - -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 `:` scrypt hashes are still verified when the -running interpreter supports scrypt. +JWT signing/verification and the require_auth decorator used by route +blueprints. Accounts come from auth-hub (see services/auth_hub_client.py); +this module only handles this app's own session token. """ -import hashlib -import hmac -import os import datetime import jwt @@ -24,64 +11,6 @@ from functools import wraps 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: now = datetime.datetime.utcnow() diff --git a/backend/config.py b/backend/config.py index eaa472c..112f007 100644 --- a/backend/config.py +++ b/backend/config.py @@ -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_EXPIRY_DAYS = int(os.environ.get("JWT_EXPIRY_DAYS") or 7) -# Who may create an account. -# "auto" - only while no user exists yet (first-run setup, then closed). -# "true" - always open. -# "false" - never; accounts must be created out of band. -# "auto" is the default because this deployment is reachable from the public -# internet, where an open registration endpoint would let anyone create an -# account and start pulling health data. -ALLOW_REGISTRATION = (os.environ.get("ALLOW_REGISTRATION") or "auto").lower() +# auth-hub OIDC/OAuth2 provider configuration. The client secret has no +# fallback on purpose — unlike the issuer URL and client id, it must never be +# hardcoded in source; put it in backend/.env (gitignored) instead. +AUTH_HUB_BASE_URL = os.environ.get("AUTH_HUB_BASE_URL") or "http://129.146.26.249:5300" +AUTH_HUB_CLIENT_ID = os.environ.get("AUTH_HUB_CLIENT_ID") or "0asGO0FdX_XYOk6O" +AUTH_HUB_CLIENT_SECRET = os.environ.get("AUTH_HUB_CLIENT_SECRET") or "" +AUTH_HUB_REDIRECT_URI = os.environ.get("AUTH_HUB_REDIRECT_URI") or "http://129.146.26.249:8123/auth/callback" # --- Static UI -------------------------------------------------------------- # Directory holding the built React app. When set and populated, the Flask diff --git a/backend/db.py b/backend/db.py index 6572fc1..f922d6b 100644 --- a/backend/db.py +++ b/backend/db.py @@ -33,10 +33,12 @@ from config import ( SCHEMA = """ CREATE TABLE IF NOT EXISTS users ( id VARCHAR(64) PRIMARY KEY, - email VARCHAR(255) NOT NULL UNIQUE, - garmin_email VARCHAR(255) NOT NULL, - garmin_password_hash TEXT NOT NULL, + email VARCHAR(255) UNIQUE, + garmin_email VARCHAR(255), + garmin_password_hash TEXT, jwt_token TEXT, + auth_hub_sub VARCHAR(64) UNIQUE, + auth_hub_username VARCHAR(255), created_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 # additive migration or they silently never appear in production. 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": [ # A full backfill runs for many minutes, so the UI needs to show how # far along it is rather than an indefinite spinner. @@ -509,6 +516,28 @@ def _migrate(cur): if "duplicate column" not in str(e).lower(): 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 ------------------------------------------------------------- def _statements(schema): diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 9fc0e68..5ba292a 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -1,78 +1,24 @@ -"""Auth routes: register / login / logout / refresh.""" -import uuid +"""Auth routes: auth-hub SSO login / logout / refresh. +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 -import os - -from auth import hash_password, verify_password, sign_token, require_auth -import config -from db import execute, query_one +from auth import sign_token, require_auth +from db import execute +from services.auth_hub_client import ( + get_authorization_url, + exchange_code_for_token, + get_userinfo, + find_or_create_user, +) 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"]) @require_auth def logout(): @@ -86,3 +32,75 @@ def refresh(): token = sign_token(g.user_id) execute("UPDATE users SET jwt_token = ? WHERE id = ?", [token, g.user_id]) 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, + }) diff --git a/backend/routes/garmin.py b/backend/routes/garmin.py index 762d13d..002e644 100644 --- a/backend/routes/garmin.py +++ b/backend/routes/garmin.py @@ -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 auth import require_auth -from db import query_one from services import garmin as garmin_svc from services import garmin_auth from services import scheduler @@ -18,11 +26,9 @@ def sync(): "garminEmail": (data.get("garminEmail") or "").strip(), "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"]: - user = query_one("SELECT garmin_email FROM users WHERE id = ?", [g.user_id]) - if user and user.get("garmin_email"): - creds["garminEmail"] = user["garmin_email"] + creds["garminEmail"] = garmin_svc.get_remembered_email(g.user_id) # 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. @@ -88,8 +94,7 @@ def login(): garmin_email = (data.get("garminEmail") or "").strip() if not garmin_email: - user = query_one("SELECT garmin_email FROM users WHERE id = ?", [g.user_id]) - garmin_email = (user or {}).get("garmin_email") or "" + garmin_email = garmin_svc.get_remembered_email(g.user_id) if not garmin_email: return jsonify({"error": "缺少 Garmin 邮箱"}), 400 diff --git a/backend/services/auth_hub_client.py b/backend/services/auth_hub_client.py new file mode 100644 index 0000000..cbf8315 --- /dev/null +++ b/backend/services/auth_hub_client.py @@ -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 diff --git a/backend/services/garmin.py b/backend/services/garmin.py index f422ced..d840a74 100644 --- a/backend/services/garmin.py +++ b/backend/services/garmin.py @@ -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. diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index d4253da..d97a4ad 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -8,6 +8,7 @@ tests never share state. import os import sys import tempfile +import uuid import pytest @@ -24,6 +25,7 @@ os.environ.setdefault( import db as db_module # 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 # 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): for var in _AI_ENV_VARS: 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) @@ -90,21 +88,32 @@ def client(app): return app.test_client() -@pytest.fixture -def user(client): - """A registered user: returns {id, email, token, password}.""" - password = "secret123" - resp = client.post( - "/api/auth/register", - json={ - "email": "tester@example.com", - "garminEmail": "gm@example.com", - "garminPassword": password, - }, +def _insert_user(email): + """Create a user row directly, bypassing HTTP. + + Accounts now come from auth-hub's OAuth dance (see routes/auth.py); the + test suite has no reason to exercise that network round trip just to get + a user id and a valid JWT. + """ + uid = str(uuid.uuid4()) + token = sign_token(uid) + 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) - body = resp.get_json() - return {**body, "password": password} + return {"id": uid, "email": email, "token": token} + + +@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 diff --git a/backend/tests/smoke.py b/backend/tests/smoke.py index c9cc1ff..32973a5 100644 --- a/backend/tests/smoke.py +++ b/backend/tests/smoke.py @@ -1,14 +1,15 @@ """ Smoke test for the Flask backend (SQLite). -Exercises the full request path: register -> login -> authenticated reads for -health summary/steps/heart-rate/sleep/activities, analysis trends + -recommendations, and Garmin sync status. Run: `python tests/smoke.py`. +Exercises the full request path: a user account (as if signed in through +auth-hub) -> authenticated reads for health summary/steps/heart-rate/sleep/ +activities, analysis trends + recommendations, and Garmin sync status. +Run: `python tests/smoke.py`. """ import os import sys import tempfile -import json +import uuid # Configure the backend BEFORE importing app/config. _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 db import execute # noqa: E402 +from auth import sign_token # noqa: E402 app = create_app() client = app.test_client() @@ -42,28 +44,19 @@ def auth_headers(token): return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} -print("\n[1] Auth: register + login") -r = client.post( - "/api/auth/register", - json={"email": "tester@example.com", "garminEmail": "gm@example.com", "garminPassword": "secret123"}, +print("\n[1] Auth: user account (as if signed in through auth-hub)") +uid = str(uuid.uuid4()) +token = sign_token(uid) +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)) -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) +check("user created", bool(uid)) r = client.get("/api/health/summary") check("unauthenticated read 401", r.status_code == 401) 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)]): date = f"2026-08-{20 + i}" execute( diff --git a/backend/tests/test_activity_detail.py b/backend/tests/test_activity_detail.py index f2d1a88..c986e4e 100644 --- a/backend/tests/test_activity_detail.py +++ b/backend/tests/test_activity_detail.py @@ -259,12 +259,8 @@ class TestStoreAndRead: ) assert svc.read_activity_detail(user["id"], "abc") is None - def test_details_are_per_account(self, db, user, client): - other = client.post( - "/api/auth/register", - json={"email": "b@example.com", "garminEmail": "bg@example.com", - "garminPassword": "pw123456"}, - ).get_json() + def test_details_are_per_account(self, db, user, make_user): + other = make_user("b@example.com") svc._store_detail(user["id"], "abc", {"summary": {"duration": 600}}) assert svc.read_activity_detail(other["id"], "abc") is None @@ -334,12 +330,8 @@ class TestSyncActivityDetails: finally: g._build_detail = original - def test_only_this_accounts_activities(self, db, user, client): - other = client.post( - "/api/auth/register", - json={"email": "b@example.com", "garminEmail": "bg@example.com", - "garminPassword": "pw123456"}, - ).get_json() + def test_only_this_accounts_activities(self, db, user, make_user): + other = make_user("b@example.com") self.seed(db, user, ["mine"]) db.execute( "INSERT INTO activities (id, user_id, activity_type, start_time, end_time) " diff --git a/backend/tests/test_ai_cache.py b/backend/tests/test_ai_cache.py index 7ed653b..51629b4 100644 --- a/backend/tests/test_ai_cache.py +++ b/backend/tests/test_ai_cache.py @@ -168,14 +168,10 @@ class TestCacheInvalidation: 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"]) - other = client.post( - "/api/auth/register", - json={"email": "other@example.com", "garminEmail": "o@example.com", - "garminPassword": "pw123456"}, - ).get_json() + other = make_user("other@example.com") health_svc.upsert_health_daily( other["id"], {"date": "2026-08-20", "steps": 5000, "sleepDuration": 6} ) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 89fd5d8..1f728f6 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -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 hashlib -import os import jwt import pytest @@ -10,66 +8,6 @@ import auth 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 -------------------------------------------------------------------- class TestTokens: def test_sign_and_verify_roundtrip(self): @@ -96,93 +34,6 @@ class TestTokens: 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 ----------------------------------------------------------- class TestRequireAuth: def test_missing_header(self, client): diff --git a/backend/tests/test_auth_hub_client.py b/backend/tests/test_auth_hub_client.py new file mode 100644 index 0000000..32eeaec --- /dev/null +++ b/backend/tests/test_auth_hub_client.py @@ -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 diff --git a/backend/tests/test_garmin_mfa.py b/backend/tests/test_garmin_mfa.py index 437d505..e2a9017 100644 --- a/backend/tests/test_garmin_mfa.py +++ b/backend/tests/test_garmin_mfa.py @@ -184,28 +184,20 @@ class TestRendezvousIsNotInMemory: 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( user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make() ) - other = client.post( - "/api/auth/register", - json={"email": "o@example.com", "garminEmail": "og@example.com", - "garminPassword": "pw123456"}, - ).get_json() + other = make_user("o@example.com") 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( user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make() ) wait_status(sid, "awaiting_code") - other = client.post( - "/api/auth/register", - json={"email": "o2@example.com", "garminEmail": "og2@example.com", - "garminPassword": "pw123456"}, - ).get_json() + other = make_user("o2@example.com") ok, _ = garmin_auth.submit_code(sid, other["id"], "123456") assert ok is False @@ -243,7 +235,9 @@ class TestEndpoints: garmin_auth, "start_login", lambda *a, **k: "session-123" ) 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.get_json()["session"] == "session-123" @@ -263,7 +257,9 @@ class TestEndpoints: garmin_svc, "_import_garmin", StubGarmin.make() ) 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"] diff --git a/backend/tests/test_garmin_sync.py b/backend/tests/test_garmin_sync.py index 89d4190..0e89087 100644 --- a/backend/tests/test_garmin_sync.py +++ b/backend/tests/test_garmin_sync.py @@ -305,16 +305,51 @@ class TestTokenStore: assert len(rows) == 1 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") - other = client.post( - "/api/auth/register", - json={"email": "o@example.com", "garminEmail": "og@example.com", - "garminPassword": "pw123456"}, - ).get_json() + other = make_user("o@example.com") 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: def test_eof_from_the_mfa_prompt_becomes_an_actionable_error( self, db, user, monkeypatch diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index a97db4e..06ed8ba 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -219,23 +219,15 @@ class TestBadgesAndRecords: assert len(rows) == 1 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) - other = client.post( - "/api/auth/register", - json={"email": "b@example.com", "garminEmail": "bg@example.com", - "garminPassword": "pw123456"}, - ).get_json() + other = make_user("b@example.com") 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 must not collide.""" - other = client.post( - "/api/auth/register", - json={"email": "c@example.com", "garminEmail": "cg@example.com", - "garminPassword": "pw123456"}, - ).get_json() + other = make_user("c@example.com") health_svc.upsert_badge(user["id"], self.BADGE) health_svc.upsert_badge(other["id"], self.BADGE) assert len(health_svc.get_badges(user["id"])) == 1 diff --git a/backend/tests/test_registration_policy.py b/backend/tests/test_registration_policy.py deleted file mode 100644 index 50f79f9..0000000 --- a/backend/tests/test_registration_policy.py +++ /dev/null @@ -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 diff --git a/backend/tests/test_scheduler.py b/backend/tests/test_scheduler.py index 9fb8e40..513799f 100644 --- a/backend/tests/test_scheduler.py +++ b/backend/tests/test_scheduler.py @@ -78,14 +78,10 @@ class TestSyncAllAccounts: def test_no_accounts_is_a_no_op(self, db, user): 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 - other = client.post( - "/api/auth/register", - json={"email": "b@example.com", "garminEmail": "bg@example.com", - "garminPassword": "pw123456"}, - ).get_json() + other = make_user("b@example.com") garmin_svc.save_token(user["id"], "t1", "a@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) 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 - other = client.post( - "/api/auth/register", - json={"email": "c@example.com", "garminEmail": "cg@example.com", - "garminPassword": "pw123456"}, - ).get_json() + other = make_user("c@example.com") garmin_svc.save_token(user["id"], "t1") garmin_svc.save_token(other["id"], "t2") diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index 4a25e17..91882dc 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -65,12 +65,8 @@ class TestSaving: svc.save_settings(user["id"], {}) assert svc.get_settings(user["id"])["heightCm"] == 180 - def test_settings_are_per_account(self, db, user, client): - other = client.post( - "/api/auth/register", - json={"email": "b@example.com", "garminEmail": "bg@example.com", - "garminPassword": "pw123456"}, - ).get_json() + def test_settings_are_per_account(self, db, user, make_user): + other = make_user("b@example.com") svc.save_settings(user["id"], {"heightCm": 178}) svc.save_settings(other["id"], {"heightCm": 160}) assert svc.get_settings(user["id"])["heightCm"] == 178 diff --git a/client/src/pages/Login.css b/client/src/pages/Login.css index bf0068a..3d08a6d 100644 --- a/client/src/pages/Login.css +++ b/client/src/pages/Login.css @@ -41,41 +41,6 @@ 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 { padding: 0.7rem 1rem; background: var(--accent-solid); @@ -98,3 +63,20 @@ opacity: 0.55; 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; +} diff --git a/client/src/pages/Login.tsx b/client/src/pages/Login.tsx deleted file mode 100644 index b09988f..0000000 --- a/client/src/pages/Login.tsx +++ /dev/null @@ -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('login'); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - // 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 ( -
-
-
-

🏃 Garmin Health Lab

-

健康数据分析平台

-
- -
- - {canRegister && ( - - )} -
- - {error &&
{error}
} - - {activeTab === 'login' && ( -
-
- - setLoginEmail(e.target.value)} - placeholder="example@example.com" - required - disabled={loading} - /> -
- -
- - setLoginPassword(e.target.value)} - placeholder="••••••••" - required - disabled={loading} - /> -
- - -
- )} - - {activeTab === 'register' && canRegister && ( -
-
- - setRegEmail(e.target.value)} - placeholder="example@example.com" - required - disabled={loading} - /> -
- -
- - setRegGarminEmail(e.target.value)} - placeholder="garmin@example.com" - required - disabled={loading} - /> -
- -
- - setRegPassword(e.target.value)} - placeholder="••••••••" - required - disabled={loading} - /> -
- -
- - setRegConfirmPassword(e.target.value)} - placeholder="••••••••" - required - disabled={loading} - /> -
- - -
- )} -
-
- ); -} - -export default Login; diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index f93be24..e2aaf13 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -1,108 +1,73 @@ -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Page } from 'framework7-react'; import { apiClient, errorMessage } from '../services/api'; import './Login.css'; -type TabType = 'login' | 'register'; - function Login() { - const [activeTab, setActiveTab] = useState('login'); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); - // 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)); + // Check if we're handling the OAuth callback from auth-hub + const params = new URLSearchParams(window.location.search); + const code = params.get('code'); + if (code) { + handleAuthHubCallback(code); + } }, []); - // 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); - + const handleAuthHubCallback = async (code: string) => { try { - const { token } = await apiClient.login(loginEmail, loginPassword); - // No 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(token); + setLoading(true); + // Retrieve code_verifier from sessionStorage + const codeVerifier = sessionStorage.getItem('auth_hub_code_verifier'); + if (!codeVerifier) { + 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) { + // 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, '登录失败')); } 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); - + const handleAuthHubLogin = async () => { try { - const { token } = await apiClient.register(regEmail, regGarminEmail, regPassword); - // No 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(token); + setLoading(true); + setError(''); + const response = await apiClient.authHubStart(); + + // 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) { - setError(errorMessage(err, '注册失败')); - } finally { + setError(errorMessage(err, '无法启动登录')); setLoading(false); } }; @@ -110,130 +75,24 @@ function Login() { return (
-
-
-

🏃 Garmin Health Lab

-

健康数据分析平台

-
+
+
+

🏃 Garmin Health Lab

+

健康数据分析平台

+
-
- - {canRegister && ( + {error &&
{error}
} + +
+

使用统一身份认证平台登录

- )} -
- - {error &&
{error}
} - - {activeTab === 'login' && ( -
-
- - setLoginEmail(e.target.value)} - placeholder="example@example.com" - required - disabled={loading} - /> -
- -
- - setLoginPassword(e.target.value)} - placeholder="••••••••" - required - disabled={loading} - /> -
- - -
- )} - - {activeTab === 'register' && canRegister && ( -
-
- - setRegEmail(e.target.value)} - placeholder="example@example.com" - required - disabled={loading} - /> -
- -
- - setRegGarminEmail(e.target.value)} - placeholder="garmin@example.com" - required - disabled={loading} - /> -
- -
- - setRegPassword(e.target.value)} - placeholder="••••••••" - required - disabled={loading} - /> -
- -
- - setRegConfirmPassword(e.target.value)} - placeholder="••••••••" - required - disabled={loading} - /> -
- - -
- )} +
diff --git a/client/src/services/api.ts b/client/src/services/api.ts index 2cd370b..fd3a50c 100644 --- a/client/src/services/api.ts +++ b/client/src/services/api.ts @@ -10,7 +10,7 @@ export const AUTH_EVENT = 'ghl:auth'; // {success, data} envelope, so responses are read as `response.data` directly. export interface AuthResponse { id: string; - email: string; + username: string; token: string; } @@ -408,31 +408,6 @@ class ApiClient { } // --- auth --- - async register(email: string, garminEmail: string, garminPassword: string) { - const { data } = await this.client.post('/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('/auth/login', { - email, - password, - }); - return data; - } - async logout() { try { 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('/auth/callback', { + params: { code, code_verifier: codeVerifier } + }); + return data; + } + // --- garmin --- /** * With a stored OAuth token no password is needed. Without one, the