TOTP
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user