This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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<typeof POST>[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);
|
||||
|
||||
Reference in New Issue
Block a user