984 lines
45 KiB
TypeScript
984 lines
45 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
// These tests intentionally use production-strength scrypt while the complete
|
|
// suite may also be saturating the CPU. Do not weaken password parameters.
|
|
vi.setConfig({ testTimeout: 20_000, hookTimeout: 20_000 });
|
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
|
import type { UserRole } from '../../src/lib/auth.js';
|
|
|
|
const password = 'testpass';
|
|
const replacement = 'another long secret passphrase';
|
|
const setupToken = 'ec_setup_test_0123456789abcdef0123456789abcdef';
|
|
let passwordHash: string;
|
|
let replacementHash: string;
|
|
let fixture: Fixture;
|
|
let auth: typeof import('../../src/lib/server/auth/index.js');
|
|
let database: typeof import('../../src/lib/server/db/index.js');
|
|
|
|
// Real scrypt hashes are shared only as fixture data. Security transitions use
|
|
// the production API; deterministic gates below exercise its async race checks.
|
|
beforeAll(async () => {
|
|
const passwords = await import('../../src/lib/server/auth/passwords.js');
|
|
passwordHash = await passwords.hashPassword(password);
|
|
replacementHash = await passwords.hashPassword(replacement);
|
|
}, 15_000);
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
vi.stubEnv('EC_SETUP_TOKEN', setupToken);
|
|
fixture = await newFixture([]);
|
|
auth = await import('../../src/lib/server/auth/index.js');
|
|
database = await import('../../src/lib/server/db/index.js');
|
|
auth.setupRequired();
|
|
});
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
vi.useRealTimers();
|
|
vi.unstubAllEnvs();
|
|
await fixture.cleanup();
|
|
});
|
|
|
|
function seedUser(
|
|
username = 'administrator',
|
|
role: UserRole = 'admin',
|
|
options: { disabled?: boolean; mustChangePassword?: boolean } = {}
|
|
) {
|
|
const id = randomUUID(),
|
|
now = Date.now();
|
|
database
|
|
.db()
|
|
.prepare(
|
|
`INSERT INTO auth_users(id,username,display_name,password_hash,role,disabled,must_change_password,created_at,updated_at)
|
|
VALUES(?,?,?,?,?,?,?,?,?)`
|
|
)
|
|
.run(
|
|
id,
|
|
username,
|
|
username,
|
|
passwordHash,
|
|
role,
|
|
Number(options.disabled ?? false),
|
|
Number(options.mustChangePassword ?? false),
|
|
now,
|
|
now
|
|
);
|
|
return auth.getUser(id)!;
|
|
}
|
|
function storedUser(id: string) {
|
|
return database.db().prepare('SELECT * FROM auth_users WHERE id=?').get(id) as {
|
|
password_hash: string;
|
|
role: UserRole;
|
|
disabled: number;
|
|
must_change_password: number;
|
|
};
|
|
}
|
|
function gate<T>() {
|
|
let release!: (value: T) => void;
|
|
const promise = new Promise<T>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
return { promise, release };
|
|
}
|
|
|
|
describe('initial administrator setup', () => {
|
|
it('reserves Maxis for system actions regardless of username case', async () => {
|
|
for (const username of ['Maxis', 'maxis', 'MAXIS', 'mAxIs']) {
|
|
await expect(
|
|
auth.bootstrapAdmin({ username, password, token: setupToken }, '192.0.2.1')
|
|
).rejects.toMatchObject({
|
|
status: 400,
|
|
message: 'Maxis is reserved for system actions. Choose another username.'
|
|
});
|
|
}
|
|
expect(auth.setupRequired()).toBe(true);
|
|
expect(auth.listUsers()).toEqual([]);
|
|
expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({
|
|
count: 0
|
|
});
|
|
});
|
|
|
|
it('requires an owner token and never creates an account from missing or wrong credentials', async () => {
|
|
const input = { username: 'Admin', password, token: setupToken };
|
|
vi.stubEnv('EC_SETUP_TOKEN', undefined);
|
|
await expect(auth.bootstrapAdmin(input, '127.0.0.1')).rejects.toMatchObject({
|
|
status: 503
|
|
});
|
|
vi.stubEnv('EC_SETUP_TOKEN', 'short');
|
|
await expect(auth.bootstrapAdmin(input, '127.0.0.1')).rejects.toMatchObject({
|
|
status: 503
|
|
});
|
|
vi.stubEnv('EC_SETUP_TOKEN', setupToken);
|
|
await expect(
|
|
auth.bootstrapAdmin({ ...input, token: 'wrong' }, '127.0.0.1')
|
|
).rejects.toMatchObject({ status: 401 });
|
|
expect(auth.setupRequired()).toBe(true);
|
|
expect(auth.listUsers()).toEqual([]);
|
|
});
|
|
|
|
it('allows exactly one competing bootstrap and atomically establishes its authenticated session', async () => {
|
|
const results = await Promise.allSettled([
|
|
auth.bootstrapAdmin(
|
|
{
|
|
username: 'FirstAdmin',
|
|
displayName: ' First Admin ',
|
|
password,
|
|
token: setupToken,
|
|
role: 'viewer'
|
|
},
|
|
'192.0.2.1'
|
|
),
|
|
auth.bootstrapAdmin(
|
|
{ username: 'SecondAdmin', password, token: setupToken },
|
|
'192.0.2.2'
|
|
)
|
|
]);
|
|
const successes = results.filter((result) => result.status === 'fulfilled');
|
|
const failures = results.filter((result) => result.status === 'rejected');
|
|
expect(successes).toHaveLength(1);
|
|
expect(failures).toHaveLength(1);
|
|
if (failures[0].status === 'rejected')
|
|
expect(failures[0].reason).toMatchObject({ status: 409 });
|
|
if (successes[0].status !== 'fulfilled') throw new Error('Bootstrap did not succeed');
|
|
const { user, session } = successes[0].value;
|
|
expect(user).toMatchObject({ role: 'admin', disabled: false, mustChangePassword: false });
|
|
expect(user.username).toBe(user.username.toLowerCase());
|
|
expect(auth.readSession(session.token)?.user.id).toBe(user.id);
|
|
expect(auth.listUsers()).toHaveLength(1);
|
|
expect(auth.setupRequired()).toBe(false);
|
|
await expect(
|
|
auth.bootstrapAdmin(
|
|
{ username: 'ThirdAdmin', password, token: setupToken },
|
|
'192.0.2.3'
|
|
)
|
|
).rejects.toMatchObject({ status: 409 });
|
|
const persisted =
|
|
JSON.stringify(database.db().prepare('SELECT * FROM auth_users').all()) +
|
|
JSON.stringify(auth.listAuthAudit());
|
|
expect(persisted).not.toContain(password);
|
|
expect(persisted).not.toContain(setupToken);
|
|
expect(JSON.stringify(user)).not.toContain('password_hash');
|
|
});
|
|
});
|
|
|
|
describe('password storage and verification', () => {
|
|
it('uses independently salted scrypt hashes and rejects wrong passwords or malformed stored hashes', async () => {
|
|
const passwords = await import('../../src/lib/server/auth/passwords.js');
|
|
const anotherHash = await passwords.hashPassword(password);
|
|
expect(anotherHash).toMatch(/^scrypt\$131072\$8\$1\$[a-f0-9]{64}\$[a-f0-9]{128}$/);
|
|
expect(anotherHash).not.toBe(passwordHash);
|
|
expect(anotherHash).not.toContain(password);
|
|
expect(await passwords.verifyPassword(password, anotherHash)).toBe(true);
|
|
expect(await passwords.verifyPassword(replacement, anotherHash)).toBe(false);
|
|
expect(await passwords.verifyPassword(password, 'scrypt$1$8$1$weak$bad')).toBe(false);
|
|
expect(await passwords.verifyPassword(undefined, anotherHash)).toBe(false);
|
|
});
|
|
|
|
it('bounds password length by Unicode characters and rejects invalid inputs before hashing', async () => {
|
|
const { validatePassword, hashPassword, verifyPassword } =
|
|
await import('../../src/lib/server/auth/passwords.js');
|
|
for (const value of [
|
|
undefined,
|
|
null,
|
|
123,
|
|
'',
|
|
'a'.repeat(7),
|
|
'🦊'.repeat(7),
|
|
'a'.repeat(129)
|
|
]) {
|
|
await expect(hashPassword(value)).rejects.toThrow('8 and 128');
|
|
}
|
|
expect(validatePassword('a'.repeat(8))).toBe('a'.repeat(8));
|
|
expect(validatePassword('🦊'.repeat(8))).toBe('🦊'.repeat(8));
|
|
expect(validatePassword('a'.repeat(128))).toHaveLength(128);
|
|
expect(await verifyPassword('a'.repeat(129), passwordHash)).toBe(false);
|
|
});
|
|
|
|
it('allows three simultaneous logins to complete with production-strength scrypt', async () => {
|
|
const users = [seedUser('first'), seedUser('second'), seedUser('third')];
|
|
const results = await Promise.all(
|
|
users.map((user, index) =>
|
|
auth.authenticatePassword(
|
|
{ username: user.username, password },
|
|
`192.0.2.${index + 1}`
|
|
)
|
|
)
|
|
);
|
|
expect(results.map((result) => result.user.id)).toEqual(users.map((user) => user.id));
|
|
for (const result of results)
|
|
expect(auth.readSession(result.session.token)?.user.id).toBe(result.user.id);
|
|
});
|
|
|
|
it('uses a short retry for password capacity while preserving the rate-limit retry', async () => {
|
|
const { PasswordServiceBusyError } = await import('../../src/lib/server/auth/passwords.js');
|
|
const { authError } = await import('../../src/lib/server/auth/http.js');
|
|
const busy = authError(new PasswordServiceBusyError());
|
|
expect(busy.status).toBe(429);
|
|
expect(busy.headers.get('Retry-After')).toBe('2');
|
|
expect(busy.headers.get('Cache-Control')).toBe('no-store');
|
|
expect(await busy.json()).toEqual({
|
|
error: 'Password service is busy. Try again shortly.'
|
|
});
|
|
|
|
const limited = authError(
|
|
new auth.AuthError('Too many attempts. Try again in 15 minutes.', 429)
|
|
);
|
|
expect(limited.status).toBe(429);
|
|
expect(limited.headers.get('Retry-After')).toBe('900');
|
|
|
|
const unexpected = authError(new Error('Database is busy with private details'));
|
|
expect(unexpected.status).toBe(500);
|
|
expect(unexpected.headers.has('Retry-After')).toBe(false);
|
|
expect(await unexpected.json()).toEqual({
|
|
error: 'The authentication request could not be completed.'
|
|
});
|
|
});
|
|
|
|
it('returns the same login error for unknown, disabled and incorrect-password accounts', async () => {
|
|
seedUser('enabled');
|
|
seedUser('disabled', 'viewer', { disabled: true });
|
|
for (const [username, supplied] of [
|
|
['missing', password],
|
|
['enabled', replacement],
|
|
['disabled', password]
|
|
]) {
|
|
await expect(
|
|
auth.authenticatePassword({ username, password: supplied }, '192.0.2.5')
|
|
).rejects.toMatchObject({ status: 401, message: 'Invalid username or password.' });
|
|
}
|
|
expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({
|
|
count: 0
|
|
});
|
|
expect(JSON.stringify(auth.listAuthAudit())).not.toContain(password);
|
|
expect(auth.listAuthAudit()).toHaveLength(3);
|
|
});
|
|
});
|
|
|
|
describe('administrator user management', () => {
|
|
it('rejects the reserved system username for every role while allowing distinct usernames', async () => {
|
|
const admin = seedUser();
|
|
for (const role of ['admin', 'super_viewer', 'operator', 'viewer'] as const) {
|
|
for (const username of ['Maxis', 'maxis', 'MAXIS', 'mAxIs']) {
|
|
await expect(
|
|
auth.createUser(admin.id, { username, role, password })
|
|
).rejects.toMatchObject({
|
|
status: 400,
|
|
message: 'Maxis is reserved for system actions. Choose another username.'
|
|
});
|
|
}
|
|
}
|
|
expect(auth.listUsers()).toHaveLength(1);
|
|
expect(auth.listAuthAudit()).toEqual([]);
|
|
await expect(
|
|
auth.createUser(admin.id, { username: 'maxis-operator', role: 'operator', password })
|
|
).resolves.toMatchObject({ username: 'maxis-operator' });
|
|
});
|
|
|
|
it('creates each supported role with a forced initial password change and rejects case-insensitive duplicate names', async () => {
|
|
const admin = seedUser();
|
|
for (const role of ['admin', 'super_viewer', 'operator', 'viewer'] as const) {
|
|
const user = await auth.createUser(admin.id, {
|
|
username: role + 'User',
|
|
displayName: ' ' + role + ' user ',
|
|
role,
|
|
password
|
|
});
|
|
expect(user).toMatchObject({
|
|
username: role + 'user',
|
|
displayName: role + ' user',
|
|
role,
|
|
mustChangePassword: true,
|
|
disabled: false
|
|
});
|
|
expect(storedUser(user.id).password_hash).toMatch(/^scrypt\$/);
|
|
expect(JSON.stringify(user)).not.toContain('password_hash');
|
|
}
|
|
await expect(
|
|
auth.createUser(admin.id, { username: 'OPERATORUSER', role: 'viewer', password })
|
|
).rejects.toMatchObject({ status: 409 });
|
|
expect(auth.listUsers()).toHaveLength(5);
|
|
expect(JSON.stringify(auth.listAuthAudit())).not.toContain(password);
|
|
});
|
|
|
|
it('rejects administrator mutations by read-only roles, operators, disabled administrators and forced-change administrators', async () => {
|
|
const target = seedUser('target', 'viewer');
|
|
const actors = [
|
|
seedUser('superviewer', 'super_viewer'),
|
|
seedUser('operator', 'operator'),
|
|
seedUser('viewer', 'viewer'),
|
|
seedUser('disabledadmin', 'admin', { disabled: true }),
|
|
seedUser('temporaryadmin', 'admin', { mustChangePassword: true })
|
|
];
|
|
const session = auth.createSession(target.id);
|
|
for (const actor of actors) {
|
|
await expect(
|
|
auth.createUser(actor.id, { username: 'intruder', role: 'admin', password })
|
|
).rejects.toMatchObject({ status: 403 });
|
|
await expect(
|
|
auth.updateUser(actor.id, target.id, { role: 'admin' })
|
|
).rejects.toMatchObject({ status: 403 });
|
|
expect(() => auth.revokeUserSessions(actor.id, target.id)).toThrow('Administrator');
|
|
}
|
|
expect(auth.getUser(target.id)?.role).toBe('viewer');
|
|
expect(auth.readSession(session.token)).not.toBeNull();
|
|
expect(auth.listUsers().some((user) => user.username === 'intruder')).toBe(false);
|
|
});
|
|
|
|
it('preserves at least one enabled administrator and permits demotion only when another remains', async () => {
|
|
const first = seedUser();
|
|
seedUser('superviewer', 'super_viewer');
|
|
const session = auth.createSession(first.id);
|
|
await expect(
|
|
auth.updateUser(first.id, first.id, { role: 'super_viewer' })
|
|
).rejects.toMatchObject({ status: 409 });
|
|
await expect(
|
|
auth.updateUser(first.id, first.id, { role: 'operator' })
|
|
).rejects.toMatchObject({ status: 409 });
|
|
await expect(auth.updateUser(first.id, first.id, { disabled: true })).rejects.toMatchObject(
|
|
{ status: 409 }
|
|
);
|
|
expect(auth.readSession(session.token)?.user.role).toBe('admin');
|
|
const second = seedUser('secondadmin');
|
|
await auth.updateUser(first.id, first.id, { role: 'super_viewer' });
|
|
expect(auth.getUser(first.id)?.role).toBe('super_viewer');
|
|
expect(auth.readSession(session.token)).toBeNull();
|
|
await expect(
|
|
auth.updateUser(second.id, second.id, { disabled: true })
|
|
).rejects.toMatchObject({ status: 409 });
|
|
expect(auth.getUser(second.id)?.role).toBe('admin');
|
|
});
|
|
|
|
it.each([false, true])(
|
|
'revokes every target session on role, enabled-state and password changes (remembered=%s)',
|
|
async (remembered) => {
|
|
const admin = seedUser(),
|
|
target = seedUser('target', 'viewer');
|
|
const administratorSession = auth.createSession(admin.id);
|
|
const first = auth.createSession(target.id, remembered),
|
|
second = auth.createSession(target.id);
|
|
await auth.updateUser(admin.id, target.id, { role: 'super_viewer' });
|
|
expect(auth.getUser(target.id)?.role).toBe('super_viewer');
|
|
expect(auth.readSession(first.token)).toBeNull();
|
|
expect(auth.readSession(second.token)).toBeNull();
|
|
const third = auth.createSession(target.id, remembered);
|
|
await auth.updateUser(admin.id, target.id, { disabled: true });
|
|
expect(auth.readSession(third.token)).toBeNull();
|
|
expect(() => auth.createSession(target.id)).toThrow('unavailable');
|
|
await auth.updateUser(admin.id, target.id, { disabled: false });
|
|
expect(auth.readSession(third.token)).toBeNull();
|
|
const fourth = auth.createSession(target.id, remembered);
|
|
const reset = await auth.updateUser(admin.id, target.id, { password: replacement });
|
|
expect(reset.mustChangePassword).toBe(true);
|
|
expect(auth.readSession(fourth.token)).toBeNull();
|
|
expect(auth.readSession(administratorSession.token)?.user.id).toBe(admin.id);
|
|
expect(storedUser(target.id).password_hash).not.toBe(passwordHash);
|
|
await expect(
|
|
auth.updateUser(admin.id, admin.id, { password: replacement })
|
|
).rejects.toThrow('current password');
|
|
}
|
|
);
|
|
|
|
it.each([false, true])(
|
|
'keeps sessions for display-only edits and supports per-user and individual revocation (remembered=%s)',
|
|
async (remembered) => {
|
|
const admin = seedUser(),
|
|
target = seedUser('target', 'viewer');
|
|
const one = auth.createSession(target.id, remembered),
|
|
two = auth.createSession(target.id, remembered),
|
|
adminSession = auth.createSession(admin.id);
|
|
await auth.updateUser(admin.id, target.id, { displayName: 'New display name' });
|
|
expect(auth.readSession(one.token)?.user.displayName).toBe('New display name');
|
|
auth.revokeSession(one.token);
|
|
expect(auth.readSession(one.token)).toBeNull();
|
|
expect(auth.readSession(two.token)).not.toBeNull();
|
|
auth.revokeUserSessions(admin.id, target.id);
|
|
expect(auth.readSession(two.token)).toBeNull();
|
|
expect(auth.readSession(adminSession.token)).not.toBeNull();
|
|
}
|
|
);
|
|
});
|
|
|
|
describe('session secrecy and expiration', () => {
|
|
it('signs out a remembered device by deleting its cookie and server session', async () => {
|
|
const user = seedUser();
|
|
const session = auth.createSession(user.id, true);
|
|
const otherDevice = auth.createSession(user.id, true);
|
|
const { POST } = await import('../../src/routes/api/auth/logout/+server.js');
|
|
const url = new URL('https://ec.test/api/auth/logout');
|
|
const cookies = { get: vi.fn(() => session.token), delete: vi.fn() };
|
|
const response = await POST({ cookies, url, locals: { user } } as unknown as Parameters<
|
|
typeof POST
|
|
>[0]);
|
|
expect(response.status).toBe(200);
|
|
expect(cookies.delete).toHaveBeenCalledWith('__Host-ec_session', { path: '/' });
|
|
expect(auth.readSession(session.token)).toBeNull();
|
|
expect(auth.readSession(otherDevice.token)).not.toBeNull();
|
|
});
|
|
|
|
it.each([undefined, false, true])(
|
|
'requires explicit opt-in at login and sets the cookie lifetime (rememberMe=%s)',
|
|
async (rememberMe) => {
|
|
const now = 2_000_000_000_000;
|
|
vi.spyOn(Date, 'now').mockReturnValue(now);
|
|
const user = seedUser();
|
|
const previous = auth.createSession(user.id, true);
|
|
const { POST } = await import('../../src/routes/api/auth/login/+server.js');
|
|
const url = new URL('https://ec.test/api/auth/login');
|
|
const cookies = { get: vi.fn(() => previous.token), set: vi.fn() };
|
|
const response = await POST({
|
|
request: new Request(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username: user.username, password, rememberMe })
|
|
}),
|
|
cookies,
|
|
url,
|
|
getClientAddress: () => '192.0.2.60'
|
|
} as unknown as Parameters<typeof POST>[0]);
|
|
expect(response.status).toBe(200);
|
|
const [name, token, options] = cookies.set.mock.calls[0];
|
|
const lifetime = rememberMe ? 30 * 24 * 60 * 60_000 : 12 * 60 * 60_000;
|
|
expect(name).toBe('__Host-ec_session');
|
|
expect(options).toEqual({
|
|
path: '/',
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: 'lax',
|
|
expires: new Date(now + lifetime)
|
|
});
|
|
expect(auth.readSession(token)).toMatchObject({
|
|
remembered: rememberMe === true,
|
|
expiresAt: now + lifetime
|
|
});
|
|
expect(auth.readSession(previous.token)).toBeNull();
|
|
}
|
|
);
|
|
|
|
it.each(['true', 'false', 1, null, {}])(
|
|
'rejects non-boolean remember-device choices: %j',
|
|
async (rememberMe) => {
|
|
const user = seedUser();
|
|
await expect(
|
|
auth.authenticatePassword(
|
|
{ username: user.username, password, rememberMe },
|
|
'192.0.2.60'
|
|
)
|
|
).rejects.toMatchObject({ status: 400 });
|
|
expect(database.db().prepare('SELECT * FROM auth_sessions').all()).toEqual([]);
|
|
}
|
|
);
|
|
|
|
it('keeps remembered sessions across inactivity, other logins and restarts, but never beyond 30 days', () => {
|
|
let now = 2_000_000_000_000;
|
|
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
const user = seedUser();
|
|
const remembered = auth.createSession(user.id, true);
|
|
const short = auth.createSession(user.id);
|
|
now += 2 * 24 * 60 * 60_000;
|
|
auth.createSession(user.id); // Expiry cleanup must retain inactive remembered devices.
|
|
database.closeDb();
|
|
expect(auth.readSession(short.token)).toBeNull();
|
|
expect(auth.readSession(remembered.token)).toMatchObject({
|
|
user: { id: user.id },
|
|
remembered: true
|
|
});
|
|
now = remembered.expiresAt - 1;
|
|
expect(auth.readSession(remembered.token)?.expiresAt).toBe(remembered.expiresAt);
|
|
now++;
|
|
expect(auth.readSession(remembered.token)).toBeNull();
|
|
auth.createSession(user.id);
|
|
expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({
|
|
count: 1
|
|
});
|
|
});
|
|
|
|
it('stores only opaque session hashes, never raw cookies or password hashes in public identities', () => {
|
|
const user = seedUser();
|
|
const first = auth.createSession(user.id),
|
|
second = auth.createSession(user.id);
|
|
expect(first.token).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
expect(first.token).not.toBe(second.token);
|
|
const sessions = JSON.stringify(database.db().prepare('SELECT * FROM auth_sessions').all());
|
|
expect(sessions).not.toContain(first.token);
|
|
expect(sessions).not.toContain(second.token);
|
|
expect(auth.readSession(first.token)?.user).not.toHaveProperty('passwordHash');
|
|
for (const token of [undefined, '', 'x', '!', 'x'.repeat(44)])
|
|
expect(auth.readSession(token)).toBeNull();
|
|
expect(auth.readSession('x'.repeat(43))).toBeNull();
|
|
});
|
|
|
|
it('throttles activity touches and expires sessions at the idle boundary', () => {
|
|
let now = 2_000_000_000_000;
|
|
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
const user = seedUser(),
|
|
session = auth.createSession(user.id);
|
|
now += 59_000;
|
|
expect(auth.readSession(session.token)).not.toBeNull();
|
|
expect(database.db().prepare('SELECT last_seen_at FROM auth_sessions').get()).toEqual({
|
|
last_seen_at: now - 59_000
|
|
});
|
|
now += 2_000;
|
|
expect(auth.readSession(session.token)).not.toBeNull();
|
|
expect(database.db().prepare('SELECT last_seen_at FROM auth_sessions').get()).toEqual({
|
|
last_seen_at: now
|
|
});
|
|
const lastSeen = now;
|
|
now = lastSeen + auth.SESSION_IDLE_MS - 1;
|
|
expect(auth.readSession(session.token, false)).not.toBeNull();
|
|
now++;
|
|
expect(auth.readSession(session.token)).toBeNull();
|
|
auth.createSession(user.id);
|
|
expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({
|
|
count: 1
|
|
});
|
|
});
|
|
|
|
it('enforces absolute expiry even while the user remains recently active', () => {
|
|
let now = 2_000_000_000_000;
|
|
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
const session = auth.createSession(seedUser().id);
|
|
now = session.expiresAt - 1;
|
|
database
|
|
.db()
|
|
.prepare('UPDATE auth_sessions SET last_seen_at=?')
|
|
.run(now - 1000);
|
|
expect(auth.readSession(session.token)).not.toBeNull();
|
|
now++;
|
|
expect(auth.readSession(session.token)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('forced password changes and asynchronous authorization races', () => {
|
|
it.each([false, true])(
|
|
'preserves this device choice through a required password change and revokes all old sessions (remembered=%s)',
|
|
async (remembered) => {
|
|
let now = 2_000_000_000_000;
|
|
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
const user = seedUser('newuser', 'viewer', { mustChangePassword: true });
|
|
const login = await auth.authenticatePassword(
|
|
{ username: user.username, password, rememberMe: remembered },
|
|
'192.0.2.61'
|
|
);
|
|
const other = auth.createSession(user.id, true);
|
|
const tokenHash = auth.readSession(login.session.token)!.tokenHash;
|
|
const { POST } = await import('../../src/routes/api/auth/password/+server.js');
|
|
const url = new URL('https://ec.test/api/auth/password');
|
|
const cookies = { set: vi.fn() };
|
|
const response = await POST({
|
|
request: new Request(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
// The server must preserve the stored policy, ignoring a forged client choice.
|
|
body: JSON.stringify({
|
|
currentPassword: password,
|
|
newPassword: replacement,
|
|
rememberMe: !remembered
|
|
})
|
|
}),
|
|
cookies,
|
|
url,
|
|
locals: { user, sessionHash: tokenHash },
|
|
getClientAddress: () => '192.0.2.61'
|
|
} as unknown as Parameters<typeof POST>[0]);
|
|
expect(response.status).toBe(200);
|
|
const [, token, options] = cookies.set.mock.calls[0];
|
|
const lifetime = remembered ? 30 * 24 * 60 * 60_000 : 12 * 60 * 60_000;
|
|
expect(options.expires).toEqual(new Date(now + lifetime));
|
|
expect(auth.readSession(login.session.token)).toBeNull();
|
|
expect(auth.readSession(other.token)).toBeNull();
|
|
expect(auth.readSession(token)).toMatchObject({
|
|
remembered,
|
|
user: { mustChangePassword: false }
|
|
});
|
|
now += 24 * 60 * 60_000;
|
|
expect(!!auth.readSession(token)).toBe(remembered);
|
|
}
|
|
);
|
|
|
|
it('requires the current password, replaces old sessions atomically and clears the forced-change state', async () => {
|
|
const user = seedUser('newuser', 'viewer', { mustChangePassword: true });
|
|
const login = await auth.authenticatePassword(
|
|
{ username: 'NEWUSER', password },
|
|
'192.0.2.10'
|
|
);
|
|
expect(login.user.mustChangePassword).toBe(true);
|
|
expect(auth.readSession(login.session.token)?.user.mustChangePassword).toBe(true);
|
|
await expect(
|
|
auth.changePassword(user.id, replacement, 'a different long passphrase')
|
|
).rejects.toThrow('Current password');
|
|
await expect(auth.changePassword(user.id, password, password)).rejects.toThrow(
|
|
'different password'
|
|
);
|
|
expect(auth.readSession(login.session.token)).not.toBeNull();
|
|
const changed = await auth.changePassword(user.id, password, replacement);
|
|
expect(changed.user).toMatchObject({
|
|
id: user.id,
|
|
role: 'viewer',
|
|
mustChangePassword: false
|
|
});
|
|
expect(auth.readSession(login.session.token)).toBeNull();
|
|
expect(auth.readSession(changed.session.token)?.user.mustChangePassword).toBe(false);
|
|
await expect(
|
|
auth.authenticatePassword({ username: user.username, password }, '192.0.2.11')
|
|
).rejects.toMatchObject({ status: 401 });
|
|
});
|
|
|
|
it('does not mint a session when the verified password is reset during login', async () => {
|
|
const admin = seedUser(),
|
|
target = seedUser('target', 'viewer');
|
|
const passwords = await import('../../src/lib/server/auth/passwords.js');
|
|
const verifying = gate<boolean>();
|
|
vi.spyOn(passwords, 'verifyPassword').mockImplementationOnce(() => verifying.promise);
|
|
vi.spyOn(passwords, 'hashPassword').mockResolvedValueOnce(replacementHash);
|
|
const pending = auth.authenticatePassword(
|
|
{ username: target.username, password },
|
|
'192.0.2.20'
|
|
);
|
|
await auth.updateUser(admin.id, target.id, { password: replacement });
|
|
verifying.release(true);
|
|
await expect(pending).rejects.toMatchObject({ status: 401 });
|
|
expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({
|
|
count: 0
|
|
});
|
|
});
|
|
|
|
it('rechecks a disabled account and current role after asynchronous login verification', async () => {
|
|
const admin = seedUser(),
|
|
target = seedUser('target', 'operator');
|
|
const passwords = await import('../../src/lib/server/auth/passwords.js');
|
|
const verifying = gate<boolean>();
|
|
vi.spyOn(passwords, 'verifyPassword').mockImplementationOnce(() => verifying.promise);
|
|
const pending = auth.authenticatePassword(
|
|
{ username: target.username, password },
|
|
'192.0.2.21'
|
|
);
|
|
await auth.updateUser(admin.id, target.id, { disabled: true });
|
|
verifying.release(true);
|
|
await expect(pending).rejects.toMatchObject({ status: 401 });
|
|
await auth.updateUser(admin.id, target.id, { disabled: false });
|
|
const second = gate<boolean>();
|
|
vi.mocked(passwords.verifyPassword).mockImplementationOnce(() => second.promise);
|
|
const nextLogin = auth.authenticatePassword(
|
|
{ username: target.username, password },
|
|
'192.0.2.22'
|
|
);
|
|
await auth.updateUser(admin.id, target.id, { role: 'viewer' });
|
|
second.release(true);
|
|
const loggedIn = await nextLogin;
|
|
expect(loggedIn.user.role).toBe('viewer');
|
|
expect(auth.readSession(loggedIn.session.token)?.user.role).toBe('viewer');
|
|
});
|
|
|
|
it('rechecks the administrator before committing a user creation that was hashing during demotion', async () => {
|
|
const actor = seedUser(),
|
|
other = seedUser('otheradmin');
|
|
const passwords = await import('../../src/lib/server/auth/passwords.js');
|
|
const hashing = gate<string>();
|
|
vi.spyOn(passwords, 'hashPassword').mockImplementationOnce(() => hashing.promise);
|
|
const pending = auth.createUser(actor.id, {
|
|
username: 'intruder',
|
|
password,
|
|
role: 'admin'
|
|
});
|
|
await auth.updateUser(other.id, actor.id, { role: 'viewer' });
|
|
hashing.release(passwordHash);
|
|
await expect(pending).rejects.toMatchObject({ status: 403 });
|
|
expect(auth.listUsers()).toHaveLength(2);
|
|
});
|
|
|
|
it('does not commit a password reset if its administrator lost access while hashing', async () => {
|
|
const actor = seedUser(),
|
|
other = seedUser('otheradmin'),
|
|
target = seedUser('target', 'viewer');
|
|
const session = auth.createSession(target.id);
|
|
const passwords = await import('../../src/lib/server/auth/passwords.js');
|
|
const hashing = gate<string>();
|
|
vi.spyOn(passwords, 'hashPassword').mockImplementationOnce(() => hashing.promise);
|
|
const pending = auth.updateUser(actor.id, target.id, { password: replacement });
|
|
await auth.updateUser(other.id, actor.id, { role: 'viewer' });
|
|
hashing.release(replacementHash);
|
|
await expect(pending).rejects.toMatchObject({ status: 403 });
|
|
expect(storedUser(target.id).password_hash).toBe(passwordHash);
|
|
expect(auth.readSession(session.token)).not.toBeNull();
|
|
});
|
|
|
|
it('rejects a self password change whose original password was concurrently reset', async () => {
|
|
const admin = seedUser(),
|
|
target = seedUser('target', 'viewer');
|
|
const passwords = await import('../../src/lib/server/auth/passwords.js');
|
|
const verifying = gate<boolean>();
|
|
vi.spyOn(passwords, 'verifyPassword').mockImplementationOnce(() => verifying.promise);
|
|
vi.spyOn(passwords, 'hashPassword').mockResolvedValue(replacementHash);
|
|
const pending = auth.changePassword(target.id, password, 'self selected new passphrase');
|
|
await auth.updateUser(admin.id, target.id, { password: replacement });
|
|
verifying.release(true);
|
|
await expect(pending).rejects.toMatchObject({ status: 409 });
|
|
expect(storedUser(target.id)).toMatchObject({
|
|
password_hash: replacementHash,
|
|
must_change_password: 1
|
|
});
|
|
expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({
|
|
count: 0
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('remembered administrator account changes', () => {
|
|
it.each([false, true])(
|
|
'enforces password confirmation in account creation and update endpoints (remembered=%s)',
|
|
async (remembered) => {
|
|
const admin = seedUser();
|
|
const login = auth.createSession(admin.id, remembered);
|
|
const sessionHash = auth.readSession(login.token)!.tokenHash;
|
|
const { POST } = await import('../../src/routes/api/users/+server.js');
|
|
const { PATCH } = await import('../../src/routes/api/users/[id]/+server.js');
|
|
function event(method: string, body: Record<string, unknown>, id = '') {
|
|
const url = new URL(`https://ec.test/api/users/${id}`);
|
|
return {
|
|
url,
|
|
request: new Request(url, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body)
|
|
}),
|
|
locals: { user: admin, sessionHash },
|
|
params: { id },
|
|
getClientAddress: () => '192.0.2.62'
|
|
};
|
|
}
|
|
const input = { username: 'newviewer', role: 'viewer', password: replacement };
|
|
if (remembered) {
|
|
for (const currentPassword of [undefined, replacement]) {
|
|
const response = await POST(
|
|
event('POST', { ...input, currentPassword }) as unknown as Parameters<
|
|
typeof POST
|
|
>[0]
|
|
);
|
|
expect(response.status).toBe(403);
|
|
expect(auth.listUsers()).toHaveLength(1);
|
|
}
|
|
}
|
|
const response = await POST(
|
|
event('POST', {
|
|
...input,
|
|
...(remembered ? { currentPassword: password } : {})
|
|
}) as unknown as Parameters<typeof POST>[0]
|
|
);
|
|
expect(response.status).toBe(201);
|
|
const { user } = await response.json();
|
|
const targetSession = auth.createSession(user.id, true);
|
|
const changes = { role: 'operator', password };
|
|
if (remembered) {
|
|
for (const currentPassword of [undefined, replacement]) {
|
|
const response = await PATCH(
|
|
event(
|
|
'PATCH',
|
|
{ ...changes, currentPassword },
|
|
user.id
|
|
) as unknown as Parameters<typeof PATCH>[0]
|
|
);
|
|
expect(response.status).toBe(403);
|
|
expect(auth.getUser(user.id)?.role).toBe('viewer');
|
|
expect(auth.readSession(targetSession.token)).not.toBeNull();
|
|
}
|
|
}
|
|
const updated = await PATCH(
|
|
event(
|
|
'PATCH',
|
|
{
|
|
...changes,
|
|
...(remembered ? { currentPassword: password } : {})
|
|
},
|
|
user.id
|
|
) as unknown as Parameters<typeof PATCH>[0]
|
|
);
|
|
expect(updated.status).toBe(200);
|
|
expect(auth.getUser(user.id)?.role).toBe('operator');
|
|
expect(auth.readSession(targetSession.token)).toBeNull();
|
|
const audit = JSON.stringify(auth.listAuthAudit());
|
|
expect(audit).not.toContain(replacement);
|
|
expect(audit).not.toContain('currentPassword');
|
|
}
|
|
);
|
|
|
|
it('rejects an account change if the remembered session is revoked while confirming the password', async () => {
|
|
const admin = seedUser(),
|
|
target = seedUser('target', 'viewer');
|
|
const session = auth.createSession(admin.id, true);
|
|
const tokenHash = auth.readSession(session.token)!.tokenHash;
|
|
const passwords = await import('../../src/lib/server/auth/passwords.js');
|
|
const verifying = gate<boolean>();
|
|
vi.spyOn(passwords, 'verifyPassword').mockImplementationOnce(() => verifying.promise);
|
|
const pending = auth.updateUser(
|
|
admin.id,
|
|
target.id,
|
|
{ role: 'admin', currentPassword: password },
|
|
{
|
|
tokenHash,
|
|
clientAddress: '192.0.2.62'
|
|
}
|
|
);
|
|
auth.revokeSession(session.token);
|
|
verifying.release(true);
|
|
await expect(pending).rejects.toMatchObject({ status: 401 });
|
|
expect(auth.getUser(target.id)?.role).toBe('viewer');
|
|
});
|
|
|
|
it('rechecks the session after hashing a new user password', async () => {
|
|
const admin = seedUser();
|
|
const session = auth.createSession(admin.id, true);
|
|
const tokenHash = auth.readSession(session.token)!.tokenHash;
|
|
const passwords = await import('../../src/lib/server/auth/passwords.js');
|
|
vi.spyOn(passwords, 'verifyPassword').mockResolvedValueOnce(true);
|
|
const hashing = gate<string>();
|
|
const hash = vi
|
|
.spyOn(passwords, 'hashPassword')
|
|
.mockImplementationOnce(() => hashing.promise);
|
|
const pending = auth.createUser(
|
|
admin.id,
|
|
{
|
|
username: 'newviewer',
|
|
role: 'viewer',
|
|
password: replacement,
|
|
currentPassword: password
|
|
},
|
|
{
|
|
tokenHash,
|
|
clientAddress: '192.0.2.62'
|
|
}
|
|
);
|
|
await vi.waitFor(() => expect(hash).toHaveBeenCalled());
|
|
auth.revokeSession(session.token);
|
|
hashing.release(replacementHash);
|
|
await expect(pending).rejects.toMatchObject({ status: 401 });
|
|
expect(auth.listUsers()).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
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,
|
|
sessionHash: auth.readSession(login.session.token)!.tokenHash
|
|
},
|
|
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);
|
|
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');
|
|
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();
|
|
});
|
|
|
|
it('limits attempts from one address across many accounts and separates setup from login windows', () => {
|
|
for (let account = 0; account < 30; account++)
|
|
auth.consumeAuthAttempt('192.0.2.40', 'account-' + account);
|
|
expect(() => auth.consumeAuthAttempt('192.0.2.40', 'new-account')).toThrow(
|
|
'Too many attempts'
|
|
);
|
|
expect(() => auth.consumeAuthAttempt('192.0.2.41', 'new-account')).not.toThrow();
|
|
expect(() => auth.consumeAuthAttempt('192.0.2.40', 'bootstrap', 'setup')).not.toThrow();
|
|
});
|
|
});
|