feat(auth): 接入 auth-hub 统一登录,网页登录与 Garmin 同步彻底分离

网页身份改由 auth-hub 做 OAuth2 + PKCE 单点登录,本地邮箱/密码登录与注册整条链路删除
(routes/auth.py、auth.py 的密码哈希、config.py 的 ALLOW_REGISTRATION)。Garmin 账号绑定/
同步保持完全独立、可选:routes/garmin.py 不再直接查 users 表,Garmin 邮箱回退统一走新增
的 services/garmin.py::get_remembered_email()(优先读 garmin_tokens 当前绑定,兼容早期账号
落在 users.garmin_email 的历史值),彻底把「你是谁」和「你绑没绑 Garmin」两件事拆开。

- db.py: users 表新增 auth_hub_sub/auth_hub_username,MIGRATIONS 补上这两列(此前遗漏导致
  已存在的生产 MariaDB 表永远不会自动加列);同时把历史遗留的 garmin_email/
  garmin_password_hash NOT NULL 约束在线迁移为可空,因为新账号不再在注册时收集这些字段。
- routes/auth.py: 修掉 /callback 路由重复拼接 /api/auth 前缀导致 404 的 bug。
- client: LoginPage 去掉本地登录/注册标签页,只保留 auth-hub 统一登录;登录成功/失败后都
  用 history.replaceState 清理地址栏,修掉 Framework7 browserHistory 读取
  /auth/callback?code=... 导致「找不到页面」的问题。
- 新增 test_auth_hub_client.py 锁定 find_or_create_user 按 auth_hub_sub 幂等——生产上曾经因为
  这个函数在没有该测试保护时被测试触发,误建过一个空账号,靠手工核对 health_data 计数才发现。
- 生产 auth-hub 侧另行为该项目注册了正式 client(未随本次提交变更,凭证只存在服务器 .env)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-08-31 23:12:17 +08:00
parent 7e51223eb9
commit 9503fca370
24 changed files with 592 additions and 1007 deletions

View File

@@ -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,
})