diff --git a/.env.docker.example b/.env.docker.example index e4353e3..9405a02 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -19,5 +19,9 @@ PUBLIC_SITE_HERO_HIGHLIGHT=贯穿每一次考试。 PUBLIC_SITE_HERO_DESCRIPTION=使用学校下发的报名号登录,完成密码更新和个人信息核验后,即可办理所有考试事项。 PUBLIC_SITE_FOOTER_NOTICE=本平台展示数据仅用于系统演示 -# 可选 Redis 缓存;容器外的 Redis 不应填写 127.0.0.1。 +# 可选 Redis;容器外的 Redis 不应填写 127.0.0.1。 +# 普通接口缓存使用 DB 0,认证状态会自动使用独立 DB 1。 # REDIS_URL=redis://redis-host:6379/0 +# REDIS_SESSION_DB=1 +# 也可让认证状态使用另一台 Redis(此时 URL 中可指定自己的逻辑 DB)。 +# REDIS_SESSION_URL=rediss://session-redis-host:6379/1 diff --git a/.env.example b/.env.example index b1cd003..7397fdd 100644 --- a/.env.example +++ b/.env.example @@ -5,12 +5,18 @@ SQLITE_PATH=./data/exam.sqlite HOST=127.0.0.1 PORT=4173 -# 可选 Redis 公开接口缓存;未设置 REDIS_URL 时自动禁用并直接读取数据库。 +# 可选 Redis。普通接口缓存使用 DB 0;配置 REDIS_URL 后,登录状态默认自动使用独立的 DB 1。 +# 未设置任何 Redis 地址时,接口缓存和认证状态均使用本机内存模式。 # REDIS_URL=redis://127.0.0.1:6379/0 # REDIS_CACHE_PREFIX=exam-information # REDIS_CACHE_TTL_SECONDS=60 # REDIS_RESULTS_CACHE_TTL_SECONDS=86400 # REDIS_CONNECT_TIMEOUT_MS=1500 +# REDIS_SESSION_DB=1 +# REDIS_SESSION_PREFIX=exam-information:auth +# AUTH_SESSION_TTL_SECONDS=28800 +# 如需让认证状态使用另一台 Redis,可设置独立地址;URL 中可直接指定逻辑 DB。 +# REDIS_SESSION_URL=rediss://session-redis.example.com:6379/1 # 仅在首次创建空数据库时使用。部署前务必修改初始密码。 INITIAL_ADMIN_USERNAME=admin diff --git a/README.md b/README.md index 2944910..9438a37 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ - 服务端角色权限校验 - SQLite / MySQL 8.4 双数据库持久化 - 可选 Redis 公开接口缓存,支持写后版本失效、热点请求合并和故障回源 +- Redis 登录状态存储,和普通接口缓存使用不同逻辑数据库,支持多实例共享会话 - 规范关系模型、外键、唯一约束和业务索引 - 业务写入与审计日志使用原子事务提交 - 关键管理操作审计日志 @@ -130,7 +131,7 @@ docker compose logs --follow app docker build --tag hengzhun-exam-system:local . ``` -镜像默认监听 `0.0.0.0:4173`,以非 root 用户运行,并通过 `/api/public/home` 执行健康检查。需要连接 MySQL 或 Redis 时,用运行环境变量覆盖 `DATABASE_CLIENT`、`DATABASE_URL`/`MYSQL_*`、`REDIS_URL`;此时 SQLite 数据卷可以移除。 +镜像默认监听 `0.0.0.0:4173`,以非 root 用户运行,并通过 `/api/public/home` 执行健康检查。需要连接 MySQL 或 Redis 时,用运行环境变量覆盖 `DATABASE_CLIENT`、`DATABASE_URL`/`MYSQL_*`、`REDIS_URL`;认证状态默认使用同一 Redis 服务的独立 DB 1,也可以通过 `REDIS_SESSION_DB` 或 `REDIS_SESSION_URL` 单独配置。此时 SQLite 数据卷可以移除。 #### Gitea Actions 自动发布到 Docker Hub 与 Gitea 软件包 @@ -233,6 +234,8 @@ npm start 配置 `REDIS_URL` 后,应用会缓存公开首页、已发布公告详情和每名考生的已发布成绩查询。公开数据默认 TTL 为 60 秒,成绩默认 TTL 为 24 小时;成绩录入/发布、批量导入、考试归档和成绩复议会自动使成绩缓存失效,超级管理员也可以在成绩管理中心手动刷新全部成绩缓存。Redis 未配置或暂时不可用时,应用会自动切换到有界本机缓存(默认最多 200 条),避免热点接口反复回源;写入后的失效语义保持不变。 +同一个 `REDIS_URL` 还会启用 Redis 认证状态存储:登录 Session、TOTP 登录挑战和 TOTP 绑定临时状态全部写入 Redis。普通接口缓存按 `REDIS_URL` 使用 DB 0 时,认证状态默认自动选择独立 DB 1;可通过 `REDIS_SESSION_DB` 修改逻辑 DB,或通过 `REDIS_SESSION_URL` 指向另一台 Redis。应用会拒绝让认证状态与普通缓存使用同一 Redis 端点的同一逻辑 DB。认证 Redis 配置后连接失败会阻止服务启动,避免多实例之间悄悄退回本机状态而出现随机掉线。完全未配置 Redis 时,认证状态仍使用本机内存,服务重启会要求重新登录。 + 数据库关系表会按写入版本复用进程内只读快照,避免每个请求重复扫描并转换全部业务表。应用自身写入会立即使快照失效;SQLite 还通过 `PRAGMA data_version` 检测其他连接的提交,MySQL 外部直写默认最多延迟 30 秒可见。可通过 `DATABASE_STATE_CACHE_TTL_MS` 调整 MySQL 快照时间,通过 `LOCAL_CACHE_MAX_ENTRIES` 控制本机接口缓存上限。 ```powershell @@ -240,10 +243,12 @@ $env:REDIS_URL = 'redis://127.0.0.1:6379/0' $env:REDIS_CACHE_PREFIX = 'exam-information' $env:REDIS_CACHE_TTL_SECONDS = '60' $env:REDIS_RESULTS_CACHE_TTL_SECONDS = '86400' +$env:REDIS_SESSION_DB = '1' +$env:AUTH_SESSION_TTL_SECONDS = '28800' npm start ``` -生产环境可使用 `redis://` 或启用 TLS 的 `rediss://` 连接地址,并通过 `REDIS_CONNECT_TIMEOUT_MS` 调整启动连接超时。 +生产环境可使用 `redis://` 或启用 TLS 的 `rediss://` 连接地址,并通过 `REDIS_CONNECT_TIMEOUT_MS` 调整启动连接超时。若 Redis Cluster 不支持非 0 逻辑 DB,请用 `REDIS_SESSION_URL` 为认证状态配置独立 Redis 实例。 ### 导入服务器 MySQL 测试数据 @@ -341,7 +346,8 @@ src/data/base.mjs 空业务库与系统基础配置 src/data/seed.mjs 手动测试数据生成器 scripts/import-test-data.mjs 独立测试数据导入脚本 src/http/responses.mjs JSON、文件与请求体处理 -src/security/session.mjs Cookie 会话与当前用户 +src/security/auth-state.mjs Redis / 本机会话与 TOTP 临时状态 +src/security/session.mjs Cookie 解析与当前用户 src/security/authorization.mjs 管理层级、权限和数据范围 src/routes/public.routes.mjs 公开 API src/routes/auth.routes.mjs 登录、注册与改密 API diff --git a/package.json b/package.json index ca47516..13ad275 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "start": "node server.mjs", - "test": "node tests/client-auth.test.mjs && node tests/api-dedup.test.mjs && node tests/cache.test.mjs && node tests/state-cache.test.mjs && node tests/document-verification.test.mjs && node tests/admission.test.mjs && node tests/seed.test.mjs && node tests/system.test.mjs", + "test": "node tests/client-auth.test.mjs && node tests/api-dedup.test.mjs && node tests/cache.test.mjs && node tests/auth-state.test.mjs && node tests/state-cache.test.mjs && node tests/document-verification.test.mjs && node tests/admission.test.mjs && node tests/seed.test.mjs && node tests/system.test.mjs", "test:cache": "node tests/cache.test.mjs", "reset-db": "node scripts/reset-dev-database.mjs", "seed-test-data": "node scripts/import-test-data.mjs", diff --git a/server.mjs b/server.mjs index 2ea1b7b..36c63f1 100644 --- a/server.mjs +++ b/server.mjs @@ -13,6 +13,7 @@ import { createAuthRoutes } from './src/routes/auth.routes.mjs'; import { createPublicRoutes } from './src/routes/public.routes.mjs'; import { adminLevelNames, adminScopeLabel, createPermissionGuard, hasPermission, permissionsByLevel, profileInScope, registrationInScope } from './src/security/authorization.mjs'; import { createSessionManager } from './src/security/session.mjs'; +import { createAuthStateStore } from './src/security/auth-state.mjs'; import { readBodyBuffer, readJson, sendError, sendJson, sendWorkbook } from './src/http/responses.mjs'; import { createBaseDatabase } from './src/data/base.mjs'; import { resolveRegion } from './src/data/region-service.mjs'; @@ -41,7 +42,6 @@ const publicSiteConfig = Object.freeze({ heroDescription: process.env.PUBLIC_SITE_HERO_DESCRIPTION || '使用学校下发的报名号登录,完成密码更新和个人信息核验后,即可办理所有考试事项。', footerNotice: process.env.PUBLIC_SITE_FOOTER_NOTICE || '' }); -const sessions = new Map(); const staticFiles = new Set([ '/index.html', '/styles.css', @@ -105,6 +105,13 @@ const initializeDatabase = () => createBaseDatabase({ }); const persistentDatabase = await createDatabase({ root, seed: initializeDatabase }); const cache = await createRedisCache(); +let authState; +try { + authState = await createAuthStateStore(); +} catch (error) { + await Promise.allSettled([persistentDatabase.close(), cache.close()]); + throw error; +} const resultCacheWriteMethods = new Set(['saveResult', 'saveResults', 'updateFeatureScore', 'updateFeatureScores', 'updateExam', 'archiveExam']); const database = withCacheInvalidation(persistentDatabase, cache, (method, args) => { const namespaces = ['public']; @@ -121,7 +128,7 @@ const documentNumberDb = await readDb(); const missingNoticeNumbers = admissionRecords(documentNumberDb, 'placement').filter(item => item.status === 'final' && !item.payload?.noticeNumber); if (missingNoticeNumbers.length) await database.saveAdmissionRecords(assignAdmissionNoticeNumbers(documentNumberDb, missingNoticeNumbers)); -const { parseCookies, currentUser, safeUser, requireUser } = createSessionManager({ sessions, readDb, sendError }); +const { parseCookies, currentUser, safeUser, requireUser } = createSessionManager({ authState, readDb, sendError }); const requirePermission = createPermissionGuard(sendError); function adminsForStep(db, adminLevel, profile) { @@ -966,7 +973,7 @@ const routeContext = { randomBytes, uid, nowIso, - sessions, + authState, buildWorkbook, buildCenterMaterialsWorkbook, hasExcelResource, @@ -1020,12 +1027,13 @@ server.listen(port, host, () => { console.log(`衡准考试信息管理系统:http://${host}:${port}`); console.log(`数据库:${database.client}(${database.location})`); console.log(`Redis 缓存:${cache.status === 'ready' ? '已连接' : cache.status === 'disabled' ? '未配置' : '不可用,已回源数据库'}`); + console.log(`登录状态:${authState.status === 'ready' ? `Redis DB ${authState.database}` : '本机内存(Redis 未配置)'}`); }); async function shutdown(signal) { console.log(`收到 ${signal},正在关闭服务...`); server.close(async () => { - await Promise.allSettled([database.close(), cache.close()]); + await Promise.allSettled([database.close(), cache.close(), authState.close()]); process.exit(0); }); } diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs index 2e7f6a3..552be86 100644 --- a/src/routes/admin.routes.mjs +++ b/src/routes/admin.routes.mjs @@ -58,7 +58,7 @@ export function createAdminRoutes(context) { randomBytes, uid, nowIso, - sessions, + authState, buildWorkbook, buildCenterMaterialsWorkbook, hasExcelResource, @@ -506,7 +506,7 @@ export function createAdminRoutes(context) { target.active = body.active == null ? target.active : Boolean(body.active); target.displayName = cleanText(body.displayName || target.displayName, 80); await database.updateAdmin(target, false, logAction(db, user, target.active ? '启用招生学校账号' : '停用招生学校账号', `${target.displayName} · ${target.username}`)); - if (!target.active) for (const [token, session] of sessions) if (session.userId === target.id) sessions.delete(token); + if (!target.active) await authState.deleteUserSessions(target.id); return sendJson(response, 200, { ok: true, account: { ...safeUser(target), active: target.active } }); } const admissionAccountResetMatch = pathname.match(/^\/api\/admin\/admission-school-accounts\/([^/]+)\/reset-password$/); @@ -518,7 +518,7 @@ export function createAdminRoutes(context) { target.passwordHash = hashPassword(temporaryPassword); target.active = true; await database.updateAdmin(target, true, logAction(db, user, '重置招生学校账号密码', `${target.displayName} · ${target.username}`)); - for (const [token, session] of sessions) if (session.userId === target.id) sessions.delete(token); + await authState.deleteUserSessions(target.id); return sendJson(response, 200, { ok: true, username: target.username, temporaryPassword }); } const settingMatch = pathname.match(/^\/api\/admin\/admissions\/([^/]+)\/setting$/); @@ -850,7 +850,7 @@ export function createAdminRoutes(context) { Object.assign(target, { displayName: cleanText(body.displayName || target.displayName, 50), classId: schoolClass?.id || target.classId || null, active: body.active == null ? target.active : Boolean(body.active) }); if (password) target.passwordHash = hashPassword(password); await database.updateAdmin(target, Boolean(password), logAction(db, user, '维护管理员账户', `${target.displayName} · ${adminLevelNames[target.adminLevel]} · ${target.active ? '启用' : '停用'}`)); - if (!target.active || password) for (const [token, session] of sessions) if (session.userId === target.id) sessions.delete(token); + if (!target.active || password) await authState.deleteUserSessions(target.id); return sendJson(response, 200, { ok: true, admin: safeUser(target) }); } const adminPasswordResetMatch = pathname.match(/^\/api\/admin\/admins\/([^/]+)\/reset-password$/); @@ -863,7 +863,7 @@ export function createAdminRoutes(context) { target.passwordHash = hashPassword(temporaryPassword); target.active = true; await database.updateAdmin(target, true, logAction(db, user, '重置管理员密码', `${target.displayName} · ${target.username}`)); - for (const [token, session] of sessions) if (session.userId === target.id) sessions.delete(token); + await authState.deleteUserSessions(target.id); return sendJson(response, 200, { ok: true, username: target.username, temporaryPassword }); } if (pathname === '/api/admin/settings/self-registration' && request.method === 'PUT') { @@ -1261,7 +1261,7 @@ export function createAdminRoutes(context) { await database.updateCandidateArchives(targets, logAction(db, user, archived ? '批量归档考生账户' : '批量恢复考生账户', `${scopeLabel} · ${targets.length} 个账户`)); if (archived && targets.length) { const targetUserIds = new Set(targets.map(item => item.id)); - for (const [token, session] of sessions) if (targetUserIds.has(session.userId)) sessions.delete(token); + await authState.deleteUsersSessions(targetUserIds); } return sendJson(response, 200, { ok: true, archived, count: targets.length, scopeLabel }); } @@ -1276,7 +1276,7 @@ export function createAdminRoutes(context) { target.passwordHash = hashPassword(temporaryPassword); target.mustChangePassword = true; await database.changePassword(target, logAction(db, user, '重置考生密码', `${target.candidateNumber} · ${profile.name}`)); - for (const [token, session] of sessions) if (session.userId === target.id) sessions.delete(token); + await authState.deleteUserSessions(target.id); return sendJson(response, 200, { ok: true, candidateNumber: target.candidateNumber, temporaryPassword }); } const candidateMatch = pathname.match(/^\/api\/admin\/candidates\/([^/]+)$/); diff --git a/src/routes/auth.routes.mjs b/src/routes/auth.routes.mjs index 705e390..037c12e 100644 --- a/src/routes/auth.routes.mjs +++ b/src/routes/auth.routes.mjs @@ -56,7 +56,7 @@ export function createAuthRoutes(context) { randomBytes, uid, nowIso, - sessions, + authState, buildWorkbook, hasExcelResource, parseWorkbook, @@ -64,21 +64,11 @@ 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) { + async function issueSession(user) { const token = randomBytes(32).toString('hex'); - sessions.set(token, { userId: user.id, expiresAt: Date.now() + 8 * 60 * 60 * 1000 }); + await authState.createSession(token, user.id); const secure = process.env.NODE_ENV === 'production' ? '; Secure' : ''; - return { token, cookie: `hz_session=${token}; Path=/; HttpOnly; SameSite=Strict${secure}; Max-Age=28800` }; + return { token, cookie: `hz_session=${token}; Path=/; HttpOnly; SameSite=Strict${secure}; Max-Age=${authState.sessionTtlSeconds}` }; } function sessionToken(request) { @@ -127,7 +117,6 @@ 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(); @@ -135,40 +124,38 @@ export function createAuthRoutes(context) { 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 }); + await authState.createLoginChallenge(challenge, user.id); return sendJson(response, 200, { ok: true, requiresTotp: true, challenge }); } - const session = issueSession(user); + const session = await 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); + const challenge = await authState.getLoginChallenge(challengeKey); + if (!challenge || challenge.attempts >= 5) { + await authState.deleteLoginChallenge(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); + await authState.deleteLoginChallenge(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 ? '验证失败次数过多,请重新登录' : '验证码或恢复码不正确'); + const failure = await authState.recordLoginChallengeFailure(challengeKey, 5); + return sendError(response, 401, failure?.exhausted ? '验证失败次数过多,请重新登录' : failure ? '验证码或恢复码不正确' : '验证请求已过期,请重新输入账号和密码'); } 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); + await authState.deleteLoginChallenge(challengeKey); + const session = await 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') { @@ -197,7 +184,6 @@ export function createAuthRoutes(context) { }); } 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, '请先修改初始密码,再启用二次验证'); @@ -209,18 +195,17 @@ export function createAuthRoutes(context) { 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 }); + await authState.createTotpSetup(token, user.id, secret); 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); + const setup = await authState.getTotpSetup(token); + if (!setup || setup.userId !== user.id) { + await authState.deleteTotpSetup(token); return sendError(response, 400, '绑定信息已过期,请重新开始'); } const body = await readJson(request); @@ -234,7 +219,7 @@ export function createAuthRoutes(context) { const db = await readDb(); const log = logAction(db, user, '启用 TOTP 二次验证', user.username); await database.updateTotpSecurity(user, log); - totpSetups.delete(token); + await authState.deleteTotpSetup(token); return sendJson(response, 200, { ok: true, recoveryCodes, user: safeUser(user) }); } if (request.method === 'POST' && pathname === '/api/auth/totp/recovery-codes') { @@ -270,12 +255,12 @@ export function createAuthRoutes(context) { const db = await readDb(); const log = logAction(db, user, '关闭 TOTP 二次验证', user.username); await database.updateTotpSecurity(user, log); - totpSetups.delete(sessionToken(request)); + await authState.deleteTotpSetup(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); + if (token) await authState.deleteSession(token); return sendJson(response, 200, { ok: true }, { 'Set-Cookie': 'hz_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0' }); } return false; diff --git a/src/routes/candidate.routes.mjs b/src/routes/candidate.routes.mjs index a00913a..5b5325a 100644 --- a/src/routes/candidate.routes.mjs +++ b/src/routes/candidate.routes.mjs @@ -55,7 +55,6 @@ export function createCandidateRoutes(context) { verifyPassword, uid, nowIso, - sessions, buildWorkbook, hasExcelResource, parseWorkbook, diff --git a/src/routes/public.routes.mjs b/src/routes/public.routes.mjs index 37500ff..8d1c72f 100644 --- a/src/routes/public.routes.mjs +++ b/src/routes/public.routes.mjs @@ -47,7 +47,6 @@ export function createPublicRoutes(context) { verifyPassword, uid, nowIso, - sessions, buildWorkbook, hasExcelResource, parseWorkbook, diff --git a/src/security/auth-state.mjs b/src/security/auth-state.mjs new file mode 100644 index 0000000..492e14b --- /dev/null +++ b/src/security/auth-state.mjs @@ -0,0 +1,259 @@ +import { createClient } from 'redis'; + +function positiveInteger(value, fallback, maximum = Number.MAX_SAFE_INTEGER) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? Math.min(parsed, maximum) : fallback; +} + +function nonNegativeInteger(value, fallback, maximum = Number.MAX_SAFE_INTEGER) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 0 ? Math.min(parsed, maximum) : fallback; +} + +function redisDatabase(url) { + try { + const pathname = new URL(url).pathname.replace(/^\//, ''); + const parsed = Number(pathname || 0); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0; + } catch { + return 0; + } +} + +function redisEndpoint(url) { + try { + const parsed = new URL(url); + return `${parsed.protocol}//${parsed.hostname}:${parsed.port || '6379'}`; + } catch { + return ''; + } +} + +function memoryAuthState({ sessionTtlSeconds, loginChallengeTtlSeconds, totpSetupTtlSeconds }) { + const sessions = new Map(); + const loginChallenges = new Map(); + const totpSetups = new Map(); + + function liveEntry(map, key) { + const entry = map.get(key); + if (!entry || entry.expiresAt <= Date.now()) { + map.delete(key); + return null; + } + return entry; + } + + return { + status: 'disabled', + backend: 'memory', + database: null, + sessionTtlSeconds, + async createSession(token, userId) { + sessions.set(token, { userId, expiresAt: Date.now() + sessionTtlSeconds * 1000 }); + }, + async getSession(token) { + const entry = liveEntry(sessions, token); + return entry ? { userId: entry.userId } : null; + }, + async deleteSession(token) { + return sessions.delete(token); + }, + async deleteUserSessions(userId) { + let deleted = 0; + for (const [token, session] of sessions) { + if (session.userId === userId) { + sessions.delete(token); + deleted += 1; + } + } + return deleted; + }, + async deleteUsersSessions(userIds) { + const targets = userIds instanceof Set ? userIds : new Set(userIds); + let deleted = 0; + for (const [token, session] of sessions) { + if (targets.has(session.userId)) { + sessions.delete(token); + deleted += 1; + } + } + return deleted; + }, + async createLoginChallenge(key, userId) { + loginChallenges.set(key, { userId, attempts: 0, expiresAt: Date.now() + loginChallengeTtlSeconds * 1000 }); + }, + async getLoginChallenge(key) { + const entry = liveEntry(loginChallenges, key); + return entry ? { userId: entry.userId, attempts: entry.attempts } : null; + }, + async recordLoginChallengeFailure(key, maximumAttempts) { + const entry = liveEntry(loginChallenges, key); + if (!entry) return null; + entry.attempts += 1; + const exhausted = entry.attempts >= maximumAttempts; + if (exhausted) loginChallenges.delete(key); + return { attempts: entry.attempts, exhausted }; + }, + async deleteLoginChallenge(key) { + return loginChallenges.delete(key); + }, + async createTotpSetup(token, userId, secret) { + totpSetups.set(token, { userId, secret, expiresAt: Date.now() + totpSetupTtlSeconds * 1000 }); + }, + async getTotpSetup(token) { + const entry = liveEntry(totpSetups, token); + return entry ? { userId: entry.userId, secret: entry.secret } : null; + }, + async deleteTotpSetup(token) { + return totpSetups.delete(token); + }, + async close() { + sessions.clear(); + loginChallenges.clear(); + totpSetups.clear(); + } + }; +} + +const recordFailureScript = ` +if redis.call('EXISTS', KEYS[1]) == 0 then + return -1 +end +local attempts = redis.call('HINCRBY', KEYS[1], 'attempts', 1) +if attempts >= tonumber(ARGV[1]) then + redis.call('DEL', KEYS[1]) +end +return attempts +`; + +export async function createAuthStateStore({ env = process.env, logger = console, clientFactory = createClient } = {}) { + const cacheUrl = String(env.REDIS_URL || '').trim(); + const explicitSessionUrl = String(env.REDIS_SESSION_URL || '').trim(); + const sessionUrl = explicitSessionUrl || cacheUrl; + const sessionTtlSeconds = positiveInteger(env.AUTH_SESSION_TTL_SECONDS, 8 * 60 * 60, 30 * 24 * 60 * 60); + const loginChallengeTtlSeconds = positiveInteger(env.AUTH_LOGIN_CHALLENGE_TTL_SECONDS, 5 * 60, 60 * 60); + const totpSetupTtlSeconds = positiveInteger(env.AUTH_TOTP_SETUP_TTL_SECONDS, 10 * 60, 60 * 60); + const lifetimes = { sessionTtlSeconds, loginChallengeTtlSeconds, totpSetupTtlSeconds }; + + if (!sessionUrl) return memoryAuthState(lifetimes); + + const cacheDatabase = redisDatabase(cacheUrl); + const configuredDatabase = String(env.REDIS_SESSION_DB || '').trim(); + const sessionDatabase = configuredDatabase + ? nonNegativeInteger(configuredDatabase, cacheDatabase === 0 ? 1 : 0, 1024) + : explicitSessionUrl + ? redisDatabase(explicitSessionUrl) + : cacheDatabase === 0 ? 1 : 0; + + if (cacheUrl && redisEndpoint(cacheUrl) === redisEndpoint(sessionUrl) && cacheDatabase === sessionDatabase) { + throw new Error('Redis 认证状态必须使用与普通缓存不同的逻辑数据库;请配置 REDIS_SESSION_DB 或 REDIS_SESSION_URL'); + } + + const prefix = String(env.REDIS_SESSION_PREFIX || 'exam-information:auth') + .trim() + .replace(/[^a-zA-Z0-9:_-]/g, '-') || 'exam-information:auth'; + const connectTimeout = positiveInteger(env.REDIS_CONNECT_TIMEOUT_MS, 1500, 30000); + const client = clientFactory({ + url: sessionUrl, + database: sessionDatabase, + socket: { connectTimeout } + }); + client.on('error', error => logger.error(`Redis 认证状态存储错误:${error?.message || error}`)); + + try { + await client.connect(); + } catch (error) { + if (client.isOpen) client.destroy(); + throw new Error(`Redis 认证状态存储连接失败:${error?.message || error}`, { cause: error }); + } + + const sessionKey = token => `${prefix}:session:${token}`; + const userSessionsKey = userId => `${prefix}:user-sessions:${userId}`; + const loginChallengeKey = key => `${prefix}:login-challenge:${key}`; + const totpSetupKey = token => `${prefix}:totp-setup:${token}`; + + async function deleteUserSessions(userId) { + const indexKey = userSessionsKey(userId); + const tokens = await client.sMembers(indexKey); + if (!tokens.length) { + await client.del(indexKey); + return 0; + } + const transaction = client.multi(); + for (const token of tokens) transaction.del(sessionKey(token)); + transaction.del(indexKey); + await transaction.exec(); + return tokens.length; + } + + return { + status: 'ready', + backend: 'redis', + database: sessionDatabase, + sessionTtlSeconds, + async createSession(token, userId) { + const indexKey = userSessionsKey(userId); + const transaction = client.multi(); + transaction.set(sessionKey(token), userId, { EX: sessionTtlSeconds }); + transaction.sAdd(indexKey, token); + transaction.expire(indexKey, sessionTtlSeconds); + await transaction.exec(); + }, + async getSession(token) { + const userId = await client.get(sessionKey(token)); + return userId ? { userId } : null; + }, + async deleteSession(token) { + const key = sessionKey(token); + const userId = await client.get(key); + const transaction = client.multi(); + transaction.del(key); + if (userId) transaction.sRem(userSessionsKey(userId), token); + await transaction.exec(); + return Boolean(userId); + }, + deleteUserSessions, + async deleteUsersSessions(userIds) { + const counts = await Promise.all([...userIds].map(deleteUserSessions)); + return counts.reduce((sum, count) => sum + count, 0); + }, + async createLoginChallenge(key, userId) { + const redisKey = loginChallengeKey(key); + const transaction = client.multi(); + transaction.hSet(redisKey, { userId, attempts: '0' }); + transaction.expire(redisKey, loginChallengeTtlSeconds); + await transaction.exec(); + }, + async getLoginChallenge(key) { + const entry = await client.hGetAll(loginChallengeKey(key)); + return entry.userId ? { userId: entry.userId, attempts: Number(entry.attempts || 0) } : null; + }, + async recordLoginChallengeFailure(key, maximumAttempts) { + const attempts = Number(await client.eval(recordFailureScript, { + keys: [loginChallengeKey(key)], + arguments: [String(maximumAttempts)] + })); + return attempts < 0 ? null : { attempts, exhausted: attempts >= maximumAttempts }; + }, + async deleteLoginChallenge(key) { + return Boolean(await client.del(loginChallengeKey(key))); + }, + async createTotpSetup(token, userId, secret) { + const key = totpSetupKey(token); + const transaction = client.multi(); + transaction.hSet(key, { userId, secret }); + transaction.expire(key, totpSetupTtlSeconds); + await transaction.exec(); + }, + async getTotpSetup(token) { + const entry = await client.hGetAll(totpSetupKey(token)); + return entry.userId && entry.secret ? { userId: entry.userId, secret: entry.secret } : null; + }, + async deleteTotpSetup(token) { + return Boolean(await client.del(totpSetupKey(token))); + }, + async close() { + if (client.isOpen) await client.quit(); + } + }; +} diff --git a/src/security/session.mjs b/src/security/session.mjs index 2da6a19..363854f 100644 --- a/src/security/session.mjs +++ b/src/security/session.mjs @@ -1,4 +1,4 @@ -export function createSessionManager({ sessions, readDb, sendError }) { +export function createSessionManager({ authState, readDb, sendError }) { function normalizeUser(user) { if (user?.role === 'admin' && !user.adminLevel) return { ...user, adminLevel: 'super' }; return user; @@ -13,11 +13,8 @@ export function createSessionManager({ sessions, readDb, sendError }) { async function currentUser(request) { const token = parseCookies(request).hz_session; - const session = token && sessions.get(token); - if (!session || session.expiresAt < Date.now()) { - if (token) sessions.delete(token); - return null; - } + const session = token ? await authState.getSession(token) : null; + if (!session) return null; const db = await readDb(); request.authDb = db; const user = db.users.find(item => item.id === session.userId) || null; diff --git a/tests/auth-state.test.mjs b/tests/auth-state.test.mjs new file mode 100644 index 0000000..3719f0b --- /dev/null +++ b/tests/auth-state.test.mjs @@ -0,0 +1,209 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { createAuthStateStore } from '../src/security/auth-state.mjs'; + +class FakeRedisClient extends EventEmitter { + constructor({ connectError = null } = {}) { + super(); + this.connectError = connectError; + this.isOpen = false; + this.values = new Map(); + this.sets = new Map(); + this.hashes = new Map(); + } + + async connect() { + if (this.connectError) throw this.connectError; + this.isOpen = true; + } + + async get(key) { + return this.values.has(key) ? this.values.get(key) : null; + } + + async set(key, value) { + this.values.set(key, String(value)); + return 'OK'; + } + + async del(key) { + const deleted = Number(this.values.delete(key)) + Number(this.sets.delete(key)) + Number(this.hashes.delete(key)); + return deleted ? 1 : 0; + } + + async sAdd(key, value) { + if (!this.sets.has(key)) this.sets.set(key, new Set()); + const before = this.sets.get(key).size; + this.sets.get(key).add(value); + return this.sets.get(key).size - before; + } + + async sRem(key, value) { + return Number(this.sets.get(key)?.delete(value) || false); + } + + async sMembers(key) { + return [...(this.sets.get(key) || [])]; + } + + async expire() { + return 1; + } + + async hSet(key, entries) { + if (!this.hashes.has(key)) this.hashes.set(key, new Map()); + for (const [field, value] of Object.entries(entries)) this.hashes.get(key).set(field, String(value)); + return Object.keys(entries).length; + } + + async hGetAll(key) { + return Object.fromEntries(this.hashes.get(key) || []); + } + + async eval(_script, { keys, arguments: scriptArguments }) { + const hash = this.hashes.get(keys[0]); + if (!hash) return -1; + const attempts = Number(hash.get('attempts') || 0) + 1; + hash.set('attempts', String(attempts)); + if (attempts >= Number(scriptArguments[0])) this.hashes.delete(keys[0]); + return attempts; + } + + multi() { + const operations = []; + const transaction = {}; + for (const method of ['set', 'del', 'sAdd', 'sRem', 'expire', 'hSet']) { + transaction[method] = (...args) => { + operations.push(() => this[method](...args)); + return transaction; + }; + } + transaction.exec = async () => Promise.all(operations.map(operation => operation())); + return transaction; + } + + async quit() { + this.isOpen = false; + } + + destroy() { + this.isOpen = false; + } +} + +const silentLogger = { error() {} }; + +{ + const state = await createAuthStateStore({ env: {} }); + assert.equal(state.status, 'disabled'); + assert.equal(state.backend, 'memory'); + await state.createSession('session-1', 'user-1'); + assert.deepEqual(await state.getSession('session-1'), { userId: 'user-1' }); + await state.createLoginChallenge('challenge-1', 'user-1'); + assert.deepEqual(await state.getLoginChallenge('challenge-1'), { userId: 'user-1', attempts: 0 }); + await state.createTotpSetup('session-1', 'user-1', 'SECRET'); + assert.deepEqual(await state.getTotpSetup('session-1'), { userId: 'user-1', secret: 'SECRET' }); + assert.equal(await state.deleteUserSessions('user-1'), 1); + assert.equal(await state.getSession('session-1'), null); + await state.close(); +} + +{ + const client = new FakeRedisClient(); + let clientOptions; + const state = await createAuthStateStore({ + env: { REDIS_URL: 'redis://cache.example:6379/0', REDIS_SESSION_PREFIX: 'test:auth' }, + logger: silentLogger, + clientFactory(options) { + clientOptions = options; + return client; + } + }); + + assert.equal(state.status, 'ready'); + assert.equal(state.backend, 'redis'); + assert.equal(state.database, 1, '普通缓存使用 DB 0 时,认证状态应自动使用 DB 1'); + assert.equal(clientOptions.database, 1); + assert.equal(clientOptions.url, 'redis://cache.example:6379/0'); + + await state.createSession('session-1', 'user-1'); + await state.createSession('session-2', 'user-1'); + assert.deepEqual(await state.getSession('session-1'), { userId: 'user-1' }); + assert.equal(await state.deleteUserSessions('user-1'), 2); + assert.equal(await state.getSession('session-1'), null); + assert.equal(await state.getSession('session-2'), null); + + await state.createLoginChallenge('challenge-1', 'user-1'); + for (let attempts = 1; attempts <= 4; attempts += 1) { + assert.deepEqual(await state.recordLoginChallengeFailure('challenge-1', 5), { attempts, exhausted: false }); + } + assert.deepEqual(await state.recordLoginChallengeFailure('challenge-1', 5), { attempts: 5, exhausted: true }); + assert.equal(await state.getLoginChallenge('challenge-1'), null); + + await state.createTotpSetup('session-3', 'user-1', 'SECRET'); + assert.deepEqual(await state.getTotpSetup('session-3'), { userId: 'user-1', secret: 'SECRET' }); + await state.deleteTotpSetup('session-3'); + assert.equal(await state.getTotpSetup('session-3'), null); + await state.close(); + assert.equal(client.isOpen, false); +} + +{ + const client = new FakeRedisClient(); + let options; + const state = await createAuthStateStore({ + env: { REDIS_URL: 'redis://cache.example:6379/0', REDIS_SESSION_URL: 'rediss://sessions.example:6380/4' }, + logger: silentLogger, + clientFactory(clientOptions) { + options = clientOptions; + return client; + } + }); + assert.equal(options.url, 'rediss://sessions.example:6380/4'); + assert.equal(options.database, 4); + await state.close(); +} + +{ + const client = new FakeRedisClient(); + let options; + const state = await createAuthStateStore({ + env: { REDIS_URL: 'redis://cache.example:6379/1', REDIS_SESSION_DB: '0' }, + logger: silentLogger, + clientFactory(clientOptions) { + options = clientOptions; + return client; + } + }); + assert.equal(options.database, 0, '应允许显式选择 DB 0,只要普通缓存使用不同的 DB'); + await state.close(); +} + +await assert.rejects( + createAuthStateStore({ + env: { REDIS_URL: 'redis://same.example:6379/0', REDIS_SESSION_URL: 'redis://same.example:6379/0' }, + logger: silentLogger, + clientFactory: () => new FakeRedisClient() + }), + /必须使用与普通缓存不同的逻辑数据库/ +); + +await assert.rejects( + createAuthStateStore({ + env: { REDIS_URL: 'redis://cache-user@same.example:6379/0', REDIS_SESSION_URL: 'redis://session-user@same.example:6379/0' }, + logger: silentLogger, + clientFactory: () => new FakeRedisClient() + }), + /必须使用与普通缓存不同的逻辑数据库/ +); + +await assert.rejects( + createAuthStateStore({ + env: { REDIS_URL: 'redis://unavailable.example:6379/0' }, + logger: silentLogger, + clientFactory: () => new FakeRedisClient({ connectError: new Error('connection refused') }) + }), + /认证状态存储连接失败/ +); + +console.log('✓ 认证状态本机回退、独立 Redis DB、会话失效与 TOTP 临时状态');