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
|
user's sessions invalidates their existing sessions. Live event streams check
|
||||||
revocation every 15 seconds and then close.
|
revocation every 15 seconds and then close.
|
||||||
|
|
||||||
Login, initial setup and password verification are rate-limited with persistent
|
Login and password changes for enabled Administrators and Super Viewers are exempt
|
||||||
15-minute windows. The database stores audit entries for login results, account
|
from per-account and per-address rate limits, including existing exhausted windows.
|
||||||
changes and successful management requests without recording passwords, tokens
|
Their attempts do not consume either limit. Other accounts and initial setup use
|
||||||
or request bodies. Administrators and Super Viewers can review recent entries in Users.
|
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
|
For every role, the password service runs at most two scrypt operations at once
|
||||||
16 more for up to two seconds. If that queue fills or the wait expires, the
|
and queues up to 16 more for up to two seconds. If that queue fills or the wait
|
||||||
request returns HTTP 429 with `Retry-After: 2`. The persistent rate limits still
|
expires, the request returns HTTP 429 with `Retry-After: 2`. The persistent rate
|
||||||
return `Retry-After: 900` when an account or address reaches its attempt limit.
|
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
|
An administrator can reset another user's password from Users. If the only
|
||||||
administrator forgets their password, an operator with local controller/container
|
administrator forgets their password, an operator with local controller/container
|
||||||
|
|||||||
@@ -106,8 +106,23 @@ export function listAuthAudit() {
|
|||||||
.all();
|
.all();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persistent fixed windows are consumed before hashing, including failed setup. */
|
/** Persistent fixed windows are consumed before hashing, except for enabled admin roles. */
|
||||||
export function consumeAuthAttempt(clientAddress: string, account: string, kind = 'login'): void {
|
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 now = Date.now();
|
||||||
const windowMs = 15 * 60_000;
|
const windowMs = 15 * 60_000;
|
||||||
const ipKey = digest(`${kind}:ip:${clientAddress}`);
|
const ipKey = digest(`${kind}:ip:${clientAddress}`);
|
||||||
|
|||||||
@@ -578,6 +578,103 @@ describe('forced password changes and asynchronous authorization races', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('persistent authentication throttling', () => {
|
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', () => {
|
it('retains case-insensitive per-account limits across controller database reopen and resets after the window', () => {
|
||||||
let now = 2_000_000_000_000;
|
let now = 2_000_000_000_000;
|
||||||
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
||||||
|
|||||||
Reference in New Issue
Block a user