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

@@ -41,41 +41,6 @@
margin: 0;
}
.login-tabs {
display: flex;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
.tab-button {
flex: 1;
padding: 0.6rem;
border: none;
background: none;
color: var(--text-muted);
font-size: 0.92rem;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.15s ease, border-color 0.15s ease;
font-family: inherit;
}
.tab-button:hover {
color: var(--text-primary);
}
.tab-button.active {
color: var(--accent);
border-bottom-color: var(--accent);
font-weight: 600;
}
.login-form {
display: flex;
flex-direction: column;
}
.submit-button {
padding: 0.7rem 1rem;
background: var(--accent-solid);
@@ -98,3 +63,20 @@
opacity: 0.55;
cursor: not-allowed;
}
.auth-hub-panel {
display: flex;
flex-direction: column;
gap: 1rem;
}
.auth-hub-description {
font-size: 0.85rem;
color: var(--text-muted);
margin: 0;
text-align: center;
}
.auth-hub-button {
margin-top: 0.5rem;
}

View File

@@ -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;

View File

@@ -1,108 +1,73 @@
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { Page } from 'framework7-react';
import { apiClient, errorMessage } from '../services/api';
import './Login.css';
type TabType = 'login' | 'register';
function Login() {
const [activeTab, setActiveTab] = useState<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));
// Check if we're handling the OAuth callback from auth-hub
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
if (code) {
handleAuthHubCallback(code);
}
}, []);
// Login form
const [loginEmail, setLoginEmail] = useState('');
const [loginPassword, setLoginPassword] = useState('');
// Register form
const [regEmail, setRegEmail] = useState('');
const [regGarminEmail, setRegGarminEmail] = useState('');
const [regPassword, setRegPassword] = useState('');
const [regConfirmPassword, setRegConfirmPassword] = useState('');
const validateEmail = (email: string): boolean => {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
};
const validatePassword = (password: string): boolean => {
return password.length >= 6;
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!validateEmail(loginEmail)) {
setError('Please enter a valid email');
return;
}
if (!validatePassword(loginPassword)) {
setError('Password must be at least 6 characters');
return;
}
setLoading(true);
const handleAuthHubCallback = async (code: string) => {
try {
const { token } = await apiClient.login(loginEmail, loginPassword);
// No navigation here: setSession tells the shell there is a session,
// and it swaps the login view for the tab bar. Routing from a view that
// is about to be unmounted races that swap.
apiClient.setSession(token);
setLoading(true);
// Retrieve code_verifier from sessionStorage
const codeVerifier = sessionStorage.getItem('auth_hub_code_verifier');
if (!codeVerifier) {
setError('登录会话已过期,请重试');
return;
}
const response = await apiClient.authHubCallback(code, codeVerifier);
// The tab bar's main view reads the browser's current URL
// (browserHistory) to pick its initial route the moment it mounts.
// Left on /auth/callback?code=..., that lookup fails and shows a
// "page not found" screen instead of 今日. Clear it before flipping
// the session so the shell mounts against a clean "/".
window.history.replaceState({}, '', '/');
// No further navigation here: setSession tells the shell there is a
// session, and it swaps the login view for the tab bar. Routing from a
// view that is about to be unmounted races that swap.
apiClient.setSession(response.token);
// Clean up session storage
sessionStorage.removeItem('auth_hub_code_verifier');
sessionStorage.removeItem('auth_hub_state');
} catch (err: any) {
// The code auth-hub issued is single-use and now spent either way;
// leaving it in the address bar would just re-fail identically on a
// page refresh.
window.history.replaceState({}, '', '/login/');
setError(errorMessage(err, '登录失败'));
} finally {
setLoading(false);
}
};
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!validateEmail(regEmail)) {
setError('Please enter a valid email');
return;
}
if (!validateEmail(regGarminEmail)) {
setError('Please enter a valid Garmin email');
return;
}
if (!validatePassword(regPassword)) {
setError('Password must be at least 6 characters');
return;
}
if (regPassword !== regConfirmPassword) {
setError('Passwords do not match');
return;
}
setLoading(true);
const handleAuthHubLogin = async () => {
try {
const { token } = await apiClient.register(regEmail, regGarminEmail, regPassword);
// No navigation here: setSession tells the shell there is a session,
// and it swaps the login view for the tab bar. Routing from a view that
// is about to be unmounted races that swap.
apiClient.setSession(token);
setLoading(true);
setError('');
const response = await apiClient.authHubStart();
// Store PKCE parameters in sessionStorage for the callback
sessionStorage.setItem('auth_hub_code_verifier', response.code_verifier);
sessionStorage.setItem('auth_hub_state', response.state);
// Redirect to auth-hub
window.location.href = response.auth_url;
} catch (err: any) {
setError(errorMessage(err, '注册失败'));
} finally {
setError(errorMessage(err, '无法启动登录'));
setLoading(false);
}
};
@@ -110,130 +75,24 @@ function Login() {
return (
<Page className="login-page" noNavbar noToolbar>
<div className="login-container">
<div className="login-card">
<div className="login-header">
<h1>🏃 Garmin Health Lab</h1>
<p></p>
</div>
<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 && (
{error && <div className="screen-error">{error}</div>}
<div className="auth-hub-panel">
<p className="auth-hub-description">使</p>
<button
className={`tab-button ${activeTab === 'register' ? 'active' : ''}`}
onClick={() => {
setActiveTab('register');
setError('');
}}
className="submit-button auth-hub-button"
onClick={handleAuthHubLogin}
disabled={loading}
>
{loading ? '跳转中...' : '登录'}
</button>
)}
</div>
{error && <div className="screen-error">{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>
</div>
</Page>

View File

@@ -10,7 +10,7 @@ export const AUTH_EVENT = 'ghl:auth';
// {success, data} envelope, so responses are read as `response.data` directly.
export interface AuthResponse {
id: string;
email: string;
username: string;
token: string;
}
@@ -408,31 +408,6 @@ class ApiClient {
}
// --- auth ---
async register(email: string, garminEmail: string, garminPassword: string) {
const { data } = await this.client.post<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() {
try {
await this.client.post('/auth/logout');
@@ -441,6 +416,23 @@ class ApiClient {
}
}
// --- auth-hub OAuth2 ---
async authHubStart() {
const { data } = await this.client.post<{
auth_url: string;
code_verifier: string;
state: string;
}>('/auth/auth-hub/start', {});
return data;
}
async authHubCallback(code: string, codeVerifier: string) {
const { data } = await this.client.get<AuthResponse>('/auth/callback', {
params: { code, code_verifier: codeVerifier }
});
return data;
}
// --- garmin ---
/**
* With a stored OAuth token no password is needed. Without one, the