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, sendJson, sendError, readJson, readBodyBuffer, sendWorkbook, currentUser, parseCookies, safeUser, requireUser, hasPermission, requirePermission, profileInScope, registrationInScope, adminScopeLabel, adminsForStep, activeWorkflow, createWorkflowSubmission, workflowView, pendingWorkflow, candidateSequence, generateCandidateNumber, cleanText, centerScopeProfile, workflowScopeProfile, candidateAccountBatchView, centerChangeView, parseCenterChange, maskId, publicExam, examRegistrationView, logAction, excelResourceNames, excelRowsForResource, importExcelResource, admitCardHtml, hashPassword, verifyPassword, randomBytes, uid, nowIso, sessions, buildWorkbook, hasExcelResource, parseWorkbook, adminLevelNames, 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); if (!user) return sendJson(response, 200, { ok: true, user: null }); const db = await readDb(); const profile = user.role === 'candidate' ? db.candidateProfiles.find(item => item.userId === user.id) : null; return sendJson(response, 200, { ok: true, user: safeUser(user), profile, ...(user.role === 'admin' ? { permissions: permissionsByLevel[user.adminLevel || 'super'], scopeLabel: adminScopeLabel(db, user) } : {}) }); } if (request.method === 'POST' && pathname === '/api/auth/register') { const body = await readJson(request); const password = String(body.password || ''); const name = cleanText(body.name, 30); const gender = cleanText(body.gender, 10); if (!name || !['男', '女'].includes(gender)) return sendError(response, 400, '请填写姓名并选择性别'); if (password.length < 8) return sendError(response, 400, '密码至少需要 8 位'); const db = await readDb(); if (!db.settings.selfRegistrationEnabled) return sendError(response, 403, '当前未开放自主注册,请使用学校下发的报名号和初始密码登录'); const schoolId = cleanText(body.schoolId, 64); const classId = cleanText(body.classId, 64); const school = db.schools.find(item => item.id === schoolId && item.active && item.isSourceSchool); const schoolClass = db.classes.find(item => item.id === classId && item.schoolId === schoolId && item.active); if (!school || !schoolClass) return sendError(response, 400, '请选择有效的学校和班级'); const draftProfile = { schoolId, classId, gender }; const generated = generateCandidateNumber(db, draftProfile); const userId = uid('usr'); const user = { id: userId, username: generated.number, candidateNumber: generated.number, passwordHash: hashPassword(password), role: 'candidate', displayName: name, active: true, mustChangePassword: false, createdAt: nowIso() }; const profile = { id: uid('profile'), userId, name, idNumber: `PENDING-${userId}`, phone: '', gender, email: '', school: school.name, grade: schoolClass.name, schoolId, classId, address: '', emergencyContact: '', emergencyPhone: '', nativePlace: '', birthDate: '', ethnicity: '', postalCode: '', guardianName: '', guardianPhone: '', profileCompleted: false, status: 'pending', reviewNote: '', updatedAt: nowIso() }; await database.createCandidate(user, profile, null, null); 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, '账号或密码不正确'); 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); if (!user) return true; const body = await readJson(request); const currentPassword = String(body.currentPassword || ''); const newPassword = String(body.newPassword || ''); if (!verifyPassword(currentPassword, user.passwordHash)) return sendError(response, 400, '当前密码不正确'); if (newPassword.length < 8) return sendError(response, 400, '新密码至少需要 8 位'); if (newPassword === currentPassword) return sendError(response, 400, '新密码不能与当前密码相同'); user.passwordHash = hashPassword(newPassword); user.mustChangePassword = false; const db = await readDb(); const log = logAction(db, user, '修改登录密码', user.role === 'candidate' ? `报名号 ${user.candidateNumber}` : user.username); 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); return sendJson(response, 200, { ok: true }, { 'Set-Cookie': 'hz_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0' }); } return false; } return handleAuth; }