TOTP
This commit is contained in:
@@ -17,6 +17,9 @@ INITIAL_ADMIN_USERNAME=admin
|
||||
INITIAL_ADMIN_PASSWORD=Admin123!
|
||||
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_CODE=HZ-EDU-032
|
||||
|
||||
@@ -65,6 +65,8 @@
|
||||
### 系统能力
|
||||
|
||||
- PBKDF2 加盐密码哈希
|
||||
- 可选 TOTP 二次验证,支持验证器扫码绑定、一次性恢复码与登录防重放
|
||||
- TOTP 密钥使用 AES-256-GCM 加密存储,恢复码仅保存带服务端密钥的哈希
|
||||
- HttpOnly、SameSite 登录 Cookie
|
||||
- 服务端角色权限校验
|
||||
- SQLite / MySQL 8.4 双数据库持久化
|
||||
@@ -88,7 +90,9 @@ npm start
|
||||
|
||||
打开 <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`。
|
||||
|
||||
|
||||
@@ -93,6 +93,18 @@ async function refreshSession() {
|
||||
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 => {
|
||||
const routeTarget = event.target.closest('[data-route]');
|
||||
if (routeTarget) {
|
||||
@@ -105,6 +117,17 @@ document.addEventListener('click', async event => {
|
||||
const action = target.dataset.action;
|
||||
try {
|
||||
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 === 'open-sidebar') return document.querySelector('#portalSidebar')?.classList.add('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;
|
||||
if (kind === 'login') {
|
||||
const data = await api('/api/auth/login', { method: 'POST', body: formObject(form) });
|
||||
state.user = data.user; await refreshSession();
|
||||
toast('登录成功', `欢迎,${data.user.displayName}`); navigate(data.user.role === 'candidate' && (state.user.mustChangePassword || !state.profile?.profileCompleted) ? 'candidate/onboarding' : `${data.user.role}/dashboard`);
|
||||
if (data.requiresTotp) {
|
||||
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') {
|
||||
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>`);
|
||||
@@ -407,6 +434,21 @@ document.addEventListener('submit', async event => {
|
||||
if (body.newPassword !== body.confirmPassword) throw new Error('两次输入的新密码不一致');
|
||||
await api('/api/auth/change-password', { method: 'POST', body });
|
||||
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') {
|
||||
const body = formObject(form);
|
||||
const data = await api(`/api/admin/candidates/${body.id}/reset-password`, { method: 'POST' });
|
||||
|
||||
+16
-1
@@ -59,7 +59,7 @@ export function buildSeedOperations(state) {
|
||||
const nullable = value => value == null || value === '' ? null : value;
|
||||
|
||||
add(
|
||||
'UPDATE schema_metadata SET schema_version = 16, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
|
||||
'UPDATE schema_metadata SET schema_version = 17, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
|
||||
Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0,
|
||||
state.meta?.createdAt || new Date().toISOString()
|
||||
);
|
||||
@@ -471,6 +471,10 @@ function stateFromRows(rows) {
|
||||
classId: row.class_id || null,
|
||||
active: row.active == null ? true : Boolean(row.active),
|
||||
mustChangePassword: Boolean(row.must_change_password),
|
||||
totpEnabled: Boolean(row.totp_enabled),
|
||||
totpSecretEncrypted: row.totp_secret_encrypted || null,
|
||||
totpRecoveryCodes: (() => { try { return JSON.parse(row.totp_recovery_codes || '[]'); } catch { return []; } })(),
|
||||
totpLastUsedStep: row.totp_last_used_step == null ? null : Number(row.totp_last_used_step),
|
||||
archivedAt: row.archived_at || null,
|
||||
archivedBy: row.archived_by || null,
|
||||
displayName: row.display_name,
|
||||
@@ -945,6 +949,17 @@ function createRepository({ client, location, read, transaction, close }) {
|
||||
if (log) operations.push(auditOperation(log));
|
||||
await transaction(operations);
|
||||
},
|
||||
async updateTotpSecurity(user, log = null) {
|
||||
const operations = [operation(
|
||||
`UPDATE users SET
|
||||
totp_enabled = ?, totp_secret_encrypted = ?, totp_recovery_codes = ?, totp_last_used_step = ?
|
||||
WHERE id = ?`,
|
||||
user.totpEnabled ? 1 : 0, optional(user.totpSecretEncrypted), JSON.stringify(user.totpRecoveryCodes || []),
|
||||
optional(user.totpLastUsedStep), user.id
|
||||
)];
|
||||
if (log) operations.push(auditOperation(log));
|
||||
await transaction(operations);
|
||||
},
|
||||
async updateCandidateArchives(users, log) {
|
||||
const operations = users.map(user => operation(
|
||||
'UPDATE users SET archived_at = ?, archived_by = ? WHERE id = ?',
|
||||
|
||||
Generated
+306
@@ -11,6 +11,7 @@
|
||||
"ckeditor5": "^48.3.1",
|
||||
"exceljs": "^4.4.0",
|
||||
"mysql2": "^3.14.2",
|
||||
"qrcode": "1.5.4",
|
||||
"redis": "^5.12.1",
|
||||
"sanitize-html": "^2.17.6"
|
||||
},
|
||||
@@ -1043,6 +1044,48 @@
|
||||
"integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles/node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles/node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/archiver": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz",
|
||||
@@ -1268,6 +1311,15 @@
|
||||
"node": ">=0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/ccount": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
|
||||
@@ -1388,6 +1440,17 @@
|
||||
"@ckeditor/ckeditor5-word-count": "48.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cluster-key-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||
@@ -1512,6 +1575,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decode-named-character-reference": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
||||
@@ -1565,6 +1637,12 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
|
||||
@@ -1671,6 +1749,12 @@
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
@@ -1753,6 +1837,19 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
@@ -1796,6 +1893,15 @@
|
||||
"is-property": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
@@ -2139,6 +2245,15 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-obj": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
|
||||
@@ -2280,6 +2395,18 @@
|
||||
"integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
@@ -3308,6 +3435,42 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
@@ -3320,6 +3483,15 @@
|
||||
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
@@ -3335,6 +3507,15 @@
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.20",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz",
|
||||
@@ -3379,6 +3560,23 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
@@ -3573,6 +3771,21 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/rimraf": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
|
||||
@@ -3642,6 +3855,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
@@ -3691,6 +3910,20 @@
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/stringify-entities": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
||||
@@ -3705,6 +3938,18 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
@@ -3988,6 +4233,26 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
@@ -4000,6 +4265,47 @@
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/zip-stream": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"ckeditor5": "^48.3.1",
|
||||
"exceljs": "^4.4.0",
|
||||
"mysql2": "^3.14.2",
|
||||
"qrcode": "1.5.4",
|
||||
"redis": "^5.12.1",
|
||||
"sanitize-html": "^2.17.6"
|
||||
},
|
||||
|
||||
@@ -51,13 +51,13 @@ export function createAdminViews(context) {
|
||||
app.innerHTML = portalShell('admin', page, loadingPanel(), ...meta[page]);
|
||||
try {
|
||||
const endpoint = page === 'admit' ? 'admission-arrangements' : page === 'flows' ? 'workflow-instances' : page === 'flow-design' ? 'workflows' : page === 'account-batches' ? 'candidate-account-batches' : page === 'organization' ? 'school-organization' : page;
|
||||
const data = page === 'security' ? {} : await api(`/api/admin/${endpoint}`);
|
||||
const data = page === 'security' ? await api('/api/auth/totp') : await api(`/api/admin/${endpoint}`);
|
||||
state.pageData = data;
|
||||
const content = {
|
||||
dashboard: () => adminDashboard(data), candidates: () => adminCandidates(data), registrations: () => adminRegistrations(data.registrations), payments: () => adminPayments(data),
|
||||
exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices), admit: () => adminAdmit(data), results: () => adminResults(data),
|
||||
schools: () => adminSchools(data), admins: () => adminUsers(data), centers: () => adminCenters(data), flows: () => adminFlows(data), organization: () => adminSchoolOrganization(data), 'account-batches': () => adminAccountBatches(data),
|
||||
'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data), security: () => accountSecurity()
|
||||
'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data), security: () => accountSecurity(data)
|
||||
}[page]();
|
||||
app.innerHTML = portalShell('admin', page, content, ...meta[page]);
|
||||
} catch (error) { renderError(error); }
|
||||
|
||||
@@ -94,13 +94,13 @@ export function createCandidateViews(context) {
|
||||
app.innerHTML = portalShell('candidate', page, loadingPanel(), ...meta[page]);
|
||||
try {
|
||||
const endpoint = page === 'dashboard' ? 'dashboard' : page === 'profile' ? 'profile' : page === 'exams' ? 'exams' : page === 'results' ? 'results' : 'registrations';
|
||||
const data = page === 'notices' ? { notices: state.publicData.notices } : page === 'security' ? {} : await api(`/api/candidate/${endpoint}`);
|
||||
const data = page === 'notices' ? { notices: state.publicData.notices } : page === 'security' ? await api('/api/auth/totp') : await api(`/api/candidate/${endpoint}`);
|
||||
state.pageData = data;
|
||||
if (data.profile) state.profile = data.profile;
|
||||
const content = {
|
||||
dashboard: () => candidateDashboard(data), profile: () => candidateProfile(data), exams: () => candidateExams(data),
|
||||
registrations: () => candidateRegistrations(data.registrations), admit: () => candidateAdmit(data.registrations),
|
||||
results: () => candidateResults(data), notices: () => candidateNotices(data.notices), security: () => accountSecurity()
|
||||
results: () => candidateResults(data), notices: () => candidateNotices(data.notices), security: () => accountSecurity(data)
|
||||
}[page]();
|
||||
app.innerHTML = portalShell('candidate', page, content, ...meta[page]);
|
||||
if (page === 'profile') mountRegionSelects(app, data.profile, { className: 'region-selects wide-field' });
|
||||
@@ -186,8 +186,14 @@ export function createCandidateViews(context) {
|
||||
return `<section class="panel notice-center"><div class="notice-center-list">${notices.map(notice => `<button data-action="open-notice" data-id="${h(notice.id)}"><time><strong>${new Date(notice.publishAt).getDate()}</strong><span>${new Date(notice.publishAt).toLocaleString('zh-CN',{month:'short'})}</span></time><span><em>${h(notice.category)}</em><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></span>${notice.pinned ? '<i>置顶</i>' : ''}${icons.arrow}</button>`).join('')}</div></section>`;
|
||||
}
|
||||
|
||||
function accountSecurity() {
|
||||
return `<section class="panel account-security-panel"><div class="account-security-copy"><span>LOGIN PASSWORD</span><h2>修改登录密码</h2><p>密码修改成功后立即生效。请使用至少 8 位、且与当前密码不同的新密码。</p><dl><div><dt>当前账号</dt><dd class="mono">${h(state.user?.candidateNumber || state.user?.username)}</dd></div><div><dt>账户类型</dt><dd>${state.user?.role === 'candidate' ? '考生账户' : statusLabels[state.user?.adminLevel] || '管理员账户'}</dd></div></dl></div><form class="stack-form account-password-form" data-form="account-password"><label><span>当前密码</span><input name="currentPassword" type="password" autocomplete="current-password" required></label><label><span>新密码</span><input name="newPassword" type="password" autocomplete="new-password" minlength="8" required></label><label><span>再次输入新密码</span><input name="confirmPassword" type="password" autocomplete="new-password" minlength="8" required></label><button class="solid-button large" type="submit">保存新密码</button></form></section>`;
|
||||
function accountSecurity(totp = {}) {
|
||||
const account = h(state.user?.candidateNumber || state.user?.username);
|
||||
const type = state.user?.role === 'candidate' ? '考生账户' : statusLabels[state.user?.adminLevel] || '管理员账户';
|
||||
const password = `<section class="panel account-security-panel"><div class="account-security-copy"><span>LOGIN PASSWORD</span><h2>修改登录密码</h2><p>密码修改成功后立即生效。请使用至少 8 位、且与当前密码不同的新密码。</p><dl><div><dt>当前账号</dt><dd class="mono">${account}</dd></div><div><dt>账户类型</dt><dd>${type}</dd></div></dl></div><form class="stack-form account-password-form" data-form="account-password"><label><span>当前密码</span><input name="currentPassword" type="password" autocomplete="current-password" required></label><label><span>新密码</span><input name="newPassword" type="password" autocomplete="new-password" minlength="8" required></label><label><span>再次输入新密码</span><input name="confirmPassword" type="password" autocomplete="new-password" minlength="8" required></label><button class="solid-button large" type="submit">保存新密码</button></form></section>`;
|
||||
const totpPanel = totp.enabled
|
||||
? `<section class="panel account-security-panel totp-security-panel enabled"><div class="account-security-copy"><span>TWO-STEP VERIFICATION</span><h2>TOTP 二次验证已开启</h2><p>登录密码验证通过后,还需要输入验证器应用生成的 6 位动态验证码。</p><div class="totp-signal"><i></i><strong>保护中</strong><span>剩余 ${h(totp.recoveryCodesRemaining)} 个恢复码</span></div></div><div class="totp-security-actions"><details><summary>重新生成恢复码</summary><form class="stack-form account-password-form" data-form="totp-recovery-codes"><label><span>当前密码</span><input name="currentPassword" type="password" autocomplete="current-password" required></label><label><span>动态验证码或恢复码</span><input name="code" autocomplete="one-time-code" required></label><button class="solid-button" type="submit">生成新的恢复码</button></form></details><details class="danger-details"><summary>关闭二次验证</summary><form class="stack-form account-password-form" data-form="totp-disable"><p>关闭后,账户将仅使用密码登录。</p><label><span>当前密码</span><input name="currentPassword" type="password" autocomplete="current-password" required></label><label><span>动态验证码或恢复码</span><input name="code" autocomplete="one-time-code" required></label><button class="danger-button" type="submit">确认关闭二次验证</button></form></details></div></section>`
|
||||
: `<section class="panel account-security-panel totp-security-panel"><div class="account-security-copy"><span>TWO-STEP VERIFICATION</span><h2>添加 TOTP 二次验证</h2><p>使用 Microsoft Authenticator、Google Authenticator、1Password 等验证器应用扫码。即使密码泄露,没有动态验证码也无法登录。</p></div><form class="stack-form account-password-form" data-form="totp-setup"><label><span>确认当前密码</span><input name="currentPassword" type="password" autocomplete="current-password" required></label><p class="form-hint">绑定时会显示二维码和手动密钥;验证成功后请立即保存恢复码。</p><button class="solid-button large" type="submit">开始绑定验证器</button></form></section>`;
|
||||
return `<div class="account-security-stack">${password}${totpPanel}</div>`;
|
||||
}
|
||||
|
||||
return { adminNavForUser, portalShell, loadingPanel, renderCandidate, accountSecurity };
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ export function createBaseDatabase({ nowIso, hashPassword, initialAdmin = {} })
|
||||
const adminId = 'usr_admin';
|
||||
const createdAt = nowIso();
|
||||
return {
|
||||
meta: { version: 15, createdAt },
|
||||
meta: { version: 17, createdAt },
|
||||
settings: { selfRegistrationEnabled: false },
|
||||
organization: { name: '考试服务平台', code: 'EXAM-SERVICE', phone: '', address: '' },
|
||||
schools: [], classes: [],
|
||||
|
||||
@@ -54,7 +54,7 @@ export function createMysqlAdapter(context) {
|
||||
hasSchemaMetadata = metadataRows.length > 0;
|
||||
existingSchemaVersion = hasSchemaMetadata ? Number(metadataRows[0].schema_version) : null;
|
||||
}
|
||||
if (existingAppTables.length && (!hasSchemaMetadata || ![15, 16].includes(existingSchemaVersion))) {
|
||||
if (existingAppTables.length && (!hasSchemaMetadata || ![15, 16, 17].includes(existingSchemaVersion))) {
|
||||
for (const table of [...mysqlTableNames].reverse()) {
|
||||
await pool.query(`DROP TABLE IF EXISTS \`${table}\``);
|
||||
}
|
||||
@@ -158,6 +158,15 @@ export function createMysqlAdapter(context) {
|
||||
await pool.execute('UPDATE schema_metadata SET schema_version = 16 WHERE id = 1');
|
||||
metadataRows[0].schema_version = 16;
|
||||
}
|
||||
if (Number(metadataRows[0]?.schema_version || 1) < 17) {
|
||||
await pool.query(`ALTER TABLE users
|
||||
ADD COLUMN totp_enabled BOOLEAN NOT NULL DEFAULT FALSE AFTER must_change_password,
|
||||
ADD COLUMN totp_secret_encrypted VARCHAR(512) NULL AFTER totp_enabled,
|
||||
ADD COLUMN totp_recovery_codes VARCHAR(2048) NOT NULL DEFAULT '[]' AFTER totp_secret_encrypted,
|
||||
ADD COLUMN totp_last_used_step BIGINT NULL AFTER totp_recovery_codes`);
|
||||
await pool.execute('UPDATE schema_metadata SET schema_version = 17 WHERE id = 1');
|
||||
metadataRows[0].schema_version = 17;
|
||||
}
|
||||
if (Number(metadataRows[0]?.app_version || 1) < 2) {
|
||||
const extension = seed();
|
||||
const connection = await pool.getConnection();
|
||||
@@ -340,7 +349,7 @@ export function createMysqlAdapter(context) {
|
||||
await connection.beginTransaction();
|
||||
const [insert] = await connection.execute(`
|
||||
INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
|
||||
VALUES (1, 16, ?, ?, ?)
|
||||
VALUES (1, 17, ?, ?, ?)
|
||||
`, [Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()]);
|
||||
if (insert.affectedRows === 1) {
|
||||
for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params);
|
||||
|
||||
@@ -53,6 +53,10 @@ export const sqliteSchema = `
|
||||
class_id TEXT REFERENCES school_classes(id) ON DELETE SET NULL,
|
||||
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
|
||||
must_change_password INTEGER NOT NULL DEFAULT 0 CHECK (must_change_password IN (0, 1)),
|
||||
totp_enabled INTEGER NOT NULL DEFAULT 0 CHECK (totp_enabled IN (0, 1)),
|
||||
totp_secret_encrypted TEXT,
|
||||
totp_recovery_codes TEXT NOT NULL DEFAULT '[]',
|
||||
totp_last_used_step INTEGER,
|
||||
archived_at TEXT,
|
||||
archived_by TEXT REFERENCES users(id) ON DELETE RESTRICT,
|
||||
display_name TEXT NOT NULL,
|
||||
@@ -538,6 +542,10 @@ export const mysqlSchema = [
|
||||
class_id VARCHAR(64) NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
must_change_password BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
totp_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
totp_secret_encrypted VARCHAR(512) NULL,
|
||||
totp_recovery_codes VARCHAR(2048) NOT NULL DEFAULT '[]',
|
||||
totp_last_used_step BIGINT NULL,
|
||||
archived_at VARCHAR(35) NULL,
|
||||
archived_by VARCHAR(64) NULL,
|
||||
display_name VARCHAR(100) NOT NULL,
|
||||
|
||||
@@ -245,6 +245,15 @@ export function createSqliteAdapter(context) {
|
||||
if (existingSystem && Number(existingSystem.schema_version || 1) < 16) {
|
||||
connection.prepare('UPDATE schema_metadata SET schema_version = 16 WHERE id = 1').run();
|
||||
}
|
||||
if (existingSystem && Number(existingSystem.schema_version || 1) < 17) {
|
||||
connection.exec(`
|
||||
ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0 CHECK (totp_enabled IN (0, 1));
|
||||
ALTER TABLE users ADD COLUMN totp_secret_encrypted TEXT;
|
||||
ALTER TABLE users ADD COLUMN totp_recovery_codes TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE users ADD COLUMN totp_last_used_step INTEGER;
|
||||
UPDATE schema_metadata SET schema_version = 17 WHERE id = 1;
|
||||
`);
|
||||
}
|
||||
if (existingSystem && Number(existingSystem.app_version || 1) < 2) {
|
||||
const extension = seed();
|
||||
connection.exec('BEGIN IMMEDIATE');
|
||||
@@ -405,7 +414,7 @@ export function createSqliteAdapter(context) {
|
||||
try {
|
||||
connection.prepare(`
|
||||
INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
|
||||
VALUES (1, 16, ?, ?, ?)
|
||||
VALUES (1, 17, ?, ?, ?)
|
||||
`).run(Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString());
|
||||
for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params);
|
||||
connection.exec('COMMIT');
|
||||
|
||||
+170
-3
@@ -1,4 +1,18 @@
|
||||
import QRCode from 'qrcode';
|
||||
import {
|
||||
assertTotpConfiguration,
|
||||
buildOtpAuthUri,
|
||||
consumeRecoveryCode,
|
||||
createRecoveryCodes,
|
||||
createTotpSecret,
|
||||
decryptTotpSecret,
|
||||
encryptTotpSecret,
|
||||
hashRecoveryCode,
|
||||
verifyTotp
|
||||
} from '../security/totp.mjs';
|
||||
|
||||
export function createAuthRoutes(context) {
|
||||
assertTotpConfiguration();
|
||||
const {
|
||||
database,
|
||||
readDb,
|
||||
@@ -50,6 +64,38 @@ export function createAuthRoutes(context) {
|
||||
permissionsByLevel
|
||||
} = context;
|
||||
|
||||
const loginChallenges = new Map();
|
||||
const totpSetups = new Map();
|
||||
const challengeLifetime = 5 * 60 * 1000;
|
||||
|
||||
function pruneTemporaryState() {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of loginChallenges) if (value.expiresAt < now) loginChallenges.delete(key);
|
||||
for (const [key, value] of totpSetups) if (value.expiresAt < now) totpSetups.delete(key);
|
||||
}
|
||||
|
||||
function issueSession(user) {
|
||||
const token = randomBytes(32).toString('hex');
|
||||
sessions.set(token, { userId: user.id, expiresAt: Date.now() + 8 * 60 * 60 * 1000 });
|
||||
const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '';
|
||||
return { token, cookie: `hz_session=${token}; Path=/; HttpOnly; SameSite=Strict${secure}; Max-Age=28800` };
|
||||
}
|
||||
|
||||
function sessionToken(request) {
|
||||
return parseCookies(request).hz_session || '';
|
||||
}
|
||||
|
||||
function verifySecondFactor(user, code) {
|
||||
if (!user.totpEnabled || !user.totpSecretEncrypted) return null;
|
||||
const normalized = String(code || '').trim();
|
||||
if (/^\d{6}$/.test(normalized)) {
|
||||
const step = verifyTotp(normalized, decryptTotpSecret(user.totpSecretEncrypted), { lastUsedStep: user.totpLastUsedStep });
|
||||
return step == null ? null : { type: 'totp', step };
|
||||
}
|
||||
const recoveryCodes = consumeRecoveryCode(normalized, user.totpRecoveryCodes || []);
|
||||
return recoveryCodes ? { type: 'recovery', recoveryCodes } : null;
|
||||
}
|
||||
|
||||
async function handleAuth(request, response, pathname) {
|
||||
if (request.method === 'GET' && pathname === '/api/auth/me') {
|
||||
const user = await currentUser(request);
|
||||
@@ -81,14 +127,49 @@ export function createAuthRoutes(context) {
|
||||
return sendJson(response, 201, { ok: true, registrationNumber: generated.number, message: '报名号已生成,请使用该号码登录并补全个人信息' });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/auth/login') {
|
||||
pruneTemporaryState();
|
||||
const body = await readJson(request);
|
||||
const db = await readDb();
|
||||
const account = cleanText(body.username, 120).toLowerCase();
|
||||
const user = db.users.find(item => item.username.toLowerCase() === account || String(item.candidateNumber || '').toLowerCase() === account);
|
||||
if (!user || user.active === false || user.archivedAt || !verifyPassword(String(body.password || ''), user.passwordHash)) return sendError(response, 401, '账号或密码不正确');
|
||||
const token = randomBytes(32).toString('hex');
|
||||
sessions.set(token, { userId: user.id, expiresAt: Date.now() + 8 * 60 * 60 * 1000 });
|
||||
return sendJson(response, 200, { ok: true, user: safeUser(user) }, { 'Set-Cookie': `hz_session=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=28800` });
|
||||
if (user.totpEnabled) {
|
||||
const challenge = randomBytes(32).toString('base64url');
|
||||
loginChallenges.set(challenge, { userId: user.id, expiresAt: Date.now() + challengeLifetime, attempts: 0 });
|
||||
return sendJson(response, 200, { ok: true, requiresTotp: true, challenge });
|
||||
}
|
||||
const session = issueSession(user);
|
||||
return sendJson(response, 200, { ok: true, user: safeUser(user) }, { 'Set-Cookie': session.cookie });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/auth/login/totp') {
|
||||
pruneTemporaryState();
|
||||
const body = await readJson(request);
|
||||
const challengeKey = String(body.challenge || '');
|
||||
const challenge = loginChallenges.get(challengeKey);
|
||||
if (!challenge || challenge.expiresAt < Date.now() || challenge.attempts >= 5) {
|
||||
loginChallenges.delete(challengeKey);
|
||||
return sendError(response, 401, '验证请求已过期,请重新输入账号和密码');
|
||||
}
|
||||
const db = await readDb();
|
||||
const user = db.users.find(item => item.id === challenge.userId);
|
||||
if (!user || !user.totpEnabled || user.active === false || user.archivedAt) {
|
||||
loginChallenges.delete(challengeKey);
|
||||
return sendError(response, 401, '验证请求已失效,请重新登录');
|
||||
}
|
||||
let verified = null;
|
||||
try { verified = verifySecondFactor(user, body.code); } catch {}
|
||||
if (!verified) {
|
||||
challenge.attempts += 1;
|
||||
if (challenge.attempts >= 5) loginChallenges.delete(challengeKey);
|
||||
return sendError(response, 401, challenge.attempts >= 5 ? '验证失败次数过多,请重新登录' : '验证码或恢复码不正确');
|
||||
}
|
||||
if (verified.type === 'totp') user.totpLastUsedStep = verified.step;
|
||||
else user.totpRecoveryCodes = verified.recoveryCodes;
|
||||
const log = verified.type === 'recovery' ? logAction(db, user, '使用 TOTP 恢复码登录', user.username) : null;
|
||||
await database.updateTotpSecurity(user, log);
|
||||
loginChallenges.delete(challengeKey);
|
||||
const session = issueSession(user);
|
||||
return sendJson(response, 200, { ok: true, user: safeUser(user), usedRecoveryCode: verified.type === 'recovery' }, { 'Set-Cookie': session.cookie });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/auth/change-password') {
|
||||
const user = await requireUser(request, response);
|
||||
@@ -106,6 +187,92 @@ export function createAuthRoutes(context) {
|
||||
await database.changePassword(user, log);
|
||||
return sendJson(response, 200, { ok: true, user: safeUser(user) });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/auth/totp') {
|
||||
const user = await requireUser(request, response);
|
||||
if (!user) return true;
|
||||
return sendJson(response, 200, {
|
||||
ok: true,
|
||||
enabled: Boolean(user.totpEnabled),
|
||||
recoveryCodesRemaining: user.totpEnabled ? (user.totpRecoveryCodes || []).length : 0
|
||||
});
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/auth/totp/setup') {
|
||||
pruneTemporaryState();
|
||||
const user = await requireUser(request, response);
|
||||
if (!user) return true;
|
||||
if (user.mustChangePassword) return sendError(response, 400, '请先修改初始密码,再启用二次验证');
|
||||
if (user.totpEnabled) return sendError(response, 409, '当前账号已经启用 TOTP 二次验证');
|
||||
const body = await readJson(request);
|
||||
if (!verifyPassword(String(body.currentPassword || ''), user.passwordHash)) return sendError(response, 400, '当前密码不正确');
|
||||
const db = await readDb();
|
||||
const issuer = cleanText(db.organization?.name || '考试服务平台', 80);
|
||||
const secret = createTotpSecret();
|
||||
const uri = buildOtpAuthUri({ secret, account: user.candidateNumber || user.username, issuer });
|
||||
const token = sessionToken(request);
|
||||
totpSetups.set(token, { userId: user.id, secret, expiresAt: Date.now() + 10 * 60 * 1000 });
|
||||
const qrCode = await QRCode.toDataURL(uri, { errorCorrectionLevel: 'M', margin: 1, width: 240 });
|
||||
return sendJson(response, 200, { ok: true, secret, uri, qrCode, expiresIn: 600 });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/auth/totp/enable') {
|
||||
pruneTemporaryState();
|
||||
const user = await requireUser(request, response);
|
||||
if (!user) return true;
|
||||
const token = sessionToken(request);
|
||||
const setup = totpSetups.get(token);
|
||||
if (!setup || setup.userId !== user.id || setup.expiresAt < Date.now()) {
|
||||
totpSetups.delete(token);
|
||||
return sendError(response, 400, '绑定信息已过期,请重新开始');
|
||||
}
|
||||
const body = await readJson(request);
|
||||
const step = verifyTotp(body.code, setup.secret);
|
||||
if (step == null) return sendError(response, 400, '动态验证码不正确,请确认设备时间准确后重试');
|
||||
const recoveryCodes = createRecoveryCodes();
|
||||
user.totpEnabled = true;
|
||||
user.totpSecretEncrypted = encryptTotpSecret(setup.secret);
|
||||
user.totpRecoveryCodes = recoveryCodes.map(hashRecoveryCode);
|
||||
user.totpLastUsedStep = step;
|
||||
const db = await readDb();
|
||||
const log = logAction(db, user, '启用 TOTP 二次验证', user.username);
|
||||
await database.updateTotpSecurity(user, log);
|
||||
totpSetups.delete(token);
|
||||
return sendJson(response, 200, { ok: true, recoveryCodes, user: safeUser(user) });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/auth/totp/recovery-codes') {
|
||||
const user = await requireUser(request, response);
|
||||
if (!user) return true;
|
||||
if (!user.totpEnabled) return sendError(response, 400, '当前账号尚未启用 TOTP 二次验证');
|
||||
const body = await readJson(request);
|
||||
if (!verifyPassword(String(body.currentPassword || ''), user.passwordHash)) return sendError(response, 400, '当前密码不正确');
|
||||
let verified = null;
|
||||
try { verified = verifySecondFactor(user, body.code); } catch {}
|
||||
if (!verified) return sendError(response, 400, '动态验证码或恢复码不正确');
|
||||
const recoveryCodes = createRecoveryCodes();
|
||||
user.totpRecoveryCodes = recoveryCodes.map(hashRecoveryCode);
|
||||
if (verified.type === 'totp') user.totpLastUsedStep = verified.step;
|
||||
const db = await readDb();
|
||||
const log = logAction(db, user, '重新生成 TOTP 恢复码', user.username);
|
||||
await database.updateTotpSecurity(user, log);
|
||||
return sendJson(response, 200, { ok: true, recoveryCodes });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/auth/totp/disable') {
|
||||
const user = await requireUser(request, response);
|
||||
if (!user) return true;
|
||||
if (!user.totpEnabled) return sendError(response, 400, '当前账号尚未启用 TOTP 二次验证');
|
||||
const body = await readJson(request);
|
||||
if (!verifyPassword(String(body.currentPassword || ''), user.passwordHash)) return sendError(response, 400, '当前密码不正确');
|
||||
let verified = null;
|
||||
try { verified = verifySecondFactor(user, body.code); } catch {}
|
||||
if (!verified) return sendError(response, 400, '动态验证码或恢复码不正确');
|
||||
user.totpEnabled = false;
|
||||
user.totpSecretEncrypted = null;
|
||||
user.totpRecoveryCodes = [];
|
||||
user.totpLastUsedStep = null;
|
||||
const db = await readDb();
|
||||
const log = logAction(db, user, '关闭 TOTP 二次验证', user.username);
|
||||
await database.updateTotpSecurity(user, log);
|
||||
totpSetups.delete(sessionToken(request));
|
||||
return sendJson(response, 200, { ok: true, user: safeUser(user) });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/auth/logout') {
|
||||
const token = parseCookies(request).hz_session;
|
||||
if (token) sessions.delete(token);
|
||||
|
||||
@@ -34,6 +34,7 @@ export function createSessionManager({ sessions, readDb, sendError }) {
|
||||
displayName: user.displayName,
|
||||
candidateNumber: user.candidateNumber || null,
|
||||
mustChangePassword: Boolean(user.mustChangePassword),
|
||||
totpEnabled: Boolean(user.totpEnabled),
|
||||
archived: Boolean(user.archivedAt)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
createHmac,
|
||||
randomBytes,
|
||||
timingSafeEqual
|
||||
} from 'node:crypto';
|
||||
|
||||
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
const RECOVERY_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||
const TOTP_PERIOD_SECONDS = 30;
|
||||
|
||||
function encryptionKey() {
|
||||
const configured = String(process.env.TOTP_ENCRYPTION_KEY || '');
|
||||
if (process.env.NODE_ENV === 'production' && configured.length < 32) {
|
||||
throw new Error('生产环境启用 TOTP 前必须设置至少 32 个字符的 TOTP_ENCRYPTION_KEY');
|
||||
}
|
||||
const material = configured || `development-only:${process.env.INITIAL_ADMIN_PASSWORD || 'local-exam-system'}`;
|
||||
return createHash('sha256').update(material).digest();
|
||||
}
|
||||
|
||||
export function assertTotpConfiguration() {
|
||||
encryptionKey();
|
||||
}
|
||||
|
||||
export function createTotpSecret() {
|
||||
const bytes = randomBytes(20);
|
||||
let bits = '';
|
||||
for (const byte of bytes) bits += byte.toString(2).padStart(8, '0');
|
||||
let encoded = '';
|
||||
for (let index = 0; index < bits.length; index += 5) {
|
||||
encoded += BASE32_ALPHABET[Number.parseInt(bits.slice(index, index + 5).padEnd(5, '0'), 2)];
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
function decodeBase32(value) {
|
||||
const normalized = String(value || '').toUpperCase().replace(/[^A-Z2-7]/g, '');
|
||||
let bits = '';
|
||||
for (const character of normalized) {
|
||||
const index = BASE32_ALPHABET.indexOf(character);
|
||||
if (index < 0) throw new Error('TOTP 密钥格式无效');
|
||||
bits += index.toString(2).padStart(5, '0');
|
||||
}
|
||||
const bytes = [];
|
||||
for (let index = 0; index + 8 <= bits.length; index += 8) bytes.push(Number.parseInt(bits.slice(index, index + 8), 2));
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
export function totpAtStep(secret, step) {
|
||||
const counter = Buffer.alloc(8);
|
||||
counter.writeBigUInt64BE(BigInt(step));
|
||||
const digest = createHmac('sha1', decodeBase32(secret)).update(counter).digest();
|
||||
const offset = digest[digest.length - 1] & 0x0f;
|
||||
const binary = (digest.readUInt32BE(offset) & 0x7fffffff) % 1_000_000;
|
||||
return String(binary).padStart(6, '0');
|
||||
}
|
||||
|
||||
export function verifyTotp(code, secret, { now = Date.now(), window = 1, lastUsedStep = null } = {}) {
|
||||
const normalized = String(code || '').replace(/\s/g, '');
|
||||
if (!/^\d{6}$/.test(normalized)) return null;
|
||||
const currentStep = Math.floor(now / 1000 / TOTP_PERIOD_SECONDS);
|
||||
for (let offset = -window; offset <= window; offset += 1) {
|
||||
const step = currentStep + offset;
|
||||
if (lastUsedStep != null && step <= Number(lastUsedStep)) continue;
|
||||
const expected = Buffer.from(totpAtStep(secret, step));
|
||||
const supplied = Buffer.from(normalized);
|
||||
if (expected.length === supplied.length && timingSafeEqual(expected, supplied)) return step;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildOtpAuthUri({ secret, account, issuer }) {
|
||||
const label = `${issuer}:${account}`;
|
||||
const params = new URLSearchParams({ secret, issuer, algorithm: 'SHA1', digits: '6', period: String(TOTP_PERIOD_SECONDS) });
|
||||
return `otpauth://totp/${encodeURIComponent(label)}?${params}`;
|
||||
}
|
||||
|
||||
export function encryptTotpSecret(secret) {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', encryptionKey(), iv);
|
||||
const encrypted = Buffer.concat([cipher.update(String(secret), 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return `v1.${iv.toString('base64url')}.${tag.toString('base64url')}.${encrypted.toString('base64url')}`;
|
||||
}
|
||||
|
||||
export function decryptTotpSecret(value) {
|
||||
const [version, ivValue, tagValue, encryptedValue] = String(value || '').split('.');
|
||||
if (version !== 'v1' || !ivValue || !tagValue || !encryptedValue) throw new Error('TOTP 密钥数据无效');
|
||||
const decipher = createDecipheriv('aes-256-gcm', encryptionKey(), Buffer.from(ivValue, 'base64url'));
|
||||
decipher.setAuthTag(Buffer.from(tagValue, 'base64url'));
|
||||
return Buffer.concat([decipher.update(Buffer.from(encryptedValue, 'base64url')), decipher.final()]).toString('utf8');
|
||||
}
|
||||
|
||||
function normalizeRecoveryCode(code) {
|
||||
return String(code || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||
}
|
||||
|
||||
export function hashRecoveryCode(code) {
|
||||
return createHmac('sha256', encryptionKey()).update(normalizeRecoveryCode(code)).digest('hex');
|
||||
}
|
||||
|
||||
export function createRecoveryCodes(count = 8) {
|
||||
return Array.from({ length: count }, () => {
|
||||
let value = '';
|
||||
const bytes = randomBytes(10);
|
||||
for (let index = 0; index < 10; index += 1) value += RECOVERY_ALPHABET[bytes[index] % RECOVERY_ALPHABET.length];
|
||||
return `${value.slice(0, 5)}-${value.slice(5)}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function consumeRecoveryCode(code, hashes = []) {
|
||||
const candidate = Buffer.from(hashRecoveryCode(code));
|
||||
const index = hashes.findIndex(hash => {
|
||||
const stored = Buffer.from(String(hash || ''));
|
||||
return stored.length === candidate.length && timingSafeEqual(stored, candidate);
|
||||
});
|
||||
if (index < 0) return null;
|
||||
return hashes.filter((_, itemIndex) => itemIndex !== index);
|
||||
}
|
||||
+23
-1
@@ -520,12 +520,34 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
||||
.registration-policy { display:flex; align-items:center; justify-content:space-between; gap:25px; margin-bottom:15px; padding:20px 22px; background:linear-gradient(110deg,#fff,#f1f5fc); }.registration-policy > div > span { color:#7483a3; font-family:Consolas,monospace; font-size:7px; letter-spacing:1px; }.registration-policy h2 { margin:5px 0; font-family:"STKaiti"; font-size:21px; font-weight:400; }.registration-policy p { margin:0; color:#848d9f; font-size:8px; }.registration-policy form { display:flex; align-items:center; gap:11px; }.policy-state { padding:6px 9px; border-radius:99px; color:#8b5f22; background:#fff0d4; font-size:8px; font-weight:700; }.policy-state.open { color:#247157; background:#e2f2ec; }.onboarding-badge { display:inline-flex; padding:5px 8px; border-radius:6px; color:#8a6229; background:#fff0d6; font-size:8px; font-weight:700; }.account-issue-note { display:flex; align-items:center; gap:10px; padding:11px 13px; border-radius:8px; color:#6d5733; background:#fff5e3; }.account-issue-note strong { font-size:8px; }.account-issue-note span { font-size:7px; }.account-number-principle { display:grid; grid-template-columns:auto 1fr; gap:4px 18px; margin-bottom:15px; padding:17px 20px; border-left:4px solid var(--blue); border-radius:10px; color:#fff; background:var(--navy); }.account-number-principle span { grid-row:1/3; align-self:center; color:#8290b5; font-family:Consolas,monospace; font-size:7px; writing-mode:vertical-rl; letter-spacing:1px; }.account-number-principle strong { font-family:"STKaiti"; font-size:20px; font-weight:400; }.account-number-principle p { margin:0; color:#aeb8d2; font-size:8px; }
|
||||
|
||||
.account-security-panel { max-width:820px; display:grid; grid-template-columns:minmax(0,.9fr) minmax(320px,1.1fr); gap:34px; padding:28px; }
|
||||
.account-security-stack { display:grid; gap:18px; }
|
||||
.account-security-copy > span,.candidate-archive-console > div > span { color:#7483a3; font-family:Consolas,monospace; font-size:7px; letter-spacing:1px; }
|
||||
.account-security-copy h2,.candidate-archive-console h2 { margin:7px 0; font-family:"STKaiti"; font-size:24px; font-weight:400; }
|
||||
.account-security-copy p,.candidate-archive-console p { margin:0; color:#818b9e; font-size:8px; line-height:1.8; }
|
||||
.account-security-copy dl { display:grid; gap:9px; margin:24px 0 0; }.account-security-copy dl div { display:grid; grid-template-columns:80px 1fr; gap:9px; padding:9px 0; border-top:1px solid var(--line); }
|
||||
.account-security-copy dt { color:#929bad; font-size:7px; }.account-security-copy dd { margin:0; font-size:9px; }
|
||||
.account-password-form { display:grid; gap:14px; padding:20px; border:1px solid var(--line); border-radius:10px; background:#f8fafd; }.account-password-form label { display:grid; gap:6px; }.account-password-form label span { color:#5e6980; font-size:8px; font-weight:700; }.account-password-form input { min-height:42px; padding:9px 11px; border:1px solid #dce2ed; border-radius:8px; background:#fff; }
|
||||
.totp-security-panel { position:relative; overflow:hidden; border-color:#ccd8ed; }
|
||||
.totp-security-panel::after { position:absolute; right:-42px; top:-62px; width:170px; height:170px; border:1px solid #dbe5f5; border-radius:50%; box-shadow:0 0 0 26px rgba(225,234,248,.42),0 0 0 52px rgba(225,234,248,.2); content:""; pointer-events:none; }
|
||||
.totp-security-panel > * { position:relative; z-index:1; }
|
||||
.form-hint { margin:0; color:#7a8599; font-size:8px; line-height:1.7; }
|
||||
.totp-signal { display:grid; grid-template-columns:10px auto 1fr; align-items:center; gap:8px; margin-top:22px; padding:11px 13px; border:1px solid #bfdfd1; border-radius:9px; background:#f1faf6; color:#246449; }
|
||||
.totp-signal i { width:9px; height:9px; border-radius:50%; background:#2a9b6d; box-shadow:0 0 0 4px rgba(42,155,109,.13); }
|
||||
.totp-signal strong { font-size:9px; }.totp-signal span { justify-self:end; color:#56806e; font-size:7px; }
|
||||
.totp-security-actions { display:grid; align-content:start; gap:10px; }
|
||||
.totp-security-actions details { border:1px solid var(--line); border-radius:10px; background:#f8fafd; }
|
||||
.totp-security-actions summary { padding:14px 16px; color:#43516a; font-size:9px; font-weight:700; cursor:pointer; }
|
||||
.totp-security-actions details[open] summary { border-bottom:1px solid var(--line); }
|
||||
.totp-security-actions .account-password-form { border:0; border-radius:0 0 10px 10px; }
|
||||
.danger-details summary { color:#9d413e; }.danger-button { min-height:40px; padding:9px 14px; border:1px solid #d9a5a1; border-radius:8px; background:#fff4f3; color:#9d3430; font:700 8px inherit; cursor:pointer; }
|
||||
.totp-setup-grid { display:grid; grid-template-columns:240px 1fr; gap:22px; padding:0 24px 18px; align-items:center; }
|
||||
.totp-qr { display:grid; place-items:center; padding:8px; border:1px solid #dce4f1; border-radius:12px; background:#fff; }
|
||||
.totp-qr img { display:block; width:100%; height:auto; }
|
||||
.totp-manual { display:grid; gap:10px; min-width:0; }.totp-manual > span { color:#65728a; font-size:8px; }.totp-manual code { overflow-wrap:anywhere; color:#263d67; font:700 11px/1.7 Consolas,monospace; letter-spacing:1px; }.totp-manual small { color:#8a94a6; font-size:7px; line-height:1.6; }
|
||||
.totp-confirm-form { border-top:1px solid var(--line); }.totp-login-form input,.totp-confirm-form input { font:700 18px Consolas,monospace; letter-spacing:5px; text-align:center; }
|
||||
.recovery-code-sheet { display:grid; grid-template-columns:repeat(2,1fr); gap:8px; margin:0 24px 16px; padding:16px; border:1px dashed #bdcbe0; border-radius:10px; background:#f7f9fd; }
|
||||
.recovery-code-sheet code { color:#263d67; font:700 11px Consolas,monospace; letter-spacing:.8px; text-align:center; }
|
||||
.security-warning { margin:0 24px; padding:10px 12px; border-left:3px solid #c69037; background:#fff9ed; color:#7a633c; font-size:8px; line-height:1.7; }
|
||||
.candidate-archive-console { display:flex; align-items:center; justify-content:space-between; gap:26px; margin-bottom:15px; padding:20px 22px; background:linear-gradient(110deg,#fff,#f3f6fb); }.archive-controls { min-width:420px; display:grid; grid-template-columns:minmax(180px,1fr) auto auto; gap:9px; }.archive-controls select { min-height:40px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; font-size:9px; }.archive-button { background:#9f463f; }.account-archived-row { opacity:.7; background:#f5f6f8; }.candidate-account-actions { display:flex; flex-wrap:wrap; gap:6px; }
|
||||
.reset-warning { padding:14px; border-left:3px solid var(--red); border-radius:8px; background:#fceceb; }.reset-warning strong { font-size:9px; }.reset-warning p { margin:5px 0 0; color:#875b58; font-size:8px; line-height:1.7; }
|
||||
|
||||
@@ -545,7 +567,7 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
||||
.admin-readable .portal-content .status,.admin-readable .portal-content .overline { font-size:11px !important; }
|
||||
|
||||
@media (max-width: 1000px) { .candidate-archive-console { align-items:flex-start; flex-direction:column; }.archive-controls { width:100%; min-width:0; } }
|
||||
@media (max-width: 620px) { .account-security-panel { grid-template-columns:1fr; padding:18px; }.archive-controls { grid-template-columns:1fr; } }
|
||||
@media (max-width: 620px) { .account-security-panel { grid-template-columns:1fr; padding:18px; }.archive-controls { grid-template-columns:1fr; }.totp-setup-grid { grid-template-columns:1fr; padding:0 18px 16px; }.totp-qr { width:min(240px,100%); justify-self:center; }.recovery-code-sheet { grid-template-columns:1fr; margin-inline:18px; }.security-warning { margin-inline:18px; } }
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.number-rule-layout,.workflow-design-grid,.workflow-board,.center-grid { grid-template-columns:1fr; }.batch-number-panel form { grid-template-columns:1fr 1fr; }.room-editor-grid { grid-template-columns:repeat(3,1fr); }.onboarding-page { grid-template-columns:290px 1fr; }.onboarding-identity { padding:32px 28px; }.onboarding-work { padding:42px 28px 60px; }
|
||||
|
||||
+45
-3
@@ -8,9 +8,11 @@ import { createDatabase, relationalTables } from '../database.mjs';
|
||||
import { createBaseDatabase } from '../src/data/base.mjs';
|
||||
import { createSeedDatabase } from '../src/data/seed.mjs';
|
||||
import { mysqlSchema } from '../src/database/schema.mjs';
|
||||
import { totpAtStep } from '../src/security/totp.mjs';
|
||||
import { buildCenterMaterialsWorkbook, buildWorkbook } from '../excel.mjs';
|
||||
|
||||
const root = resolve(process.cwd());
|
||||
assert.equal(totpAtStep('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ', 1), '287082', 'TOTP 实现应符合 RFC 6238 SHA-1 测试向量的 6 位结果');
|
||||
const port = 4182;
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const testDb = resolve(root, 'data', 'test-db.sqlite');
|
||||
@@ -37,7 +39,7 @@ assert.doesNotMatch(mysqlAdapterSource, /ADD\s+COLUMN\s+IF\s+NOT\s+EXISTS/i, 'My
|
||||
assert.match(mysqlAdapterSource, /for \(const statement of mysqlSchema\) await pool\.query\(statement\)/, 'MySQL DDL 应使用文本协议执行');
|
||||
assert.match(mysqlAdapterSource, /existingResultLockTriggers\.has\(name\)\) await pool\.query\(statement\)/, 'MySQL 触发器不得通过预处理协议创建');
|
||||
assert.doesNotMatch(mysqlAdapterSource, /\.execute\(\s*['"`]\s*(?:CREATE|ALTER|DROP|SHOW)\b/i, 'MySQL DDL 和 SHOW 语句不得通过预处理协议执行');
|
||||
assert.match(mysqlAdapterSource, /existingAppTables\.length && \(!hasSchemaMetadata \|\| !\[15, 16\]\.includes\(existingSchemaVersion\)\)/, 'MySQL 应保留可迁移的 v15 结构并重建更旧或未完成的开发结构');
|
||||
assert.match(mysqlAdapterSource, /existingAppTables\.length && \(!hasSchemaMetadata \|\| !\[15, 16, 17\]\.includes\(existingSchemaVersion\)\)/, 'MySQL 应保留可迁移的 v15-v17 结构并重建更旧或未完成的开发结构');
|
||||
assert.match(mysqlAdapterSource, /\[\.\.\.mysqlTableNames\]\.reverse\(\)/, 'MySQL 半成品表应按外键依赖逆序清理');
|
||||
const serverSource = await readFile(resolve(root, 'server.mjs'), 'utf8');
|
||||
assert.doesNotMatch(serverSource, /src\/data\/seed\.mjs|createSeedDatabase/, '服务启动不得引用测试数据生成器');
|
||||
@@ -89,7 +91,7 @@ await seededTestDatabase.close();
|
||||
|
||||
const server = spawn(process.execPath, ['server.mjs'], {
|
||||
cwd: root,
|
||||
env: { ...process.env, NODE_ENV: 'test', DATABASE_CLIENT: 'sqlite', REDIS_URL: '', PORT: String(port), SQLITE_PATH: testDb, PUBLIC_SITE_NAME: '环境变量考试中心', PUBLIC_SITE_CODE: 'ENV-TEST', PUBLIC_SITE_PHONE: '0518-1234 5678', PUBLIC_SITE_ADDRESS: '测试地址 1 号', PUBLIC_SITE_EMAIL: 'service@example.test', PUBLIC_SITE_HERO_TITLE: '一次配置,', PUBLIC_SITE_HERO_HIGHLIGHT: '统一首页文案。', PUBLIC_SITE_FOOTER_NOTICE: '环境变量页脚提示' },
|
||||
env: { ...process.env, NODE_ENV: 'test', DATABASE_CLIENT: 'sqlite', REDIS_URL: '', TOTP_ENCRYPTION_KEY: 'test-only-totp-encryption-key-32-characters', PORT: String(port), SQLITE_PATH: testDb, PUBLIC_SITE_NAME: '环境变量考试中心', PUBLIC_SITE_CODE: 'ENV-TEST', PUBLIC_SITE_PHONE: '0518-1234 5678', PUBLIC_SITE_ADDRESS: '测试地址 1 号', PUBLIC_SITE_EMAIL: 'service@example.test', PUBLIC_SITE_HERO_TITLE: '一次配置,', PUBLIC_SITE_HERO_HIGHLIGHT: '统一首页文案。', PUBLIC_SITE_FOOTER_NOTICE: '环境变量页脚提示' },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
@@ -219,7 +221,7 @@ try {
|
||||
inspector.close();
|
||||
assert.ok(relationalTables.every(table => tableNames.includes(table)), '所有关系模型总表与分表登记表都必须存在');
|
||||
assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储');
|
||||
assert.equal(schemaVersion, 16, '考试与学校物理分表应使用 v16 数据结构');
|
||||
assert.equal(schemaVersion, 17, 'TOTP 账户安全应使用 v17 数据结构');
|
||||
assert.ok(examPartitions.length > 0, '每场考试都应登记一组专属物理表');
|
||||
assert.equal(examPartitions.length, seededExamCount, '考试分表登记不得缺漏');
|
||||
assert.ok(examPartitionCoverage.every(item => item.candidates === item.expectedCandidates && item.admissions === item.expectedAdmissions && item.results === item.expectedResults), '考试专属表应与该场考试的考生、准考信息和成绩数据一致');
|
||||
@@ -229,6 +231,7 @@ try {
|
||||
assert.ok(['archived_at', 'archived_by'].every(column => examColumns.includes(column)), '考试应保存不可逆归档时间和超级管理员');
|
||||
assert.equal(resultLockTriggers.length, 3, '数据库应从插入、更新、删除三个方向永久锁定归档成绩');
|
||||
assert.ok(['archived_at', 'archived_by'].every(column => userColumns.includes(column)), '账户应保存独立归档状态和校方操作人');
|
||||
assert.ok(['totp_enabled', 'totp_secret_encrypted', 'totp_recovery_codes', 'totp_last_used_step'].every(column => userColumns.includes(column)), '账户应保存加密 TOTP 状态、恢复码哈希和防重放时间片');
|
||||
assert.ok(['payment_status', 'paid_at', 'paid_by'].every(column => registrationColumns.includes(column)), '报名应保存缴费状态、确认时间和班级负责人');
|
||||
assert.ok(seededSchoolCount >= 4, '独立测试数据应覆盖至少四所学校');
|
||||
assert.ok(seededCandidateCount >= 360, '独立测试数据应包含数百名考生');
|
||||
@@ -264,6 +267,45 @@ try {
|
||||
const loginAdmin = await admin.request('/api/auth/login', { method: 'POST', body: { username: 'admin', password: '12345678' } });
|
||||
assert.equal(loginAdmin.data.user.role, 'admin');
|
||||
assert.equal(loginAdmin.data.user.adminLevel, 'super', '默认管理员应为超级管理员');
|
||||
assert.equal((await admin.request('/api/auth/totp')).data.enabled, false, '账号应默认关闭 TOTP');
|
||||
const totpSetup = await admin.request('/api/auth/totp/setup', { method: 'POST', body: { currentPassword: '12345678' } });
|
||||
assert.equal(totpSetup.response.status, 200, '当前密码验证通过后应可开始绑定 TOTP');
|
||||
assert.match(totpSetup.data.qrCode, /^data:image\/png;base64,/, 'TOTP 二维码应在服务端本地生成为 PNG data URL');
|
||||
assert.match(totpSetup.data.uri, /^otpauth:\/\/totp\//, '绑定响应应提供标准 otpauth URI');
|
||||
const setupStep = Math.floor(Date.now() / 1000 / 30);
|
||||
const totpEnable = await admin.request('/api/auth/totp/enable', { method: 'POST', body: { code: totpAtStep(totpSetup.data.secret, setupStep) } });
|
||||
assert.equal(totpEnable.response.status, 200, '正确动态验证码应完成 TOTP 绑定');
|
||||
assert.equal(totpEnable.data.recoveryCodes.length, 8, '启用后应一次性签发 8 个恢复码');
|
||||
assert.equal(totpEnable.data.user.totpEnabled, true, '安全用户信息应公开 TOTP 开启状态但不公开密钥');
|
||||
assert.equal(totpEnable.data.user.totpSecretEncrypted, undefined, 'API 不得返回加密后的 TOTP 密钥');
|
||||
const originalRecoveryCodes = totpEnable.data.recoveryCodes;
|
||||
|
||||
await admin.request('/api/auth/logout', { method: 'POST' });
|
||||
const totpPasswordLogin = await admin.request('/api/auth/login', { method: 'POST', body: { username: 'admin', password: '12345678' } });
|
||||
assert.equal(totpPasswordLogin.data.requiresTotp, true, '启用后密码验证不得直接创建登录会话');
|
||||
assert.equal((await admin.request('/api/auth/login/totp', { method: 'POST', body: { challenge: totpPasswordLogin.data.challenge, code: '000000' } })).response.status, 401, '错误动态验证码应被拒绝');
|
||||
const loginStep = Math.floor(Date.now() / 1000 / 30) + 1;
|
||||
const totpLogin = await admin.request('/api/auth/login/totp', { method: 'POST', body: { challenge: totpPasswordLogin.data.challenge, code: totpAtStep(totpSetup.data.secret, loginStep) } });
|
||||
assert.equal(totpLogin.response.status, 200, '验证器动态验证码应完成第二步登录');
|
||||
|
||||
await admin.request('/api/auth/logout', { method: 'POST' });
|
||||
const recoveryPasswordLogin = await admin.request('/api/auth/login', { method: 'POST', body: { username: 'admin', password: '12345678' } });
|
||||
const recoveryLogin = await admin.request('/api/auth/login/totp', { method: 'POST', body: { challenge: recoveryPasswordLogin.data.challenge, code: originalRecoveryCodes[0] } });
|
||||
assert.equal(recoveryLogin.response.status, 200, '恢复码应可替代动态验证码登录');
|
||||
assert.equal(recoveryLogin.data.usedRecoveryCode, true, '恢复码登录应明确提醒用户');
|
||||
assert.equal((await admin.request('/api/auth/totp')).data.recoveryCodesRemaining, 7, '使用后的恢复码应立即失效并减少剩余数量');
|
||||
|
||||
await admin.request('/api/auth/logout', { method: 'POST' });
|
||||
const reusedRecoveryPassword = await admin.request('/api/auth/login', { method: 'POST', body: { username: 'admin', password: '12345678' } });
|
||||
assert.equal((await admin.request('/api/auth/login/totp', { method: 'POST', body: { challenge: reusedRecoveryPassword.data.challenge, code: originalRecoveryCodes[0] } })).response.status, 401, '恢复码不得重复使用');
|
||||
assert.equal((await admin.request('/api/auth/login/totp', { method: 'POST', body: { challenge: reusedRecoveryPassword.data.challenge, code: originalRecoveryCodes[1] } })).response.status, 200, '同一挑战剩余尝试次数内应允许改用有效恢复码');
|
||||
const regeneratedRecovery = await admin.request('/api/auth/totp/recovery-codes', { method: 'POST', body: { currentPassword: '12345678', code: originalRecoveryCodes[2] } });
|
||||
assert.equal(regeneratedRecovery.data.recoveryCodes.length, 8, '通过二次验证后应可轮换全部恢复码');
|
||||
const disableTotp = await admin.request('/api/auth/totp/disable', { method: 'POST', body: { currentPassword: '12345678', code: regeneratedRecovery.data.recoveryCodes[0] } });
|
||||
assert.equal(disableTotp.response.status, 200, '当前密码和第二因素均通过后应可关闭 TOTP');
|
||||
assert.equal((await admin.request('/api/auth/totp')).data.enabled, false, '关闭后应清空 TOTP 状态');
|
||||
await admin.request('/api/auth/logout', { method: 'POST' });
|
||||
assert.equal((await admin.request('/api/auth/login', { method: 'POST', body: { username: 'admin', password: '12345678' } })).data.user.role, 'admin', '关闭后密码登录应恢复为单步会话');
|
||||
const loginLegacyAdmin = await legacyAdmin.request('/api/auth/login', { method: 'POST', body: { username: 'legacy_super', password: '12345678' } });
|
||||
assert.equal(loginLegacyAdmin.data.user.adminLevel, 'super', '未保存层级的旧版管理员登录后应规范化为超级管理员');
|
||||
assert.equal((await schoolAdmin.request('/api/auth/login', { method: 'POST', body: { username: 'school_admin', password: '12345678' } })).data.user.adminLevel, 'school');
|
||||
|
||||
Reference in New Issue
Block a user