网页身份改由 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>
107 lines
3.4 KiB
Python
107 lines
3.4 KiB
Python
"""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
|
|
|
|
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__)
|
|
|
|
|
|
@bp.route("/logout", methods=["POST"])
|
|
@require_auth
|
|
def logout():
|
|
execute("UPDATE users SET jwt_token = NULL WHERE id = ?", [g.user_id])
|
|
return jsonify({"message": "ok"})
|
|
|
|
|
|
@bp.route("/refresh", methods=["POST"])
|
|
@require_auth
|
|
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,
|
|
})
|