Prevent cross-address login lockouts (Refs #63)
CI / checks (push) Successful in 6m30s

This commit is contained in:
2026-10-17 10:55:28 -04:00
parent f3b9699317
commit 4501eba873
14 changed files with 922 additions and 61 deletions
+386
View File
@@ -0,0 +1,386 @@
import { createHash, randomUUID } from 'node:crypto';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { AuthUser, UserRole } from '../../src/lib/auth.js';
import { newFixture, type Fixture } from '../helpers/fixture.js';
// Exercise real password verification without weakening production scrypt costs.
vi.setConfig({ testTimeout: 20_000, hookTimeout: 20_000 });
const password = 'testpass';
const replacement = 'a replacement account passphrase';
const firstAddress = '192.0.2.1';
const limitedAddress = '192.0.2.2';
let passwordHash: string;
let fixture: Fixture;
let auth: typeof import('../../src/lib/server/auth/index.js');
let database: typeof import('../../src/lib/server/db/index.js');
beforeAll(async () => {
const passwords = await import('../../src/lib/server/auth/passwords.js');
passwordHash = await passwords.hashPassword(password);
});
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([]);
auth = await import('../../src/lib/server/auth/index.js');
database = await import('../../src/lib/server/db/index.js');
auth.setupRequired();
});
afterEach(async () => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
await fixture.cleanup();
});
function seedUser(username = 'viewer', role: UserRole = 'viewer', disabled = false): AuthUser {
const id = randomUUID();
const now = Date.now();
database
.db()
.prepare(
`INSERT INTO auth_users(id,username,display_name,password_hash,role,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`
)
.run(id, username, username, passwordHash, role, Number(disabled), now, now);
return auth.getUser(id)!;
}
function exhaustPair(username: string, address = limitedAddress) {
for (let attempt = 0; attempt < 10; attempt++) auth.consumeAuthAttempt(address, username);
}
function devices() {
return database.db().prepare('SELECT * FROM auth_login_devices ORDER BY rowid').all();
}
async function loginRoute(
input: Record<string, unknown>,
options: { cookie?: string; address?: string; origin?: string } = {}
) {
const { POST } = await import('../../src/routes/api/auth/login/+server.js');
const url = new URL('/api/auth/login', options.origin ?? 'https://ec.test');
const deviceName = url.protocol === 'https:' ? '__Host-ec_login_device' : 'ec_login_device_dev';
const cookies = {
get: vi.fn((name: string) => (name === deviceName ? options.cookie : undefined)),
set: vi.fn()
};
const response = await POST({
request: new Request(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input)
}),
cookies,
url,
getClientAddress: () => options.address ?? firstAddress
} as unknown as Parameters<typeof POST>[0]);
return { response, cookies };
}
describe('known login device proof', () => {
it('creates an account-bound, hashed proof after password verification that cannot authenticate a session', async () => {
const now = 2_000_000_000_000;
vi.spyOn(Date, 'now').mockReturnValue(now);
const user = seedUser();
const result = await auth.authenticatePassword(
{ username: user.username, password },
firstAddress
);
const device = result.loginDevice!;
expect(device.token).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(device.token).not.toBe(result.session.token);
expect(device.expiresAt).toBe(now + 30 * 24 * 60 * 60_000);
expect(devices()).toEqual([
{
token_hash: createHash('sha256').update(device.token).digest('hex'),
user_id: user.id,
created_at: now,
expires_at: device.expiresAt
}
]);
expect(auth.readSession(device.token)).toBeNull();
expect(auth.readSession(result.session.token)?.user.id).toBe(user.id);
const persisted = JSON.stringify(devices()) + JSON.stringify(auth.listAuthAudit());
expect(persisted).not.toContain(device.token);
expect(persisted).not.toContain(result.session.token);
expect(persisted).not.toContain(password);
});
it('allows a proven account past an exhausted pair while still rejecting a wrong password', async () => {
const user = seedUser();
const initial = await auth.authenticatePassword(
{ username: user.username, password },
firstAddress
);
exhaustPair(user.username);
await expect(
auth.authenticatePassword({ username: user.username, password }, limitedAddress)
).rejects.toMatchObject({ status: 429 });
const original = devices();
await expect(
auth.authenticatePassword(
{ username: user.username, password: 'wrong password' },
limitedAddress,
initial.loginDevice!.token
)
).rejects.toMatchObject({ status: 401 });
expect(devices()).toEqual(original);
const result = await auth.authenticatePassword(
{ username: user.username.toUpperCase(), password },
limitedAddress,
initial.loginDevice!.token
);
expect(result.user.id).toBe(user.id);
expect(result.loginDevice).toEqual(initial.loginDevice);
expect(devices()).toEqual(original);
});
it.each(['other account', 'malformed', 'unknown', 'expired', 'session token'] as const)(
'does not bypass an exhausted pair with %s proof',
async (kind) => {
const user = seedUser();
const initial = await auth.authenticatePassword(
{ username: user.username, password },
firstAddress
);
const target = kind === 'other account' ? seedUser('another-viewer') : user;
let token = initial.loginDevice!.token;
if (kind === 'malformed') token += '!';
if (kind === 'unknown') token = 'x'.repeat(43);
if (kind === 'session token') token = initial.session.token;
if (kind === 'expired')
database.db().prepare('UPDATE auth_login_devices SET expires_at=?').run(Date.now());
exhaustPair(target.username);
const original = devices();
await expect(
auth.authenticatePassword(
{ username: target.username, password },
limitedAddress,
token
)
).rejects.toMatchObject({ status: 429 });
expect(devices()).toEqual(original);
}
);
it('continues enforcing the shared IP ceiling for a valid device', async () => {
const user = seedUser();
const initial = await auth.authenticatePassword(
{ username: user.username, password },
firstAddress
);
for (let attempt = 0; attempt < 30; attempt++)
auth.consumeAuthAttempt(limitedAddress, `missing-${attempt}`);
await expect(
auth.authenticatePassword(
{ username: user.username, password },
limitedAddress,
initial.loginDevice!.token
)
).rejects.toMatchObject({ status: 429 });
expect(devices()).toHaveLength(1);
});
it('accepts proof only from the request cookie and ignores body-only trust assertions', async () => {
const user = seedUser();
const initial = await auth.authenticatePassword(
{ username: user.username, password },
firstAddress
);
exhaustPair(user.username);
const input = {
username: user.username,
password,
loginDevice: initial.loginDevice!.token,
trusted: true,
knownDevice: true
};
const rejected = await loginRoute(input, { address: limitedAddress });
expect(rejected.response.status).toBe(429);
expect(rejected.cookies.set).not.toHaveBeenCalled();
const accepted = await loginRoute(input, {
address: limitedAddress,
cookie: initial.loginDevice!.token
});
expect(accepted.response.status).toBe(200);
expect(accepted.cookies.get).toHaveBeenCalledWith('__Host-ec_login_device');
expect(devices()).toHaveLength(1);
});
it('retains the original absolute expiry when the database is reopened and the device logs in again', async () => {
const now = 2_000_000_000_000;
const clock = vi.spyOn(Date, 'now').mockReturnValue(now);
const user = seedUser();
const initial = await auth.authenticatePassword(
{ username: user.username, password },
firstAddress
);
const original = devices();
database.closeDb();
clock.mockReturnValue(now + 5 * 24 * 60 * 60_000);
exhaustPair(user.username);
const next = await auth.authenticatePassword(
{ username: user.username, password },
limitedAddress,
initial.loginDevice!.token
);
expect(next.loginDevice).toEqual(initial.loginDevice);
expect(devices()).toEqual(original);
clock.mockReturnValue(initial.loginDevice!.expiresAt);
exhaustPair(user.username);
await expect(
auth.authenticatePassword(
{ username: user.username, password },
limitedAddress,
initial.loginDevice!.token
)
).rejects.toMatchObject({ status: 429 });
});
it('does not issue device cookies or store proof for failed password logins', async () => {
seedUser();
seedUser('disabled', 'viewer', true);
for (const input of [
{ username: 'viewer', password: 'wrong password' },
{ username: 'missing', password },
{ username: 'disabled', password }
]) {
const result = await loginRoute(input);
expect(result.response.status).toBe(401);
expect(result.cookies.set).not.toHaveBeenCalled();
expect(devices()).toEqual([]);
}
});
});
describe('known login device lifecycle', () => {
it('keeps device proof through logout while removing the session cookie and session', async () => {
const user = seedUser();
const initial = await auth.authenticatePassword(
{ username: user.username, password },
firstAddress
);
const original = devices();
const { POST } = await import('../../src/routes/api/auth/logout/+server.js');
const cookies = {
get: vi.fn((name: string) =>
name === '__Host-ec_session' ? initial.session.token : initial.loginDevice!.token
),
delete: vi.fn()
};
const response = await POST({
cookies,
url: new URL('https://ec.test/api/auth/logout'),
locals: { user }
} as unknown as Parameters<typeof POST>[0]);
expect(response.status).toBe(200);
expect(cookies.delete.mock.calls).toEqual([['__Host-ec_session', { path: '/' }]]);
expect(auth.readSession(initial.session.token)).toBeNull();
expect(devices()).toEqual(original);
exhaustPair(user.username);
await expect(
auth.authenticatePassword(
{ username: user.username, password },
limitedAddress,
initial.loginDevice!.token
)
).resolves.toMatchObject({ user: { id: user.id }, loginDevice: initial.loginDevice });
});
it.each(['role', 'disable', 'password reset', 'password change', 'revoke sessions'] as const)(
'revokes device proof after an account %s operation',
async (operation) => {
const admin = seedUser('administrator', 'admin');
const user = seedUser();
const initial = await auth.authenticatePassword(
{ username: user.username, password },
firstAddress
);
if (operation === 'role')
await auth.updateUser(admin.id, user.id, { role: 'operator' });
if (operation === 'disable')
await auth.updateUser(admin.id, user.id, { disabled: true });
if (operation === 'password reset')
await auth.updateUser(admin.id, user.id, { password: replacement });
if (operation === 'password change')
await auth.changePassword(
user.id,
password,
replacement,
auth.readSession(initial.session.token)!.tokenHash
);
if (operation === 'revoke sessions') auth.revokeUserSessions(admin.id, user.id);
expect(devices()).toEqual([]);
expect(auth.readSession(initial.session.token)).toBeNull();
exhaustPair(user.username);
await expect(
auth.authenticatePassword(
{
username: user.username,
password: operation.startsWith('password') ? replacement : password
},
limitedAddress,
initial.loginDevice!.token
)
).rejects.toMatchObject({ status: 429 });
}
);
it.each([
{ origin: 'https://ec.test', rememberMe: false },
{ origin: 'https://ec.test', rememberMe: true },
{ origin: 'http://localhost', rememberMe: false }
])('sets a separate device cookie for $origin with rememberMe=$rememberMe', async (options) => {
const now = 2_000_000_000_000;
vi.spyOn(Date, 'now').mockReturnValue(now);
const user = seedUser();
const { response, cookies } = await loginRoute(
{ username: user.username, password, rememberMe: options.rememberMe },
options
);
expect(response.status).toBe(200);
expect(cookies.set).toHaveBeenCalledTimes(2);
const secure = options.origin.startsWith('https:');
const deviceName = secure ? '__Host-ec_login_device' : 'ec_login_device_dev';
const sessionName = secure ? '__Host-ec_session' : 'ec_session_dev';
const [, deviceToken, deviceOptions] = cookies.set.mock.calls.find(
([name]) => name === deviceName
)!;
const [, sessionToken, sessionOptions] = cookies.set.mock.calls.find(
([name]) => name === sessionName
)!;
expect(deviceOptions).toEqual({
path: '/',
httpOnly: true,
secure,
sameSite: 'lax',
expires: new Date(now + 30 * 24 * 60 * 60_000)
});
expect(sessionOptions.expires).toEqual(
new Date(
now +
(options.rememberMe
? auth.REMEMBERED_SESSION_LIFETIME_MS
: auth.SESSION_LIFETIME_MS)
)
);
expect(deviceToken).not.toBe(sessionToken);
expect(auth.readSession(deviceToken)).toBeNull();
expect(auth.readSession(sessionToken)?.remembered).toBe(options.rememberMe);
expect(response.headers.get('Cache-Control')).toBe('no-store');
expect(await response.json()).toEqual({ user: expect.objectContaining({ id: user.id }) });
});
});
+175
View File
@@ -0,0 +1,175 @@
import Database from 'better-sqlite3';
import { createHash } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { recoverAdministrator } from '../../scripts/reset-admin.mjs';
import { verifyPassword } from '../../src/lib/server/auth/passwords.js';
import { migrateAuthSchema } from '../../src/lib/server/auth/schema.js';
vi.setConfig({ testTimeout: 20_000 });
let database: Database.Database;
const password = 'replacement administrator passphrase';
const digest = (value: string) => createHash('sha256').update(value).digest('hex');
const pairKey = (kind: string, account: string, ip: string) =>
`${kind}:account:${digest(account.toLowerCase())}:ip:${digest(ip)}`;
beforeEach(() => {
database = new Database(':memory:');
database.pragma('foreign_keys = ON');
migrateAuthSchema(database);
database.exec(`
INSERT INTO auth_users VALUES
('admin-id','Admin_One','Admin','original-hash','admin',0,1,10,20,NULL),
('other-id','Other_Admin','Other','other-hash','admin',0,0,10,20,NULL),
('viewer-id','Viewer','Viewer','viewer-hash','viewer',0,0,10,20,NULL),
('disabled-id','Disabled_Admin','Disabled','disabled-hash','admin',1,0,10,20,NULL);
INSERT INTO auth_sessions VALUES
('admin-session','admin-id',10,1000,100),
('other-session','other-id',10,1000,100);
`);
});
afterEach(() => database.close());
function rateKeys() {
return database.prepare('SELECT key FROM auth_rate_limits ORDER BY key').all();
}
function addRates(keys: string[]) {
const insert = database.prepare('INSERT INTO auth_rate_limits VALUES (?,100,30)');
for (const key of keys) insert.run(key);
}
function addDevices() {
database.exec(`
CREATE TABLE auth_login_devices (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL
);
INSERT INTO auth_login_devices VALUES
('admin-device','admin-id',1000,10),
('other-device','other-id',1000,10);
`);
}
describe('local administrator recovery', () => {
it('resets only the target administrator and clears their sessions, devices and account limits', async () => {
addDevices();
const cleared = [
pairKey('login', 'admin_one', '192.0.2.1'),
pairKey('login', 'admin_one', '192.0.2.2'),
digest('login:account:admin_one'),
digest('password:account:admin-id')
];
const preserved = [
pairKey('login', 'other_admin', '192.0.2.1'),
pairKey('setup', 'admin_one', '192.0.2.1'),
digest('login:account:other_admin'),
digest('password:account:other-id'),
digest('login:ip:192.0.2.1'),
digest('password:ip:192.0.2.1'),
digest('setup:ip:192.0.2.1')
];
addRates([...cleared, ...preserved]);
const others = database.prepare("SELECT * FROM auth_users WHERE id!='admin-id'").all();
await recoverAdministrator(database, 'ADMIN_ONE', password);
const user = database
.prepare(
"SELECT password_hash,must_change_password,updated_at FROM auth_users WHERE id='admin-id'"
)
.get() as { password_hash: string; must_change_password: number; updated_at: number };
expect(await verifyPassword(password, user.password_hash)).toBe(true);
expect(user.must_change_password).toBe(0);
expect(user.updated_at).toBeGreaterThan(20);
expect(database.prepare("SELECT * FROM auth_users WHERE id!='admin-id'").all()).toEqual(
others
);
expect(database.prepare('SELECT token_hash,user_id FROM auth_sessions').all()).toEqual([
{ token_hash: 'other-session', user_id: 'other-id' }
]);
expect(database.prepare('SELECT token_hash,user_id FROM auth_login_devices').all()).toEqual(
[{ token_hash: 'other-device', user_id: 'other-id' }]
);
expect(rateKeys()).toEqual(preserved.sort().map((key) => ({ key })));
expect(
database.prepare('SELECT actor_id,action,target_id,details FROM auth_audit').all()
).toEqual([
{
actor_id: null,
action: 'password.recovered',
target_id: 'admin-id',
details: '{"source":"local-cli"}'
}
]);
});
it('supports older databases without trusted-device tables and clears their legacy account limits', async () => {
addRates([digest('login:account:admin_one'), digest('password:account:admin-id')]);
await recoverAdministrator(database, 'admin_one', password);
expect(rateKeys()).toEqual([]);
expect(
database.prepare("SELECT * FROM auth_sessions WHERE user_id='admin-id'").all()
).toEqual([]);
expect(
database.prepare("SELECT name FROM sqlite_master WHERE name='auth_login_devices'").get()
).toBeUndefined();
});
it('rejects disabled, non-administrator and missing accounts without making changes', async () => {
const users = database.prepare('SELECT * FROM auth_users').all();
const sessions = database.prepare('SELECT * FROM auth_sessions').all();
for (const username of ['disabled_admin', 'viewer', 'missing']) {
await expect(recoverAdministrator(database, username, password)).rejects.toThrow(
'An enabled administrator with that username was not found.'
);
}
expect(database.prepare('SELECT * FROM auth_users').all()).toEqual(users);
expect(database.prepare('SELECT * FROM auth_sessions').all()).toEqual(sessions);
expect(database.prepare('SELECT * FROM auth_audit').all()).toEqual([]);
});
it('rolls back the password, session, device and rate changes when recovery cannot be audited', async () => {
addDevices();
addRates([pairKey('login', 'admin_one', '192.0.2.1')]);
database.exec(`
CREATE TRIGGER reject_recovery BEFORE INSERT ON auth_audit
BEGIN SELECT RAISE(ABORT, 'audit unavailable'); END;
`);
const tables = ['auth_users', 'auth_sessions', 'auth_login_devices', 'auth_rate_limits'];
const before = tables.map((table) => database.prepare(`SELECT * FROM ${table}`).all());
await expect(recoverAdministrator(database, 'admin_one', password)).rejects.toThrow(
'audit unavailable'
);
expect(tables.map((table) => database.prepare(`SELECT * FROM ${table}`).all())).toEqual(
before
);
expect(database.prepare('SELECT * FROM auth_audit').all()).toEqual([]);
});
it('keeps the command restricted to interactive password entry', () => {
const command = spawnSync(process.execPath, ['scripts/reset-admin.mjs', 'admin_one'], {
encoding: 'utf8',
input: password
});
expect(command.status).toBe(1);
expect(command.stderr).toContain('Run this command in an interactive terminal.');
expect(command.stdout).not.toContain(password);
const argument = spawnSync(
process.execPath,
['scripts/reset-admin.mjs', 'admin_one', password],
{
encoding: 'utf8'
}
);
expect(argument.status).toBe(1);
expect(argument.stderr).toContain('Usage: node scripts/reset-admin.mjs');
expect(argument.stderr).not.toContain(password);
});
});
+90 -1
View File
@@ -1,6 +1,10 @@
import Database from 'better-sqlite3';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
migrateLoginDevices,
migrateModeratorRole,
migrateRememberedSessions,
migrateSuperViewerRole
@@ -9,6 +13,7 @@ import { runMigrations } from '../../src/lib/server/db/migration-runner.js';
import { migrations } from '../../src/lib/server/db/migrations.js';
let handle: Database.Database;
const temporaryDirectories: string[] = [];
const migration = migrations.find((entry) => entry.name === 'super viewer authentication role')!;
beforeEach(() => {
@@ -30,7 +35,11 @@ beforeEach(() => {
`);
});
afterEach(() => handle.close());
afterEach(() => {
handle.close();
for (const directory of temporaryDirectories.splice(0))
rmSync(directory, { recursive: true, force: true });
});
function rows(table: string) {
// Compare the original session fields across migrations that add policy columns.
@@ -155,6 +164,86 @@ describe('remembered session migration', () => {
});
});
describe('known login device migration', () => {
it('preserves existing authentication data and cascades device removal when an account is deleted', () => {
const upgrade = migrations.find((entry) => entry.name === 'known login devices')!;
runMigrations(handle, migrations.slice(0, upgrade.version - 1));
handle.exec("UPDATE auth_sessions SET remembered=1 WHERE user_id='admin'");
const tables = ['auth_users', 'auth_audit', 'auth_rate_limits', 'servers'];
const original = tables.map(rows);
const sessions = handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all();
runMigrations(handle, migrations);
expect(tables.map(rows)).toEqual(original);
expect(handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all()).toEqual(
sessions
);
expect(rows('auth_login_devices')).toEqual([]);
expect(handle.pragma('foreign_key_list(auth_login_devices)')).toEqual([
expect.objectContaining({ table: 'auth_users', from: 'user_id', on_delete: 'CASCADE' })
]);
expect(handle.pragma('index_info(auth_login_devices_user)')).toEqual([
expect.objectContaining({ name: 'user_id' })
]);
expect(handle.pragma('index_info(auth_login_devices_expires_at)')).toEqual([
expect.objectContaining({ name: 'expires_at' })
]);
handle.exec(`
INSERT INTO auth_login_devices VALUES
('admin-device-hash','admin',100,3000),
('operator-device-hash','operator',200,4000);
`);
expect(() =>
handle.exec("INSERT INTO auth_login_devices VALUES ('missing-device','missing',0,1)")
).toThrow('FOREIGN KEY constraint failed');
expect(() =>
handle.exec("INSERT INTO auth_login_devices VALUES ('admin-device-hash','admin',0,1)")
).toThrow('UNIQUE constraint failed');
handle.exec("DELETE FROM auth_users WHERE id='operator'");
expect(rows('auth_login_devices')).toEqual([
{ token_hash: 'admin-device-hash', user_id: 'admin', created_at: 100, expires_at: 3000 }
]);
expect(handle.prepare('SELECT user_id FROM auth_sessions').all()).toEqual([
{ user_id: 'admin' }
]);
expect(handle.pragma('foreign_key_check')).toEqual([]);
});
it('keeps device hashes and existing authentication data when replayed or reopened', () => {
const upgrade = migrations.find((entry) => entry.name === 'known login devices')!;
runMigrations(handle, migrations);
handle.exec("INSERT INTO auth_login_devices VALUES ('device-hash','admin',100,3000)");
const tables = ['auth_users', 'auth_audit', 'auth_rate_limits', 'auth_login_devices'];
const original = tables.map(rows);
const sessions = handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all();
const schemaVersion = handle.pragma('schema_version', { simple: true });
migrateLoginDevices(handle);
handle.pragma(`user_version = ${upgrade.version - 1}`);
runMigrations(handle, migrations);
expect(tables.map(rows)).toEqual(original);
expect(handle.pragma('schema_version', { simple: true })).toBe(schemaVersion);
const directory = mkdtempSync(join(tmpdir(), 'electric-cherry-auth-schema-'));
temporaryDirectories.push(directory);
const databasePath = join(directory, 'auth.sqlite');
writeFileSync(databasePath, handle.serialize());
handle.close();
handle = new Database(databasePath);
handle.pragma('foreign_keys = ON');
runMigrations(handle, migrations);
expect(handle.pragma('user_version', { simple: true })).toBe(migrations.length);
expect(handle.pragma('schema_version', { simple: true })).toBe(schemaVersion);
expect(tables.map(rows)).toEqual(original);
expect(handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all()).toEqual(
sessions
);
expect(handle.pragma('foreign_key_check')).toEqual([]);
});
});
describe('moderator role migration', () => {
it('preserves all account roles and remembered sessions while extending the role constraint', () => {
const upgrade = migrations.find((entry) => entry.name === 'moderator authentication role')!;
+35 -6
View File
@@ -1,4 +1,4 @@
import { randomUUID } from 'node:crypto';
import { createHash, randomUUID } from 'node:crypto';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
// These tests intentionally use production-strength scrypt while the complete
@@ -871,6 +871,28 @@ describe('remembered administrator account changes', () => {
});
describe('persistent authentication throttling', () => {
it('blocks an exhausted account/address pair without locking out the account at another address', async () => {
const user = seedUser('target', 'viewer');
const input = { username: user.username.toUpperCase(), password };
for (let attempt = 0; attempt < 10; attempt++)
auth.consumeAuthAttempt('192.0.2.70', input.username);
// A pre-upgrade account-wide bucket must not continue a global lockout.
database
.db()
.prepare('INSERT INTO auth_rate_limits(key,window_start,attempts) VALUES(?,?,?)')
.run(
createHash('sha256').update(`login:account:${user.username}`).digest('hex'),
Date.now(),
10
);
await expect(auth.authenticatePassword(input, '192.0.2.70')).rejects.toMatchObject({
status: 429
});
await expect(auth.authenticatePassword(input, '192.0.2.71')).resolves.toMatchObject({
user: { id: user.id }
});
});
it.each(['admin', 'super_viewer'] as const)(
'does not consume login or password-change limits for an enabled %s',
(role) => {
@@ -959,32 +981,39 @@ describe('persistent authentication throttling', () => {
const account = kind === 'login' ? user.username : user.id;
for (let attempt = 0; attempt < 10; attempt++)
auth.consumeAuthAttempt('192.0.2.52', account, kind);
expect(() => auth.consumeAuthAttempt('192.0.2.53', account, kind)).toThrow(
expect(() => auth.consumeAuthAttempt('192.0.2.52', account, kind)).toThrow(
'Too many attempts'
);
if (kind === 'login')
expect(() => auth.consumeAuthAttempt('192.0.2.53', account, kind)).not.toThrow();
else
expect(() => auth.consumeAuthAttempt('192.0.2.53', account, kind)).toThrow(
'Too many attempts'
);
}
await expect(
auth.authenticatePassword(
{ username: user.username, password, role: 'admin' },
'192.0.2.53'
'192.0.2.52'
)
).rejects.toMatchObject({ status: 429 });
});
it('retains case-insensitive per-account limits across controller database reopen and resets after the window', () => {
it('retains case-insensitive account/address limits across database reopen and resets after the window', () => {
let now = 2_000_000_000_000;
vi.spyOn(Date, 'now').mockImplementation(() => now);
for (let attempt = 0; attempt < 10; attempt++)
auth.consumeAuthAttempt('192.0.2.30', attempt % 2 ? 'ADMIN' : 'admin');
database.closeDb();
expect(() => auth.consumeAuthAttempt('192.0.2.31', 'Admin')).toThrow('Too many attempts');
expect(() => auth.consumeAuthAttempt('192.0.2.30', 'Admin')).toThrow('Too many attempts');
expect(() => auth.consumeAuthAttempt('192.0.2.31', 'Admin')).not.toThrow();
const persisted = JSON.stringify(
database.db().prepare('SELECT * FROM auth_rate_limits').all()
);
expect(persisted).not.toContain('192.0.2.30');
expect(persisted).not.toContain('admin');
now += 15 * 60_000;
expect(() => auth.consumeAuthAttempt('192.0.2.31', 'Admin')).not.toThrow();
expect(() => auth.consumeAuthAttempt('192.0.2.30', 'Admin')).not.toThrow();
});
it('limits attempts from one address across many accounts and separates setup from login windows', () => {