210 lines
6.7 KiB
JavaScript
210 lines
6.7 KiB
JavaScript
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 临时状态');
|