fix(auth): 修复登录后反复报未配置账号并跳回登录页的死循环
根因:ny_save_config 只写不校验,Db::settingsSet 内部吞异常且返回 void。 data/ 目录不可写时登录页照常显示登录成功,但下一请求读到的账号仍是空的 → 抛未配置卷王登录账号 → 前端 auth 打标 → 跳登录页 → 无限循环。 修复: - Db::settingsSet 改返回 bool 并回读校验;新增 settingsDelete - ny_save_config 双写后均做回读校验,失败写入 ny_config_save_error() - 新增 ny_config_diag()/ny_session_usable(),暴露来源/可写性/是否已存账号 - ny_load_config 把 null 账号强制成空字符串(脏数据不再伪装成未配置) - login.php 存凭据后回读确认,存不住/会话不可写直接报错而不是假成功 - login.php 账号为空时页面直接给出 data 目录写权限提示 - config_save 合并逻辑抽成 ny_apply_config_input:账号/密码不许被改空 - 鉴权失败响应带 extra.diag,前端 401 也算 auth - 前端防死循环:刚登录 120s 内或已跳 2 次则停止自动跳转,弹窗给出真实原因+诊断 测试:新增 tests/test-config-persist.php(29) 与 tools/test-login-loop.js(19); run-all.sh 纳入两者。全套 148+48=196 项通过。
This commit is contained in:
+109
-16
@@ -31,6 +31,8 @@
|
||||
_sorted: null, // 虚拟滚动:最近一次排序后的完整行数组(缓存,避免重复排序)
|
||||
_kwDebounce: null, // 搜索框防抖定时器
|
||||
csrf: '', // CSRF token(来自 config_get),写请求自动附带
|
||||
_authRedirecting: false, // 鉴权失败已接管(防止并发请求重复跳登录页)
|
||||
_authStopped: false, // 鉴权失败已停自动刷新(等用户处理,不再定时重试)
|
||||
};
|
||||
|
||||
/* ---------------- 本地存储 ---------------- */
|
||||
@@ -89,7 +91,9 @@
|
||||
}
|
||||
if (!json.ok) {
|
||||
const err = new Error(json.msg || '请求失败');
|
||||
err.auth = !!(json.extra && json.extra.auth);
|
||||
// 401 = 本系统会话已失效,与"卷王鉴权失败"同样需要重新登录;
|
||||
// handleAuthError 里的防死循环闸门会保证"刚登录完又失败"时不再反复跳转。
|
||||
err.auth = !!(json.extra && json.extra.auth) || res.status === 401;
|
||||
err.extra = json.extra || null;
|
||||
throw err;
|
||||
}
|
||||
@@ -154,6 +158,106 @@
|
||||
return '已导出' + what + (parts.length ? '(' + parts.join(' / ') + ')' : '(全部班级 / 组别)');
|
||||
}
|
||||
|
||||
/* ---------------- 鉴权失败处理(含防死循环) ----------------
|
||||
* 死循环场景:登录页登录成功 → 进主页 → 拉数据仍报"未配置账号/登录失败" → 自动跳登录页 → 再登录……
|
||||
* 用户看到的就是"一直让重新登录、根本用不了"。这里加两道闸门:
|
||||
* 1) 刚登录完(120 秒内)又鉴权失败 → 不再自动跳转,直接把服务端真实原因 + 落盘诊断摊开给用户
|
||||
* 2) 同一会话已自动跳转过 2 次 → 同样停在页面报错
|
||||
*/
|
||||
const AUTH_FRESH_MS = 120000;
|
||||
const AUTH_MAX_JUMPS = 2;
|
||||
|
||||
function authJustLoggedIn() {
|
||||
try {
|
||||
const t = parseInt(sessionStorage.getItem('ny_login_at') || '0', 10);
|
||||
return t > 0 && (Date.now() - t) < AUTH_FRESH_MS;
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
function authJumpCount() {
|
||||
try { return parseInt(sessionStorage.getItem('ny_auth_jumps') || '0', 10) || 0; } catch (e) { return 0; }
|
||||
}
|
||||
function stopAutoRefresh() {
|
||||
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
||||
if (state.hlTimer) { clearInterval(state.hlTimer); state.hlTimer = null; }
|
||||
state._authStopped = true;
|
||||
}
|
||||
function forceRelogin() {
|
||||
stopAutoRefresh();
|
||||
// 必须先清服务端会话,否则 login.php 发现会话还在会把人踢回 index.php,跳不过去
|
||||
fetch('logout.php', { method: 'POST', credentials: 'same-origin' })
|
||||
.catch(() => {})
|
||||
.finally(() => { window.location.href = 'login.php'; });
|
||||
}
|
||||
|
||||
function diagLines(diag) {
|
||||
if (!diag) return [];
|
||||
const yn = v => (v ? '是' : '否');
|
||||
return [
|
||||
'配置来源:' + (diag.storage || '-') + '(配置文件可写=' + yn(diag.cfg_writable) + ')',
|
||||
'数据目录:' + (diag.data_dir || '-') + '(可写=' + yn(diag.dir_writable) + ')',
|
||||
'数据库:' + (diag.db_connected ? ('已连接(可写=' + yn(diag.db_writable) + ')') : ('未连接 ' + (diag.db_error || ''))),
|
||||
'已存账号:' + (diag.has_username ? (diag.username || '已设置') : '空 ← 这就是「未配置账号」的原因'),
|
||||
'已存密码:' + (diag.has_password ? '已保存' : '空'),
|
||||
diag.save_error ? ('保存异常:' + diag.save_error) : '',
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function showAuthFailure(e) {
|
||||
const lines = diagLines(e && e.extra ? e.extra.diag : null);
|
||||
const box = document.createElement('div');
|
||||
box.className = 'modal open';
|
||||
box.innerHTML =
|
||||
'<div class="modal-box" style="width:600px">' +
|
||||
'<div class="modal-head"><h3>无法访问卷王</h3><button class="x" data-close>×</button></div>' +
|
||||
'<div class="modal-body">' +
|
||||
'<p style="margin:0 0 10px;line-height:1.7">' + esc((e && e.message) || '鉴权失败') + '</p>' +
|
||||
'<p style="margin:0 0 10px;line-height:1.7;color:var(--text-2)">' +
|
||||
'为避免一直把您踢回登录页,已停止自动跳转。请按下面提示处理后点【重试】。</p>' +
|
||||
(lines.length
|
||||
? '<pre style="white-space:pre-wrap;margin:0 0 10px;padding:10px 12px;border-radius:8px;background:var(--border-2);font-size:12px;line-height:1.9">' + esc(lines.join('\n')) + '</pre>'
|
||||
: '') +
|
||||
'<p style="margin:0;line-height:1.7;color:var(--text-2)">常见原因:① data 目录不可写(账号密码存不住);' +
|
||||
'② 卷王账号/密码已变更;③ 卷王地址不可达。</p>' +
|
||||
'</div>' +
|
||||
'<div class="modal-foot">' +
|
||||
'<button class="btn" data-auth-retry>重试</button>' +
|
||||
'<button class="btn" data-auth-settings>打开设置</button>' +
|
||||
'<button class="btn btn-primary" data-auth-relogin>重新登录</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
document.body.appendChild(box);
|
||||
box.querySelector('[data-auth-retry]').onclick = () => {
|
||||
box.remove();
|
||||
state._authRedirecting = false;
|
||||
state._authStopped = false;
|
||||
refresh(false, true); // 非静默 + 强制拉取
|
||||
};
|
||||
box.querySelector('[data-auth-settings]').onclick = () => {
|
||||
box.remove();
|
||||
const b = document.getElementById('btnSettings');
|
||||
if (b) b.click();
|
||||
};
|
||||
box.querySelector('[data-auth-relogin]').onclick = () => { box.remove(); forceRelogin(); };
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一处理鉴权类错误;返回 true 表示已接管(调用方直接 return)。
|
||||
*/
|
||||
function handleAuthError(e) {
|
||||
if (state._authRedirecting) return true;
|
||||
state._authRedirecting = true;
|
||||
const jumps = authJumpCount();
|
||||
if (authJustLoggedIn() || jumps >= AUTH_MAX_JUMPS) {
|
||||
stopAutoRefresh();
|
||||
showAuthFailure(e);
|
||||
return true;
|
||||
}
|
||||
try { sessionStorage.setItem('ny_auth_jumps', String(jumps + 1)); } catch (err) {}
|
||||
showAlert('卷王账号未配置或登录已失效,正在跳转登录页…', 'error');
|
||||
forceRelogin();
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ---------------- 问卷列表 ---------------- */
|
||||
// 把数组渲染到 #projectSel(抽出便于缓存复用)
|
||||
function renderProjects() {
|
||||
@@ -234,6 +338,7 @@
|
||||
LS.set(LS_PROJECTS, state.projects);
|
||||
renderProjects();
|
||||
} catch (e) {
|
||||
if (e.auth) { handleAuthError(e); return; }
|
||||
if (!cached || !cached.length) {
|
||||
setConn('conn-err', '连接失败');
|
||||
showAlert('无法连接卷王系统:' + e.message + '\n请点击右上角【设置】检查系统地址、接口前缀与账号密码。');
|
||||
@@ -374,21 +479,7 @@
|
||||
LS.set(LS_ROSTER, { ts: Date.now(), students: d.roster || [] });
|
||||
} catch (e) {
|
||||
if (e.auth) {
|
||||
// 卷王账号未配置或登录已失效:引导重新登录(强制清掉本地会话 + 跳登录页)
|
||||
// 关键:必须先调 logout.php 把服务端会话清空,否则 login.php 发现会话还在会把人踢回 index.php,形成"跳不过去"的死循环
|
||||
if (!state._authRedirecting) {
|
||||
state._authRedirecting = true;
|
||||
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
||||
if (state.hlTimer) clearInterval(state.hlTimer);
|
||||
showAlert('卷王账号未配置或登录已失效,正在跳转登录页…', 'error');
|
||||
// 异步清会话,忽略失败;登录页端也有探测兜底(token 过期时主动销毁会话)
|
||||
fetch('logout.php', { method: 'POST', credentials: 'same-origin' })
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
// 200ms 足够让 toast 入场,又不至于让用户干等
|
||||
setTimeout(() => { window.location.href = 'login.php'; }, 200);
|
||||
});
|
||||
}
|
||||
handleAuthError(e);
|
||||
return;
|
||||
}
|
||||
if (!cached) {
|
||||
@@ -989,6 +1080,8 @@
|
||||
// 用 setTimeout 链而非 setInterval,便于动态调整间隔;后台标签页暂停刷新,回到前台立即补一次。
|
||||
function schedule() {
|
||||
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
||||
// 鉴权失败已停止自动刷新(handleAuthError),别再排下一轮把用户反复踢走
|
||||
if (state._authStopped) return;
|
||||
if (!$('#autoRefresh').checked) return;
|
||||
const base = (parseInt($('#intervalSel').value, 10) || 15) * 1000;
|
||||
const backoff = Math.min(8, Math.pow(2, state.connFail)) * base;
|
||||
|
||||
Reference in New Issue
Block a user