This commit is contained in:
biss committed 2026-07-20 22:01:44 +08:00
1 parent 76a1b66f53
commit 1287544c41
17 files changed
+777 -21

No files matched your search

+3
View File
@@ -17,6 +17,9 @@ INITIAL_ADMIN_USERNAME=admin
INITIAL_ADMIN_PASSWORD=Admin123! INITIAL_ADMIN_PASSWORD=Admin123!
INITIAL_ADMIN_DISPLAY_NAME=系统管理员 INITIAL_ADMIN_DISPLAY_NAME=系统管理员
# TOTP 密钥加密主密钥。生产环境必填且至少 32 个字符;修改后已绑定的 TOTP 将无法解密。
# TOTP_ENCRYPTION_KEY=replace-with-a-random-secret-of-at-least-32-characters
# 公开首页文案与联系方式(修改后重启应用生效) # 公开首页文案与联系方式(修改后重启应用生效)
PUBLIC_SITE_NAME=海州市教育考试中心 PUBLIC_SITE_NAME=海州市教育考试中心
PUBLIC_SITE_CODE=HZ-EDU-032 PUBLIC_SITE_CODE=HZ-EDU-032
+5 -1
View File
@@ -65,6 +65,8 @@
### 系统能力 ### 系统能力
- PBKDF2 加盐密码哈希 - PBKDF2 加盐密码哈希
- 可选 TOTP 二次验证,支持验证器扫码绑定、一次性恢复码与登录防重放
- TOTP 密钥使用 AES-256-GCM 加密存储,恢复码仅保存带服务端密钥的哈希
- HttpOnly、SameSite 登录 Cookie - HttpOnly、SameSite 登录 Cookie
- 服务端角色权限校验 - 服务端角色权限校验
- SQLite / MySQL 8.4 双数据库持久化 - SQLite / MySQL 8.4 双数据库持久化
@@ -88,7 +90,9 @@ npm start
打开 <http://127.0.0.1:4173>。 打开 <http://127.0.0.1:4173>。
本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构,但不会导入学校、考生、考试或报名测试数据。首次建库只写入系统基础配置和一个超级管理员;账号、密码和显示名可通过 `INITIAL_ADMIN_USERNAME`、`INITIAL_ADMIN_PASSWORD`、`INITIAL_ADMIN_DISPLAY_NAME` 设置。当前数据库结构版本为 v15;低于 v15 的开发库会提示重建,不执行兼容迁移。 账户可在“账户安全”中启用 TOTP 二次验证。生产环境必须设置至少 32 个字符的 `TOTP_ENCRYPTION_KEY`;该值用于加密 TOTP 密钥并保护恢复码哈希,部署后必须稳定保存,不能随意更换。本地开发未设置时会使用仅适合开发的稳定派生值。
本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构,但不会导入学校、考生、考试或报名测试数据。首次建库只写入系统基础配置和一个超级管理员;账号、密码和显示名可通过 `INITIAL_ADMIN_USERNAME`、`INITIAL_ADMIN_PASSWORD`、`INITIAL_ADMIN_DISPLAY_NAME` 设置。当前数据库结构版本为 v17;v16 数据库会自动增加 TOTP 字段,低于 v15 的开发库会提示重建。
需要清空并重建空业务库时运行 `npm run reset-db`。需要测试数据时再手动运行 `npm run seed-test-data`;导入脚本会读取项目根目录的 `.env`,并根据 `DATABASE_CLIENT` 选择 SQLite 或 MySQL。它会生成 4 所学校、360 名批量考生及 360 条不同状态的报名数据,并明确不生成考场编排计划和准考证。省市区县下拉数据位于 `src/data/china-regions.mjs`,当前版本为国家地名信息库截至 2025-12-31 的三级快照,并补入和康县(653228)与和安县(653229);从新版 CSV 更新时可运行 `node scripts/build-regions.mjs <CSV路径> src/data/china-regions.mjs`。 需要清空并重建空业务库时运行 `npm run reset-db`。需要测试数据时再手动运行 `npm run seed-test-data`;导入脚本会读取项目根目录的 `.env`,并根据 `DATABASE_CLIENT` 选择 SQLite 或 MySQL。它会生成 4 所学校、360 名批量考生及 360 条不同状态的报名数据,并明确不生成考场编排计划和准考证。省市区县下拉数据位于 `src/data/china-regions.mjs`,当前版本为国家地名信息库截至 2025-12-31 的三级快照,并补入和康县(653228)与和安县(653229);从新版 CSV 更新时可运行 `node scripts/build-regions.mjs <CSV路径> src/data/china-regions.mjs`。
+44 -2
View File
@@ -93,6 +93,18 @@ async function refreshSession() {
state.scopeLabel = session.scopeLabel || ''; state.scopeLabel = session.scopeLabel || '';
} }
async function finishLogin(data) {
state.user = data.user;
await refreshSession();
closeModal();
toast(data.usedRecoveryCode ? '已使用恢复码登录' : '登录成功', data.usedRecoveryCode ? '该恢复码已失效,请检查剩余恢复码' : `欢迎,${data.user.displayName}`);
navigate(data.user.role === 'candidate' && (state.user.mustChangePassword || !state.profile?.profileCompleted) ? 'candidate/onboarding' : `${data.user.role}/dashboard`);
}
function showRecoveryCodes(codes) {
setModal(`<div class="modal-head"><div><span>RECOVERY CODES</span><h2>保存一次性恢复码</h2><p>手机丢失或验证器不可用时,可使用其中一个恢复码登录。每个只能使用一次。</p></div></div><div class="recovery-code-sheet" data-recovery-codes>${codes.map(code => `<code>${h(code)}</code>`).join('')}</div><div class="security-warning">请立即复制并离线保存。关闭后系统不会再次显示这些恢复码。</div><div class="modal-foot"><button class="ghost-button" type="button" data-action="copy-recovery-codes">复制全部</button><button class="solid-button" type="button" data-action="close-modal-refresh">我已安全保存</button></div>`);
}
document.addEventListener('click', async event => { document.addEventListener('click', async event => {
const routeTarget = event.target.closest('[data-route]'); const routeTarget = event.target.closest('[data-route]');
if (routeTarget) { if (routeTarget) {
@@ -105,6 +117,17 @@ document.addEventListener('click', async event => {
const action = target.dataset.action; const action = target.dataset.action;
try { try {
if (action === 'close-modal') return closeModal(); if (action === 'close-modal') return closeModal();
if (action === 'close-modal-refresh') { closeModal(); return renderRoute(); }
if (action === 'copy-recovery-codes') {
const codes = [...document.querySelectorAll('[data-recovery-codes] code')].map(item => item.textContent).join('\n');
await navigator.clipboard.writeText(codes);
return toast('恢复码已复制', '请保存到可信的离线位置');
}
if (action === 'copy-totp-secret') {
const secret = document.querySelector('[data-totp-secret]')?.textContent.replace(/\s/g, '') || '';
await navigator.clipboard.writeText(secret);
return toast('手动密钥已复制');
}
if (action === 'retry') return renderRoute(); if (action === 'retry') return renderRoute();
if (action === 'open-sidebar') return document.querySelector('#portalSidebar')?.classList.add('open'); if (action === 'open-sidebar') return document.querySelector('#portalSidebar')?.classList.add('open');
if (action === 'close-sidebar') return document.querySelector('#portalSidebar')?.classList.remove('open'); if (action === 'close-sidebar') return document.querySelector('#portalSidebar')?.classList.remove('open');
@@ -392,8 +415,12 @@ document.addEventListener('submit', async event => {
const kind = form.dataset.form; const kind = form.dataset.form;
if (kind === 'login') { if (kind === 'login') {
const data = await api('/api/auth/login', { method: 'POST', body: formObject(form) }); const data = await api('/api/auth/login', { method: 'POST', body: formObject(form) });
state.user = data.user; await refreshSession(); if (data.requiresTotp) {
toast('登录成功', `欢迎,${data.user.displayName}`); navigate(data.user.role === 'candidate' && (state.user.mustChangePassword || !state.profile?.profileCompleted) ? 'candidate/onboarding' : `${data.user.role}/dashboard`); setModal(`<div class="modal-head"><div><span>SECOND STEP</span><h2>输入动态验证码</h2><p>打开验证器应用,输入当前显示的 6 位验证码;也可以使用一个恢复码。</p></div><button data-action="close-modal">×</button></div><form class="modal-form totp-login-form" data-form="totp-login"><input type="hidden" name="challenge" value="${h(data.challenge)}"><label><span>动态验证码或恢复码</span><input name="code" inputmode="numeric" autocomplete="one-time-code" required autofocus placeholder="000000"></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">返回</button><button type="submit" class="solid-button">验证并登录</button></div></form>`);
} else await finishLogin(data);
} else if (kind === 'totp-login') {
const data = await api('/api/auth/login/totp', { method: 'POST', body: formObject(form) });
await finishLogin(data);
} else if (kind === 'register') { } else if (kind === 'register') {
const data = await api('/api/auth/register', { method: 'POST', body: formObject(form) }); const data = await api('/api/auth/register', { method: 'POST', body: formObject(form) });
setModal(`<div class="modal-head"><div><span>CANDIDATE NUMBER</span><h2>请保存你的报名号</h2><p>该号码就是长期使用的考生账户。</p></div><button data-action="close-modal">×</button></div><div class="issued-number"><span>固定报名号</span><strong>${h(data.registrationNumber)}</strong><p>以后报名不同考试仍使用这个号码。关闭窗口前请抄写或截图保存。</p></div><div class="modal-foot"><button class="solid-button" data-route="login">前往登录</button></div>`); setModal(`<div class="modal-head"><div><span>CANDIDATE NUMBER</span><h2>请保存你的报名号</h2><p>该号码就是长期使用的考生账户。</p></div><button data-action="close-modal">×</button></div><div class="issued-number"><span>固定报名号</span><strong>${h(data.registrationNumber)}</strong><p>以后报名不同考试仍使用这个号码。关闭窗口前请抄写或截图保存。</p></div><div class="modal-foot"><button class="solid-button" data-route="login">前往登录</button></div>`);
@@ -407,6 +434,21 @@ document.addEventListener('submit', async event => {
if (body.newPassword !== body.confirmPassword) throw new Error('两次输入的新密码不一致'); if (body.newPassword !== body.confirmPassword) throw new Error('两次输入的新密码不一致');
await api('/api/auth/change-password', { method: 'POST', body }); await api('/api/auth/change-password', { method: 'POST', body });
form.reset(); toast('密码修改成功', '下次登录请使用新密码'); form.reset(); toast('密码修改成功', '下次登录请使用新密码');
} else if (kind === 'totp-setup') {
const data = await api('/api/auth/totp/setup', { method: 'POST', body: formObject(form) });
setModal(`<div class="modal-head"><div><span>AUTHENTICATOR SETUP</span><h2>扫描二维码</h2><p>在验证器应用中添加账户,然后输入应用显示的 6 位验证码完成绑定。</p></div><button data-action="close-modal">×</button></div><div class="totp-setup-grid"><div class="totp-qr"><img src="${h(data.qrCode)}" alt="TOTP 绑定二维码" width="240" height="240"></div><div class="totp-manual"><span>无法扫码?手动输入密钥</span><code data-totp-secret>${h(data.secret.match(/.{1,4}/g)?.join(' ') || data.secret)}</code><button type="button" class="text-button" data-action="copy-totp-secret">复制密钥</button><small>类型:基于时间 · 6 位 · 每 30 秒更新</small></div></div><form class="modal-form totp-confirm-form" data-form="totp-enable"><label><span>验证器中的 6 位验证码</span><input name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required placeholder="000000"></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">验证并启用</button></div></form>`);
} else if (kind === 'totp-enable') {
const data = await api('/api/auth/totp/enable', { method: 'POST', body: formObject(form) });
state.user = data.user;
showRecoveryCodes(data.recoveryCodes);
} else if (kind === 'totp-recovery-codes') {
const data = await api('/api/auth/totp/recovery-codes', { method: 'POST', body: formObject(form) });
showRecoveryCodes(data.recoveryCodes);
} else if (kind === 'totp-disable') {
await api('/api/auth/totp/disable', { method: 'POST', body: formObject(form) });
await refreshSession();
toast('二次验证已关闭', '账户现在仅使用密码登录');
renderRoute();
} else if (kind === 'candidate-password-reset') { } else if (kind === 'candidate-password-reset') {
const body = formObject(form); const body = formObject(form);
const data = await api(`/api/admin/candidates/${body.id}/reset-password`, { method: 'POST' }); const data = await api(`/api/admin/candidates/${body.id}/reset-password`, { method: 'POST' });
Loaded 3 of 17 files, more files were not shown because too many files have changed in this diff. Show more