From 95160de5b4457018d1a2e66317a916787076cf1f Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Mon, 12 Oct 2026 22:01:03 -0400 Subject: [PATCH] Exempt admins and super viewers from auth rate limits --- docs/guides/authentication.md | 21 +++++--- src/lib/server/auth/index.ts | 19 ++++++- tests/unit/auth-users.test.ts | 97 +++++++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 10 deletions(-) diff --git a/docs/guides/authentication.md b/docs/guides/authentication.md index ad2517c..7bf9475 100644 --- a/docs/guides/authentication.md +++ b/docs/guides/authentication.md @@ -83,15 +83,20 @@ Changing a password, changing a role, disabling an account, or revoking that user's sessions invalidates their existing sessions. Live event streams check revocation every 15 seconds and then close. -Login, initial setup and password verification are rate-limited with persistent -15-minute windows. The database stores audit entries for login results, account -changes and successful management requests without recording passwords, tokens -or request bodies. Administrators and Super Viewers can review recent entries in Users. +Login and password changes for enabled Administrators and Super Viewers are exempt +from per-account and per-address rate limits, including existing exhausted windows. +Their attempts do not consume either limit. Other accounts and initial setup use +persistent 15-minute windows. Exemptions use the account's current database role; +password verification is still required. The database stores audit entries for login +results, account changes and successful management requests without recording +passwords, tokens or request bodies. Administrators and Super Viewers can review +recent entries in Users. -The password service runs at most two scrypt operations at once and queues up to -16 more for up to two seconds. If that queue fills or the wait expires, the -request returns HTTP 429 with `Retry-After: 2`. The persistent rate limits still -return `Retry-After: 900` when an account or address reaches its attempt limit. +For every role, the password service runs at most two scrypt operations at once +and queues up to 16 more for up to two seconds. If that queue fills or the wait +expires, the request returns HTTP 429 with `Retry-After: 2`. The persistent rate +limits return `Retry-After: 900` when a non-exempt account or address reaches its +attempt limit. An administrator can reset another user's password from Users. If the only administrator forgets their password, an operator with local controller/container diff --git a/src/lib/server/auth/index.ts b/src/lib/server/auth/index.ts index 49cc42e..bdc4348 100644 --- a/src/lib/server/auth/index.ts +++ b/src/lib/server/auth/index.ts @@ -106,8 +106,23 @@ export function listAuthAudit() { .all(); } -/** Persistent fixed windows are consumed before hashing, including failed setup. */ -export function consumeAuthAttempt(clientAddress: string, account: string, kind = 'login'): void { +/** Persistent fixed windows are consumed before hashing, except for enabled admin roles. */ +export function consumeAuthAttempt( + clientAddress: string, + account: string, + kind: 'login' | 'setup' | 'password' = 'login' +): void { + // Resolve the current role from storage; setup never has an existing account exemption. + if ( + kind !== 'setup' && + database() + .prepare( + `SELECT 1 FROM auth_users WHERE ${kind === 'login' ? 'username' : 'id'}=? + AND disabled=0 AND role IN ('admin','super_viewer')` + ) + .get(kind === 'login' ? account.toLowerCase() : account) + ) + return; const now = Date.now(); const windowMs = 15 * 60_000; const ipKey = digest(`${kind}:ip:${clientAddress}`); diff --git a/tests/unit/auth-users.test.ts b/tests/unit/auth-users.test.ts index 52a63f6..99267a8 100644 --- a/tests/unit/auth-users.test.ts +++ b/tests/unit/auth-users.test.ts @@ -578,6 +578,103 @@ describe('forced password changes and asynchronous authorization races', () => { }); describe('persistent authentication throttling', () => { + it.each(['admin', 'super_viewer'] as const)( + 'does not consume login or password-change limits for an enabled %s', + (role) => { + const user = seedUser('unlimited', role); + for (let attempt = 0; attempt < 31; attempt++) { + auth.consumeAuthAttempt('192.0.2.50', user.username.toUpperCase()); + auth.consumeAuthAttempt('192.0.2.50', user.id, 'password'); + } + expect(database.db().prepare('SELECT * FROM auth_rate_limits').all()).toEqual([]); + + // Initial setup cannot inherit an existing account's exemption. + for (let attempt = 0; attempt < 10; attempt++) + auth.consumeAuthAttempt('192.0.2.50', user.username, 'setup'); + expect(() => auth.consumeAuthAttempt('192.0.2.50', user.username, 'setup')).toThrow( + 'Too many attempts' + ); + } + ); + + it.each(['admin', 'super_viewer'] as const)( + 'allows %s login and password changes through exhausted account and address limits', + async (role) => { + const actor = seedUser(); + const user = seedUser('promoted', 'viewer', { mustChangePassword: true }); + const address = '192.0.2.51'; + for (const kind of ['login', 'password'] as const) { + const account = kind === 'login' ? user.username : user.id; + for (let attempt = 0; attempt < 10; attempt++) + auth.consumeAuthAttempt(address, account, kind); + for (let attempt = 0; attempt < 20; attempt++) + auth.consumeAuthAttempt(address, `missing-${attempt}`, kind); + } + const limits = database + .db() + .prepare('SELECT * FROM auth_rate_limits ORDER BY key') + .all(); + await auth.updateUser(actor.id, user.id, { role }); + + await expect( + auth.authenticatePassword( + { username: user.username, password: replacement }, + address + ) + ).rejects.toMatchObject({ status: 401 }); + const login = await auth.authenticatePassword( + { username: user.username.toUpperCase(), password }, + address + ); + expect(login.user).toMatchObject({ id: user.id, role, mustChangePassword: true }); + expect(auth.readSession(login.session.token)?.user.id).toBe(user.id); + + const { POST } = await import('../../src/routes/api/auth/password/+server.js'); + const url = new URL('https://ec.test/api/auth/password'); + const response = await POST({ + request: new Request(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ currentPassword: password, newPassword: replacement }) + }), + cookies: { set: vi.fn() }, + locals: { user: login.user }, + url, + getClientAddress: () => address + } as unknown as Parameters[0]); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + user: { id: user.id, role, mustChangePassword: false } + }); + expect( + database.db().prepare('SELECT * FROM auth_rate_limits ORDER BY key').all() + ).toEqual(limits); + } + ); + + it.each([ + ['viewer', false], + ['operator', false], + ['admin', true], + ['super_viewer', true] + ] as const)('retains limits for %s with disabled=%s', async (role, disabled) => { + const user = seedUser('limited', role, { disabled }); + for (const kind of ['login', 'password'] as const) { + 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( + 'Too many attempts' + ); + } + await expect( + auth.authenticatePassword( + { username: user.username, password, role: 'admin' }, + '192.0.2.53' + ) + ).rejects.toMatchObject({ status: 429 }); + }); + it('retains case-insensitive per-account limits across controller database reopen and resets after the window', () => { let now = 2_000_000_000_000; vi.spyOn(Date, 'now').mockImplementation(() => now);