This commit is contained in:
2026-07-22 18:12:09 +08:00 Unverified
parent 56f1d7a273
commit 0b05dc2a09
12 changed files with 533 additions and 61 deletions
+5 -1
View File
@@ -19,5 +19,9 @@ PUBLIC_SITE_HERO_HIGHLIGHT=贯穿每一次考试。
PUBLIC_SITE_HERO_DESCRIPTION=使用学校下发的报名号登录,完成密码更新和个人信息核验后,即可办理所有考试事项。 PUBLIC_SITE_HERO_DESCRIPTION=使用学校下发的报名号登录,完成密码更新和个人信息核验后,即可办理所有考试事项。
PUBLIC_SITE_FOOTER_NOTICE=本平台展示数据仅用于系统演示 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_URL=redis://redis-host:6379/0
# REDIS_SESSION_DB=1
# 也可让认证状态使用另一台 Redis(此时 URL 中可指定自己的逻辑 DB)。
# REDIS_SESSION_URL=rediss://session-redis-host:6379/1
+7 -1
View File
@@ -5,12 +5,18 @@ SQLITE_PATH=./data/exam.sqlite
HOST=127.0.0.1 HOST=127.0.0.1
PORT=4173 PORT=4173
# 可选 Redis 公开接口缓存;未设置 REDIS_URL 时自动禁用并直接读取数据库 # 可选 Redis。普通接口缓存使用 DB 0;配置 REDIS_URL 后,登录状态默认自动使用独立的 DB 1
# 未设置任何 Redis 地址时,接口缓存和认证状态均使用本机内存模式。
# REDIS_URL=redis://127.0.0.1:6379/0 # REDIS_URL=redis://127.0.0.1:6379/0
# REDIS_CACHE_PREFIX=exam-information # REDIS_CACHE_PREFIX=exam-information
# REDIS_CACHE_TTL_SECONDS=60 # REDIS_CACHE_TTL_SECONDS=60
# REDIS_RESULTS_CACHE_TTL_SECONDS=86400 # REDIS_RESULTS_CACHE_TTL_SECONDS=86400
# REDIS_CONNECT_TIMEOUT_MS=1500 # 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 INITIAL_ADMIN_USERNAME=admin
+9 -3
View File
@@ -73,6 +73,7 @@
- 服务端角色权限校验 - 服务端角色权限校验
- SQLite / MySQL 8.4 双数据库持久化 - SQLite / MySQL 8.4 双数据库持久化
- 可选 Redis 公开接口缓存,支持写后版本失效、热点请求合并和故障回源 - 可选 Redis 公开接口缓存,支持写后版本失效、热点请求合并和故障回源
- Redis 登录状态存储,和普通接口缓存使用不同逻辑数据库,支持多实例共享会话
- 规范关系模型、外键、唯一约束和业务索引 - 规范关系模型、外键、唯一约束和业务索引
- 业务写入与审计日志使用原子事务提交 - 业务写入与审计日志使用原子事务提交
- 关键管理操作审计日志 - 关键管理操作审计日志
@@ -130,7 +131,7 @@ docker compose logs --follow app
docker build --tag hengzhun-exam-system:local . 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 软件包 #### Gitea Actions 自动发布到 Docker Hub 与 Gitea 软件包
@@ -233,6 +234,8 @@ npm start
配置 `REDIS_URL` 后,应用会缓存公开首页、已发布公告详情和每名考生的已发布成绩查询。公开数据默认 TTL 为 60 秒,成绩默认 TTL 为 24 小时;成绩录入/发布、批量导入、考试归档和成绩复议会自动使成绩缓存失效,超级管理员也可以在成绩管理中心手动刷新全部成绩缓存。Redis 未配置或暂时不可用时,应用会自动切换到有界本机缓存(默认最多 200 条),避免热点接口反复回源;写入后的失效语义保持不变。 配置 `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` 控制本机接口缓存上限。 数据库关系表会按写入版本复用进程内只读快照,避免每个请求重复扫描并转换全部业务表。应用自身写入会立即使快照失效;SQLite 还通过 `PRAGMA data_version` 检测其他连接的提交,MySQL 外部直写默认最多延迟 30 秒可见。可通过 `DATABASE_STATE_CACHE_TTL_MS` 调整 MySQL 快照时间,通过 `LOCAL_CACHE_MAX_ENTRIES` 控制本机接口缓存上限。
```powershell ```powershell
@@ -240,10 +243,12 @@ $env:REDIS_URL = 'redis://127.0.0.1:6379/0'
$env:REDIS_CACHE_PREFIX = 'exam-information' $env:REDIS_CACHE_PREFIX = 'exam-information'
$env:REDIS_CACHE_TTL_SECONDS = '60' $env:REDIS_CACHE_TTL_SECONDS = '60'
$env:REDIS_RESULTS_CACHE_TTL_SECONDS = '86400' $env:REDIS_RESULTS_CACHE_TTL_SECONDS = '86400'
$env:REDIS_SESSION_DB = '1'
$env:AUTH_SESSION_TTL_SECONDS = '28800'
npm start 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 测试数据 ### 导入服务器 MySQL 测试数据
@@ -341,7 +346,8 @@ src/data/base.mjs 空业务库与系统基础配置
src/data/seed.mjs 手动测试数据生成器 src/data/seed.mjs 手动测试数据生成器
scripts/import-test-data.mjs 独立测试数据导入脚本 scripts/import-test-data.mjs 独立测试数据导入脚本
src/http/responses.mjs JSON、文件与请求体处理 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/security/authorization.mjs 管理层级、权限和数据范围
src/routes/public.routes.mjs 公开 API src/routes/public.routes.mjs 公开 API
src/routes/auth.routes.mjs 登录、注册与改密 API src/routes/auth.routes.mjs 登录、注册与改密 API
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node server.mjs", "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", "test:cache": "node tests/cache.test.mjs",
"reset-db": "node scripts/reset-dev-database.mjs", "reset-db": "node scripts/reset-dev-database.mjs",
"seed-test-data": "node scripts/import-test-data.mjs", "seed-test-data": "node scripts/import-test-data.mjs",
+12 -4
View File
@@ -13,6 +13,7 @@ import { createAuthRoutes } from './src/routes/auth.routes.mjs';
import { createPublicRoutes } from './src/routes/public.routes.mjs'; import { createPublicRoutes } from './src/routes/public.routes.mjs';
import { adminLevelNames, adminScopeLabel, createPermissionGuard, hasPermission, permissionsByLevel, profileInScope, registrationInScope } from './src/security/authorization.mjs'; import { adminLevelNames, adminScopeLabel, createPermissionGuard, hasPermission, permissionsByLevel, profileInScope, registrationInScope } from './src/security/authorization.mjs';
import { createSessionManager } from './src/security/session.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 { readBodyBuffer, readJson, sendError, sendJson, sendWorkbook } from './src/http/responses.mjs';
import { createBaseDatabase } from './src/data/base.mjs'; import { createBaseDatabase } from './src/data/base.mjs';
import { resolveRegion } from './src/data/region-service.mjs'; import { resolveRegion } from './src/data/region-service.mjs';
@@ -41,7 +42,6 @@ const publicSiteConfig = Object.freeze({
heroDescription: process.env.PUBLIC_SITE_HERO_DESCRIPTION || '使用学校下发的报名号登录,完成密码更新和个人信息核验后,即可办理所有考试事项。', heroDescription: process.env.PUBLIC_SITE_HERO_DESCRIPTION || '使用学校下发的报名号登录,完成密码更新和个人信息核验后,即可办理所有考试事项。',
footerNotice: process.env.PUBLIC_SITE_FOOTER_NOTICE || '' footerNotice: process.env.PUBLIC_SITE_FOOTER_NOTICE || ''
}); });
const sessions = new Map();
const staticFiles = new Set([ const staticFiles = new Set([
'/index.html', '/index.html',
'/styles.css', '/styles.css',
@@ -105,6 +105,13 @@ const initializeDatabase = () => createBaseDatabase({
}); });
const persistentDatabase = await createDatabase({ root, seed: initializeDatabase }); const persistentDatabase = await createDatabase({ root, seed: initializeDatabase });
const cache = await createRedisCache(); 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 resultCacheWriteMethods = new Set(['saveResult', 'saveResults', 'updateFeatureScore', 'updateFeatureScores', 'updateExam', 'archiveExam']);
const database = withCacheInvalidation(persistentDatabase, cache, (method, args) => { const database = withCacheInvalidation(persistentDatabase, cache, (method, args) => {
const namespaces = ['public']; const namespaces = ['public'];
@@ -121,7 +128,7 @@ const documentNumberDb = await readDb();
const missingNoticeNumbers = admissionRecords(documentNumberDb, 'placement').filter(item => item.status === 'final' && !item.payload?.noticeNumber); const missingNoticeNumbers = admissionRecords(documentNumberDb, 'placement').filter(item => item.status === 'final' && !item.payload?.noticeNumber);
if (missingNoticeNumbers.length) await database.saveAdmissionRecords(assignAdmissionNoticeNumbers(documentNumberDb, missingNoticeNumbers)); 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); const requirePermission = createPermissionGuard(sendError);
function adminsForStep(db, adminLevel, profile) { function adminsForStep(db, adminLevel, profile) {
@@ -966,7 +973,7 @@ const routeContext = {
randomBytes, randomBytes,
uid, uid,
nowIso, nowIso,
sessions, authState,
buildWorkbook, buildWorkbook,
buildCenterMaterialsWorkbook, buildCenterMaterialsWorkbook,
hasExcelResource, hasExcelResource,
@@ -1020,12 +1027,13 @@ server.listen(port, host, () => {
console.log(`衡准考试信息管理系统:http://${host}:${port}`); console.log(`衡准考试信息管理系统:http://${host}:${port}`);
console.log(`数据库:${database.client}${database.location}`); console.log(`数据库:${database.client}${database.location}`);
console.log(`Redis 缓存:${cache.status === 'ready' ? '已连接' : cache.status === 'disabled' ? '未配置' : '不可用,已回源数据库'}`); console.log(`Redis 缓存:${cache.status === 'ready' ? '已连接' : cache.status === 'disabled' ? '未配置' : '不可用,已回源数据库'}`);
console.log(`登录状态:${authState.status === 'ready' ? `Redis DB ${authState.database}` : '本机内存(Redis 未配置)'}`);
}); });
async function shutdown(signal) { async function shutdown(signal) {
console.log(`收到 ${signal},正在关闭服务...`); console.log(`收到 ${signal},正在关闭服务...`);
server.close(async () => { server.close(async () => {
await Promise.allSettled([database.close(), cache.close()]); await Promise.allSettled([database.close(), cache.close(), authState.close()]);
process.exit(0); process.exit(0);
}); });
} }
+7 -7
View File
@@ -58,7 +58,7 @@ export function createAdminRoutes(context) {
randomBytes, randomBytes,
uid, uid,
nowIso, nowIso,
sessions, authState,
buildWorkbook, buildWorkbook,
buildCenterMaterialsWorkbook, buildCenterMaterialsWorkbook,
hasExcelResource, hasExcelResource,
@@ -506,7 +506,7 @@ export function createAdminRoutes(context) {
target.active = body.active == null ? target.active : Boolean(body.active); target.active = body.active == null ? target.active : Boolean(body.active);
target.displayName = cleanText(body.displayName || target.displayName, 80); target.displayName = cleanText(body.displayName || target.displayName, 80);
await database.updateAdmin(target, false, logAction(db, user, target.active ? '启用招生学校账号' : '停用招生学校账号', `${target.displayName} · ${target.username}`)); 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 } }); return sendJson(response, 200, { ok: true, account: { ...safeUser(target), active: target.active } });
} }
const admissionAccountResetMatch = pathname.match(/^\/api\/admin\/admission-school-accounts\/([^/]+)\/reset-password$/); const admissionAccountResetMatch = pathname.match(/^\/api\/admin\/admission-school-accounts\/([^/]+)\/reset-password$/);
@@ -518,7 +518,7 @@ export function createAdminRoutes(context) {
target.passwordHash = hashPassword(temporaryPassword); target.passwordHash = hashPassword(temporaryPassword);
target.active = true; target.active = true;
await database.updateAdmin(target, true, logAction(db, user, '重置招生学校账号密码', `${target.displayName} · ${target.username}`)); 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 }); return sendJson(response, 200, { ok: true, username: target.username, temporaryPassword });
} }
const settingMatch = pathname.match(/^\/api\/admin\/admissions\/([^/]+)\/setting$/); 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) }); 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); if (password) target.passwordHash = hashPassword(password);
await database.updateAdmin(target, Boolean(password), logAction(db, user, '维护管理员账户', `${target.displayName} · ${adminLevelNames[target.adminLevel]} · ${target.active ? '启用' : '停用'}`)); 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) }); return sendJson(response, 200, { ok: true, admin: safeUser(target) });
} }
const adminPasswordResetMatch = pathname.match(/^\/api\/admin\/admins\/([^/]+)\/reset-password$/); const adminPasswordResetMatch = pathname.match(/^\/api\/admin\/admins\/([^/]+)\/reset-password$/);
@@ -863,7 +863,7 @@ export function createAdminRoutes(context) {
target.passwordHash = hashPassword(temporaryPassword); target.passwordHash = hashPassword(temporaryPassword);
target.active = true; target.active = true;
await database.updateAdmin(target, true, logAction(db, user, '重置管理员密码', `${target.displayName} · ${target.username}`)); 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 }); return sendJson(response, 200, { ok: true, username: target.username, temporaryPassword });
} }
if (pathname === '/api/admin/settings/self-registration' && request.method === 'PUT') { 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} 个账户`)); await database.updateCandidateArchives(targets, logAction(db, user, archived ? '批量归档考生账户' : '批量恢复考生账户', `${scopeLabel} · ${targets.length} 个账户`));
if (archived && targets.length) { if (archived && targets.length) {
const targetUserIds = new Set(targets.map(item => item.id)); 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 }); return sendJson(response, 200, { ok: true, archived, count: targets.length, scopeLabel });
} }
@@ -1276,7 +1276,7 @@ export function createAdminRoutes(context) {
target.passwordHash = hashPassword(temporaryPassword); target.passwordHash = hashPassword(temporaryPassword);
target.mustChangePassword = true; target.mustChangePassword = true;
await database.changePassword(target, logAction(db, user, '重置考生密码', `${target.candidateNumber} · ${profile.name}`)); 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 }); return sendJson(response, 200, { ok: true, candidateNumber: target.candidateNumber, temporaryPassword });
} }
const candidateMatch = pathname.match(/^\/api\/admin\/candidates\/([^/]+)$/); const candidateMatch = pathname.match(/^\/api\/admin\/candidates\/([^/]+)$/);
+21 -36
View File
@@ -56,7 +56,7 @@ export function createAuthRoutes(context) {
randomBytes, randomBytes,
uid, uid,
nowIso, nowIso,
sessions, authState,
buildWorkbook, buildWorkbook,
hasExcelResource, hasExcelResource,
parseWorkbook, parseWorkbook,
@@ -64,21 +64,11 @@ export function createAuthRoutes(context) {
permissionsByLevel permissionsByLevel
} = context; } = context;
const loginChallenges = new Map(); async function issueSession(user) {
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'); 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' : ''; 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) { function sessionToken(request) {
@@ -127,7 +117,6 @@ export function createAuthRoutes(context) {
return sendJson(response, 201, { ok: true, registrationNumber: generated.number, message: '报名号已生成,请使用该号码登录并补全个人信息' }); return sendJson(response, 201, { ok: true, registrationNumber: generated.number, message: '报名号已生成,请使用该号码登录并补全个人信息' });
} }
if (request.method === 'POST' && pathname === '/api/auth/login') { if (request.method === 'POST' && pathname === '/api/auth/login') {
pruneTemporaryState();
const body = await readJson(request); const body = await readJson(request);
const db = await readDb(); const db = await readDb();
const account = cleanText(body.username, 120).toLowerCase(); 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 || user.active === false || user.archivedAt || !verifyPassword(String(body.password || ''), user.passwordHash)) return sendError(response, 401, '账号或密码不正确');
if (user.totpEnabled) { if (user.totpEnabled) {
const challenge = randomBytes(32).toString('base64url'); 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 }); 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 }); return sendJson(response, 200, { ok: true, user: safeUser(user) }, { 'Set-Cookie': session.cookie });
} }
if (request.method === 'POST' && pathname === '/api/auth/login/totp') { if (request.method === 'POST' && pathname === '/api/auth/login/totp') {
pruneTemporaryState();
const body = await readJson(request); const body = await readJson(request);
const challengeKey = String(body.challenge || ''); const challengeKey = String(body.challenge || '');
const challenge = loginChallenges.get(challengeKey); const challenge = await authState.getLoginChallenge(challengeKey);
if (!challenge || challenge.expiresAt < Date.now() || challenge.attempts >= 5) { if (!challenge || challenge.attempts >= 5) {
loginChallenges.delete(challengeKey); await authState.deleteLoginChallenge(challengeKey);
return sendError(response, 401, '验证请求已过期,请重新输入账号和密码'); return sendError(response, 401, '验证请求已过期,请重新输入账号和密码');
} }
const db = await readDb(); const db = await readDb();
const user = db.users.find(item => item.id === challenge.userId); const user = db.users.find(item => item.id === challenge.userId);
if (!user || !user.totpEnabled || user.active === false || user.archivedAt) { if (!user || !user.totpEnabled || user.active === false || user.archivedAt) {
loginChallenges.delete(challengeKey); await authState.deleteLoginChallenge(challengeKey);
return sendError(response, 401, '验证请求已失效,请重新登录'); return sendError(response, 401, '验证请求已失效,请重新登录');
} }
let verified = null; let verified = null;
try { verified = verifySecondFactor(user, body.code); } catch {} try { verified = verifySecondFactor(user, body.code); } catch {}
if (!verified) { if (!verified) {
challenge.attempts += 1; const failure = await authState.recordLoginChallengeFailure(challengeKey, 5);
if (challenge.attempts >= 5) loginChallenges.delete(challengeKey); return sendError(response, 401, failure?.exhausted ? '验证失败次数过多,请重新登录' : failure ? '验证码或恢复码不正确' : '验证请求已过期,请重新输入账号和密码');
return sendError(response, 401, challenge.attempts >= 5 ? '验证失败次数过多,请重新登录' : '验证码或恢复码不正确');
} }
if (verified.type === 'totp') user.totpLastUsedStep = verified.step; if (verified.type === 'totp') user.totpLastUsedStep = verified.step;
else user.totpRecoveryCodes = verified.recoveryCodes; else user.totpRecoveryCodes = verified.recoveryCodes;
const log = verified.type === 'recovery' ? logAction(db, user, '使用 TOTP 恢复码登录', user.username) : null; const log = verified.type === 'recovery' ? logAction(db, user, '使用 TOTP 恢复码登录', user.username) : null;
await database.updateTotpSecurity(user, log); await database.updateTotpSecurity(user, log);
loginChallenges.delete(challengeKey); await authState.deleteLoginChallenge(challengeKey);
const session = issueSession(user); const session = await issueSession(user);
return sendJson(response, 200, { ok: true, user: safeUser(user), usedRecoveryCode: verified.type === 'recovery' }, { 'Set-Cookie': session.cookie }); 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') { 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') { if (request.method === 'POST' && pathname === '/api/auth/totp/setup') {
pruneTemporaryState();
const user = await requireUser(request, response); const user = await requireUser(request, response);
if (!user) return true; if (!user) return true;
if (user.mustChangePassword) return sendError(response, 400, '请先修改初始密码,再启用二次验证'); if (user.mustChangePassword) return sendError(response, 400, '请先修改初始密码,再启用二次验证');
@@ -209,18 +195,17 @@ export function createAuthRoutes(context) {
const secret = createTotpSecret(); const secret = createTotpSecret();
const uri = buildOtpAuthUri({ secret, account: user.candidateNumber || user.username, issuer }); const uri = buildOtpAuthUri({ secret, account: user.candidateNumber || user.username, issuer });
const token = sessionToken(request); 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 }); const qrCode = await QRCode.toDataURL(uri, { errorCorrectionLevel: 'M', margin: 1, width: 240 });
return sendJson(response, 200, { ok: true, secret, uri, qrCode, expiresIn: 600 }); return sendJson(response, 200, { ok: true, secret, uri, qrCode, expiresIn: 600 });
} }
if (request.method === 'POST' && pathname === '/api/auth/totp/enable') { if (request.method === 'POST' && pathname === '/api/auth/totp/enable') {
pruneTemporaryState();
const user = await requireUser(request, response); const user = await requireUser(request, response);
if (!user) return true; if (!user) return true;
const token = sessionToken(request); const token = sessionToken(request);
const setup = totpSetups.get(token); const setup = await authState.getTotpSetup(token);
if (!setup || setup.userId !== user.id || setup.expiresAt < Date.now()) { if (!setup || setup.userId !== user.id) {
totpSetups.delete(token); await authState.deleteTotpSetup(token);
return sendError(response, 400, '绑定信息已过期,请重新开始'); return sendError(response, 400, '绑定信息已过期,请重新开始');
} }
const body = await readJson(request); const body = await readJson(request);
@@ -234,7 +219,7 @@ export function createAuthRoutes(context) {
const db = await readDb(); const db = await readDb();
const log = logAction(db, user, '启用 TOTP 二次验证', user.username); const log = logAction(db, user, '启用 TOTP 二次验证', user.username);
await database.updateTotpSecurity(user, log); await database.updateTotpSecurity(user, log);
totpSetups.delete(token); await authState.deleteTotpSetup(token);
return sendJson(response, 200, { ok: true, recoveryCodes, user: safeUser(user) }); return sendJson(response, 200, { ok: true, recoveryCodes, user: safeUser(user) });
} }
if (request.method === 'POST' && pathname === '/api/auth/totp/recovery-codes') { if (request.method === 'POST' && pathname === '/api/auth/totp/recovery-codes') {
@@ -270,12 +255,12 @@ export function createAuthRoutes(context) {
const db = await readDb(); const db = await readDb();
const log = logAction(db, user, '关闭 TOTP 二次验证', user.username); const log = logAction(db, user, '关闭 TOTP 二次验证', user.username);
await database.updateTotpSecurity(user, log); await database.updateTotpSecurity(user, log);
totpSetups.delete(sessionToken(request)); await authState.deleteTotpSetup(sessionToken(request));
return sendJson(response, 200, { ok: true, user: safeUser(user) }); return sendJson(response, 200, { ok: true, user: safeUser(user) });
} }
if (request.method === 'POST' && pathname === '/api/auth/logout') { if (request.method === 'POST' && pathname === '/api/auth/logout') {
const token = parseCookies(request).hz_session; 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 sendJson(response, 200, { ok: true }, { 'Set-Cookie': 'hz_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0' });
} }
return false; return false;
-1
View File
@@ -55,7 +55,6 @@ export function createCandidateRoutes(context) {
verifyPassword, verifyPassword,
uid, uid,
nowIso, nowIso,
sessions,
buildWorkbook, buildWorkbook,
hasExcelResource, hasExcelResource,
parseWorkbook, parseWorkbook,
-1
View File
@@ -47,7 +47,6 @@ export function createPublicRoutes(context) {
verifyPassword, verifyPassword,
uid, uid,
nowIso, nowIso,
sessions,
buildWorkbook, buildWorkbook,
hasExcelResource, hasExcelResource,
parseWorkbook, parseWorkbook,
+259
View File
@@ -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();
}
};
}
+3 -6
View File
@@ -1,4 +1,4 @@
export function createSessionManager({ sessions, readDb, sendError }) { export function createSessionManager({ authState, readDb, sendError }) {
function normalizeUser(user) { function normalizeUser(user) {
if (user?.role === 'admin' && !user.adminLevel) return { ...user, adminLevel: 'super' }; if (user?.role === 'admin' && !user.adminLevel) return { ...user, adminLevel: 'super' };
return user; return user;
@@ -13,11 +13,8 @@ export function createSessionManager({ sessions, readDb, sendError }) {
async function currentUser(request) { async function currentUser(request) {
const token = parseCookies(request).hz_session; const token = parseCookies(request).hz_session;
const session = token && sessions.get(token); const session = token ? await authState.getSession(token) : null;
if (!session || session.expiresAt < Date.now()) { if (!session) return null;
if (token) sessions.delete(token);
return null;
}
const db = await readDb(); const db = await readDb();
request.authDb = db; request.authDb = db;
const user = db.users.find(item => item.id === session.userId) || null; const user = db.users.find(item => item.id === session.userId) || null;
+209
View File
@@ -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 临时状态');