This commit is contained in:
@@ -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