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