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:
+89
-41
@@ -812,11 +812,12 @@ Login Overlay (BIZ-110)
|
||||
<div class="login-card">
|
||||
<div class="login-title">🔐 登录验证</div>
|
||||
<div class="login-subtitle">双色球号码生成系统</div>
|
||||
<div class="login-hint" id="loginHint">请输入访问令牌(LOTTO_API_TOKEN)以继续</div>
|
||||
<input type="password" class="login-input" id="loginTokenInput" placeholder="在此粘贴令牌..." autocomplete="off" onkeydown="if(event.key==='Enter')handleLoginSubmit()">
|
||||
<div class="login-hint" id="loginHint">请输入账号密码以继续(BIZ-129 方案 C)</div>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -998,7 +999,7 @@ const state = {
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Login Page (BIZ-110)
|
||||
// Login Page (BIZ-129 方案 C:username + password + cookie session)
|
||||
// ============================================================
|
||||
function showLoginPage(reason) {
|
||||
const overlay = document.getElementById('loginOverlay');
|
||||
@@ -1010,14 +1011,17 @@ function showLoginPage(reason) {
|
||||
// 提示信息
|
||||
const hint = document.getElementById('loginHint');
|
||||
if (hint) {
|
||||
hint.textContent = reason || '请输入访问令牌(LOTTO_API_TOKEN)以继续';
|
||||
}
|
||||
// 自动聚焦输入框
|
||||
const input = document.getElementById('loginTokenInput');
|
||||
if (input) {
|
||||
input.value = '';
|
||||
setTimeout(() => input.focus(), 50);
|
||||
hint.textContent = reason || '请输入账号密码以继续';
|
||||
}
|
||||
// 清空输入框 + 聚焦用户名
|
||||
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() {
|
||||
@@ -1028,14 +1032,17 @@ function showMainPage() {
|
||||
}
|
||||
|
||||
async function handleLoginSubmit() {
|
||||
const input = document.getElementById('loginTokenInput');
|
||||
const userInput = document.getElementById('loginUsernameInput');
|
||||
const passInput = document.getElementById('loginPasswordInput');
|
||||
const btn = document.getElementById('loginSubmitBtn');
|
||||
const errEl = document.getElementById('loginError');
|
||||
if (!input || !btn) return;
|
||||
const token = (input.value || '').trim();
|
||||
if (!token) {
|
||||
if (!userInput || !passInput || !btn) return;
|
||||
|
||||
const username = (userInput.value || '').trim();
|
||||
const password = passInput.value || '';
|
||||
if (!username || !password) {
|
||||
if (errEl) {
|
||||
errEl.textContent = '令牌不能为空';
|
||||
errEl.textContent = '用户名和密码不能为空';
|
||||
errEl.style.display = 'block';
|
||||
}
|
||||
return;
|
||||
@@ -1045,19 +1052,40 @@ async function handleLoginSubmit() {
|
||||
const oldText = btn.textContent;
|
||||
btn.textContent = '⏳ 验证中...';
|
||||
try {
|
||||
// 试探一下 /api/status 验证 token(BIZ-110:/api/status 不走 require_auth,
|
||||
// 因此用 /api/records 探测,且带回业务数据)
|
||||
setToken(token);
|
||||
await api('/api/records?page=1&page_size=1');
|
||||
// 验证成功
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
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();
|
||||
showToast('✅ 登录成功', 'success');
|
||||
showToast('✅ 登录成功(' + (j.data.user.username || username) + ')', 'success');
|
||||
// 重新加载当前页面数据
|
||||
if (state.currentPage) switchPage(state.currentPage);
|
||||
} catch (e) {
|
||||
setToken('');
|
||||
if (errEl) {
|
||||
errEl.textContent = '令牌无效:' + (e.message || '验证失败');
|
||||
errEl.textContent = '网络错误:' + (e.message || '请检查连接');
|
||||
errEl.style.display = 'block';
|
||||
}
|
||||
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 {
|
||||
return localStorage.getItem(AUTH_STORAGE_KEY) || '';
|
||||
} catch (e) {
|
||||
@@ -1114,47 +1144,53 @@ function getToken() {
|
||||
}
|
||||
}
|
||||
|
||||
function setToken(t) {
|
||||
function setUsername(u) {
|
||||
try {
|
||||
if (t) localStorage.setItem(AUTH_STORAGE_KEY, t);
|
||||
if (u) localStorage.setItem(AUTH_STORAGE_KEY, u);
|
||||
else localStorage.removeItem(AUTH_STORAGE_KEY);
|
||||
} catch (e) { /* localStorage 不可用(隐私模式)—忽略 */ }
|
||||
}
|
||||
|
||||
function isLoggedIn() {
|
||||
return !!getToken();
|
||||
// 是否登录仅看 cookie 是否存在;username 仅为 UI 优化
|
||||
return document.cookie.split(';').some(c => c.trim().startsWith('lotto_session='));
|
||||
}
|
||||
|
||||
function logout(reason) {
|
||||
setToken('');
|
||||
async function logout(reason) {
|
||||
// 调后端 logout 吊销 session,然后清本地状态
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' });
|
||||
} catch (e) {
|
||||
console.warn('logout 请求失败:', e);
|
||||
}
|
||||
setUsername('');
|
||||
showToast(reason || '已退出登录', 'info');
|
||||
showLoginPage();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API Helper (BIZ-110: 注入 Bearer token)
|
||||
// API Helper (BIZ-129: 不再注入 Bearer,cookie 由浏览器自动带上)
|
||||
// ============================================================
|
||||
async function api(path, options = {}) {
|
||||
const opts = { credentials: 'same-origin', ...options }; // 关键:带上 cookie
|
||||
const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) };
|
||||
const token = getToken();
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(path, { ...options, headers });
|
||||
res = await fetch(path, { ...opts, headers });
|
||||
} catch (e) {
|
||||
showToast('网络错误:' + e.message, 'error');
|
||||
throw e;
|
||||
}
|
||||
|
||||
// 401 → 清 token + 跳登录页
|
||||
// 401 → 清 username + 跳登录页(cookie 由浏览器自动清除 — 如果服务器返回 Set-Cookie 过期)
|
||||
if (res.status === 401) {
|
||||
let msg = '未授权访问';
|
||||
try {
|
||||
const j = await res.json();
|
||||
if (j && j.error) msg = j.error;
|
||||
} catch (_) { /* 忽略 JSON 解析错误 */ }
|
||||
setToken('');
|
||||
setUsername('');
|
||||
showLoginPage(msg);
|
||||
throw new Error(msg);
|
||||
}
|
||||
@@ -1762,13 +1798,25 @@ async function compareRecord(id) {
|
||||
// ============================================================
|
||||
// Init
|
||||
// ============================================================
|
||||
function bootstrap() {
|
||||
async function bootstrap() {
|
||||
// BIZ-129 方案 C:检查 cookie + 调 /api/auth/me 验证
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPage();
|
||||
return;
|
||||
}
|
||||
showMainPage();
|
||||
loadStatsOverview();
|
||||
try {
|
||||
const me = await api('/api/auth/me');
|
||||
if (me && me.username) {
|
||||
setUsername(me.username);
|
||||
showMainPage();
|
||||
loadStatsOverview();
|
||||
} else {
|
||||
showLoginPage();
|
||||
}
|
||||
} catch (e) {
|
||||
// 401 已被 api() 拦截;其他错误 → 跳登录页
|
||||
showLoginPage();
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
||||
Reference in New Issue
Block a user