This commit is contained in:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user