diff --git a/.env.example b/.env.example index 9cc1948..fa4f529 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index a046b82..ea382ec 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ ### 系统能力 - PBKDF2 加盐密码哈希 +- 可选 TOTP 二次验证,支持验证器扫码绑定、一次性恢复码与登录防重放 +- TOTP 密钥使用 AES-256-GCM 加密存储,恢复码仅保存带服务端密钥的哈希 - HttpOnly、SameSite 登录 Cookie - 服务端角色权限校验 - SQLite / MySQL 8.4 双数据库持久化 @@ -88,7 +90,9 @@ npm start 打开 。 -本地开发无需额外配置,首次运行会自动创建 `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 src/data/china-regions.mjs`。 diff --git a/app.js b/app.js index 21be7e6..79c9dab 100644 --- a/app.js +++ b/app.js @@ -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(`
${codes.map(code => `${h(code)}`).join('')}
请立即复制并离线保存。关闭后系统不会再次显示这些恢复码。
`); +} + 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(``); + } 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(`
固定报名号${h(data.registrationNumber)}

以后报名不同考试仍使用这个号码。关闭窗口前请抄写或截图保存。

`); @@ -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(`
TOTP 绑定二维码
无法扫码?手动输入密钥${h(data.secret.match(/.{1,4}/g)?.join(' ') || data.secret)}类型:基于时间 · 6 位 · 每 30 秒更新
`); + } 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' }); diff --git a/database.mjs b/database.mjs index 30707f5..f8dcd38 100644 --- a/database.mjs +++ b/database.mjs @@ -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 = ?', diff --git a/package-lock.json b/package-lock.json index 47c4665..3250482 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 21b061d..c95e77d 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs index f620308..c01a0d5 100644 --- a/src/client/admin-views.mjs +++ b/src/client/admin-views.mjs @@ -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); } diff --git a/src/client/candidate-views.mjs b/src/client/candidate-views.mjs index 8ed0f54..8f7ff31 100644 --- a/src/client/candidate-views.mjs +++ b/src/client/candidate-views.mjs @@ -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 `
${notices.map(notice => ``).join('')}
`; } - function accountSecurity() { - return ``; + function accountSecurity(totp = {}) { + const account = h(state.user?.candidateNumber || state.user?.username); + const type = state.user?.role === 'candidate' ? '考生账户' : statusLabels[state.user?.adminLevel] || '管理员账户'; + const password = ``; + const totpPanel = totp.enabled + ? `` + : ``; + return ``; } return { adminNavForUser, portalShell, loadingPanel, renderCandidate, accountSecurity }; diff --git a/src/data/base.mjs b/src/data/base.mjs index 710276a..01c770e 100644 --- a/src/data/base.mjs +++ b/src/data/base.mjs @@ -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: [], diff --git a/src/database/mysql-adapter.mjs b/src/database/mysql-adapter.mjs index 431e8b5..4e3aa1c 100644 --- a/src/database/mysql-adapter.mjs +++ b/src/database/mysql-adapter.mjs @@ -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); diff --git a/src/database/schema.mjs b/src/database/schema.mjs index 366df7a..01bccfc 100644 --- a/src/database/schema.mjs +++ b/src/database/schema.mjs @@ -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, diff --git a/src/database/sqlite-adapter.mjs b/src/database/sqlite-adapter.mjs index 2a66562..8916174 100644 --- a/src/database/sqlite-adapter.mjs +++ b/src/database/sqlite-adapter.mjs @@ -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'); diff --git a/src/routes/auth.routes.mjs b/src/routes/auth.routes.mjs index eb4ed1b..87dd252 100644 --- a/src/routes/auth.routes.mjs +++ b/src/routes/auth.routes.mjs @@ -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); diff --git a/src/security/session.mjs b/src/security/session.mjs index 6c20b02..ce106d3 100644 --- a/src/security/session.mjs +++ b/src/security/session.mjs @@ -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) }; } diff --git a/src/security/totp.mjs b/src/security/totp.mjs new file mode 100644 index 0000000..ef3161f --- /dev/null +++ b/src/security/totp.mjs @@ -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); +} diff --git a/styles.css b/styles.css index 8da52e7..0883060 100644 --- a/styles.css +++ b/styles.css @@ -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; } diff --git a/tests/system.test.mjs b/tests/system.test.mjs index ce150c2..7005e00 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -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');