redis
This commit is contained in:
+5
-1
@@ -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
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+12
-4
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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\/([^/]+)$/);
|
||||
|
||||
+21
-36
@@ -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;
|
||||
|
||||
@@ -55,7 +55,6 @@ export function createCandidateRoutes(context) {
|
||||
verifyPassword,
|
||||
uid,
|
||||
nowIso,
|
||||
sessions,
|
||||
buildWorkbook,
|
||||
hasExcelResource,
|
||||
parseWorkbook,
|
||||
|
||||
@@ -47,7 +47,6 @@ export function createPublicRoutes(context) {
|
||||
verifyPassword,
|
||||
uid,
|
||||
nowIso,
|
||||
sessions,
|
||||
buildWorkbook,
|
||||
hasExcelResource,
|
||||
parseWorkbook,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 临时状态');
|
||||
Reference in New Issue
Block a user