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-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();
|
||||||
|
|||||||
Reference in New Issue
Block a user