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