feat(BIZ-129): index.html 前端鉴权适配方案 C(session cookie + username/password)

替换 BIZ-110 Bearer token 模式 → 方案 C(cookie session + 账号密码):
- AUTH_STORAGE_KEY: 'lotto_api_token' → 'lotto_username'
- getToken/setToken → getUsername/setUsername(localStorage 只存用户名供 UI 提示)
- isLoggedIn() 改为检查 cookie 'lotto_session' 存在性(HttpOnly JS 读不到,仅作为 UI 优化)
- api() 去掉 Authorization: Bearer 注入;改用 fetch 的 credentials: 'same-origin' 让浏览器自动带 cookie
- loginOverlay: 单 token 输入框 → username + password 双输入框
- handleLoginSubmit: POST /api/auth/login + 处理 account_locked / rate_limited 等 code
- logout(): POST /api/auth/logout 吊销 session,再清本地 username
- bootstrap(): 启动时调 /api/auth/me 验证会话有效性

安全要点:
- HttpOnly cookie 防 XSS 读取(即使有 XSS 漏洞,攻击者也拿不到 session)
- 401 → 清 username + 跳登录页(api() 统一处理)
- logout() 走服务端吊销,防止 cookie 被持久化复用

依赖:commit 44f46a0 的 auth_api 蓝图 + Flask-Limiter 集成。

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
costcodev
2026-09-05 07:15:36 +08:00
parent 44f46a0e40
commit 1df6e7fa83
+89 -41
View File
@@ -812,11 +812,12 @@ Login Overlay (BIZ-110)
<div class="login-card"> <div class="login-card">
<div class="login-title">🔐 登录验证</div> <div class="login-title">🔐 登录验证</div>
<div class="login-subtitle">双色球号码生成系统</div> <div class="login-subtitle">双色球号码生成系统</div>
<div class="login-hint" id="loginHint">请输入访问令牌(LOTTO_API_TOKEN)以继续</div> <div class="login-hint" id="loginHint">请输入账号密码以继续(BIZ-129 方案 C)</div>
<input type="password" class="login-input" id="loginTokenInput" placeholder="在此粘贴令牌..." autocomplete="off" onkeydown="if(event.key==='Enter')handleLoginSubmit()"> <input type="text" class="login-input" id="loginUsernameInput" placeholder="用户名" autocomplete="username" style="margin-bottom: 10px;">
<input type="password" class="login-input" id="loginPasswordInput" placeholder="密码" autocomplete="current-password" onkeydown="if(event.key==='Enter')handleLoginSubmit()">
<button class="login-btn" id="loginSubmitBtn" onclick="handleLoginSubmit()">登录</button> <button class="login-btn" id="loginSubmitBtn" onclick="handleLoginSubmit()">登录</button>
<div class="login-error" id="loginError" style="display:none;"></div> <div class="login-error" id="loginError" style="display:none;"></div>
<div class="login-foot">令牌保存在浏览器 localStorage,仅本机有效。</div> <div class="login-foot">会话存于 HttpOnly cookie(30 天),XSS 不可读。</div>
</div> </div>
</div> </div>
@@ -998,7 +999,7 @@ const state = {
}; };
// ============================================================ // ============================================================
// Login Page (BIZ-110) // Login Page (BIZ-129 方案 C:username + password + cookie session)
// ============================================================ // ============================================================
function showLoginPage(reason) { function showLoginPage(reason) {
const overlay = document.getElementById('loginOverlay'); const overlay = document.getElementById('loginOverlay');
@@ -1010,14 +1011,17 @@ function showLoginPage(reason) {
// 提示信息 // 提示信息
const hint = document.getElementById('loginHint'); const hint = document.getElementById('loginHint');
if (hint) { if (hint) {
hint.textContent = reason || '请输入访问令牌(LOTTO_API_TOKEN)以继续'; hint.textContent = reason || '请输入账号密码以继续';
}
// 自动聚焦输入框
const input = document.getElementById('loginTokenInput');
if (input) {
input.value = '';
setTimeout(() => input.focus(), 50);
} }
// 清空输入框 + 聚焦用户名
const userInput = document.getElementById('loginUsernameInput');
const passInput = document.getElementById('loginPasswordInput');
if (userInput) { userInput.value = getUsername(); }
if (passInput) { passInput.value = ''; }
setTimeout(() => {
if (userInput && !userInput.value) userInput.focus();
else if (passInput) passInput.focus();
}, 50);
} }
function showMainPage() { function showMainPage() {
@@ -1028,14 +1032,17 @@ function showMainPage() {
} }
async function handleLoginSubmit() { async function handleLoginSubmit() {
const input = document.getElementById('loginTokenInput'); const userInput = document.getElementById('loginUsernameInput');
const passInput = document.getElementById('loginPasswordInput');
const btn = document.getElementById('loginSubmitBtn'); const btn = document.getElementById('loginSubmitBtn');
const errEl = document.getElementById('loginError'); const errEl = document.getElementById('loginError');
if (!input || !btn) return; if (!userInput || !passInput || !btn) return;
const token = (input.value || '').trim();
if (!token) { const username = (userInput.value || '').trim();
const password = passInput.value || '';
if (!username || !password) {
if (errEl) { if (errEl) {
errEl.textContent = '令牌不能为空'; errEl.textContent = '用户名和密码不能为空';
errEl.style.display = 'block'; errEl.style.display = 'block';
} }
return; return;
@@ -1045,19 +1052,40 @@ async function handleLoginSubmit() {
const oldText = btn.textContent; const oldText = btn.textContent;
btn.textContent = '⏳ 验证中...'; btn.textContent = '⏳ 验证中...';
try { try {
// 试探一下 /api/status 验证 token(BIZ-110:/api/status 不走 require_auth, const res = await fetch('/api/auth/login', {
// 因此用 /api/records 探测,且带回业务数据) method: 'POST',
setToken(token); credentials: 'same-origin',
await api('/api/records?page=1&page_size=1'); headers: { 'Content-Type': 'application/json' },
// 验证成功 body: JSON.stringify({ username, password }),
});
const j = await res.json().catch(() => ({}));
if (!res.ok || !j.success) {
const code = j.code || 'login_failed';
const msg = j.error || `登录失败(HTTP ${res.status})`;
if (errEl) {
// 锁定提示额外说明
if (code === 'account_locked') {
errEl.textContent = '⛔ ' + msg;
} else if (code === 'rate_limited') {
errEl.textContent = '⏳ ' + msg;
} else {
errEl.textContent = msg;
}
errEl.style.display = 'block';
}
btn.disabled = false;
btn.textContent = oldText;
return;
}
// 登录成功
setUsername(username);
showMainPage(); showMainPage();
showToast('✅ 登录成功', 'success'); showToast('✅ 登录成功(' + (j.data.user.username || username) + ')', 'success');
// 重新加载当前页面数据 // 重新加载当前页面数据
if (state.currentPage) switchPage(state.currentPage); if (state.currentPage) switchPage(state.currentPage);
} catch (e) { } catch (e) {
setToken('');
if (errEl) { if (errEl) {
errEl.textContent = '令牌无效:' + (e.message || '验证失败'); errEl.textContent = '网络错误:' + (e.message || '请检查连接');
errEl.style.display = 'block'; errEl.style.display = 'block';
} }
btn.disabled = false; btn.disabled = false;
@@ -1102,11 +1130,13 @@ function switchPage(page) {
} }
// ============================================================ // ============================================================
// Auth (BIZ-110) // Auth (BIZ-129 方案 C:session cookie + username/password)
// ============================================================ // ============================================================
const AUTH_STORAGE_KEY = 'lotto_api_token'; // 架构决策:HttpOnly cookie 走 session,localStorage 只存 username(用于 UI 提示)
// session 本身由服务器写入 HttpOnly cookie,前端 JS 读不到——完全防 XSS。
const AUTH_STORAGE_KEY = 'lotto_username';
function getToken() { function getUsername() {
try { try {
return localStorage.getItem(AUTH_STORAGE_KEY) || ''; return localStorage.getItem(AUTH_STORAGE_KEY) || '';
} catch (e) { } catch (e) {
@@ -1114,47 +1144,53 @@ function getToken() {
} }
} }
function setToken(t) { function setUsername(u) {
try { try {
if (t) localStorage.setItem(AUTH_STORAGE_KEY, t); if (u) localStorage.setItem(AUTH_STORAGE_KEY, u);
else localStorage.removeItem(AUTH_STORAGE_KEY); else localStorage.removeItem(AUTH_STORAGE_KEY);
} catch (e) { /* localStorage 不可用(隐私模式)—忽略 */ } } catch (e) { /* localStorage 不可用(隐私模式)—忽略 */ }
} }
function isLoggedIn() { function isLoggedIn() {
return !!getToken(); // 是否登录仅看 cookie 是否存在;username 仅为 UI 优化
return document.cookie.split(';').some(c => c.trim().startsWith('lotto_session='));
} }
function logout(reason) { async function logout(reason) {
setToken(''); // 调后端 logout 吊销 session,然后清本地状态
try {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' });
} catch (e) {
console.warn('logout 请求失败:', e);
}
setUsername('');
showToast(reason || '已退出登录', 'info'); showToast(reason || '已退出登录', 'info');
showLoginPage(); showLoginPage();
} }
// ============================================================ // ============================================================
// API Helper (BIZ-110: 注入 Bearer token) // API Helper (BIZ-129: 不再注入 Bearer,cookie 由浏览器自动带上)
// ============================================================ // ============================================================
async function api(path, options = {}) { async function api(path, options = {}) {
const opts = { credentials: 'same-origin', ...options }; // 关键:带上 cookie
const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) }; const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) };
const token = getToken();
if (token) headers['Authorization'] = 'Bearer ' + token;
let res; let res;
try { try {
res = await fetch(path, { ...options, headers }); res = await fetch(path, { ...opts, headers });
} catch (e) { } catch (e) {
showToast('网络错误:' + e.message, 'error'); showToast('网络错误:' + e.message, 'error');
throw e; throw e;
} }
// 401 → 清 token + 跳登录页 // 401 → 清 username + 跳登录页(cookie 由浏览器自动清除 — 如果服务器返回 Set-Cookie 过期)
if (res.status === 401) { if (res.status === 401) {
let msg = '未授权访问'; let msg = '未授权访问';
try { try {
const j = await res.json(); const j = await res.json();
if (j && j.error) msg = j.error; if (j && j.error) msg = j.error;
} catch (_) { /* 忽略 JSON 解析错误 */ } } catch (_) { /* 忽略 JSON 解析错误 */ }
setToken(''); setUsername('');
showLoginPage(msg); showLoginPage(msg);
throw new Error(msg); throw new Error(msg);
} }
@@ -1762,13 +1798,25 @@ async function compareRecord(id) {
// ============================================================ // ============================================================
// Init // Init
// ============================================================ // ============================================================
function bootstrap() { async function bootstrap() {
// BIZ-129 方案 C:检查 cookie + 调 /api/auth/me 验证
if (!isLoggedIn()) { if (!isLoggedIn()) {
showLoginPage(); showLoginPage();
return; return;
} }
showMainPage(); try {
loadStatsOverview(); const me = await api('/api/auth/me');
if (me && me.username) {
setUsername(me.username);
showMainPage();
loadStatsOverview();
} else {
showLoginPage();
}
} catch (e) {
// 401 已被 api() 拦截;其他错误 → 跳登录页
showLoginPage();
}
} }
bootstrap(); bootstrap();