Files
Exam-Information-System/src/routes/auth.routes.mjs
T

286 lines
14 KiB
JavaScript

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;
}