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
+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', () => {