This commit is contained in:
@@ -0,0 +1,386 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AuthUser, UserRole } from '../../src/lib/auth.js';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
|
||||
// Exercise real password verification without weakening production scrypt costs.
|
||||
vi.setConfig({ testTimeout: 20_000, hookTimeout: 20_000 });
|
||||
const password = 'testpass';
|
||||
const replacement = 'a replacement account passphrase';
|
||||
const firstAddress = '192.0.2.1';
|
||||
const limitedAddress = '192.0.2.2';
|
||||
let passwordHash: string;
|
||||
let fixture: Fixture;
|
||||
let auth: typeof import('../../src/lib/server/auth/index.js');
|
||||
let database: typeof import('../../src/lib/server/db/index.js');
|
||||
|
||||
beforeAll(async () => {
|
||||
const passwords = await import('../../src/lib/server/auth/passwords.js');
|
||||
passwordHash = await passwords.hashPassword(password);
|
||||
});
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
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.unstubAllEnvs();
|
||||
await fixture.cleanup();
|
||||
});
|
||||
|
||||
function seedUser(username = 'viewer', role: UserRole = 'viewer', disabled = false): AuthUser {
|
||||
const id = randomUUID();
|
||||
const now = Date.now();
|
||||
database
|
||||
.db()
|
||||
.prepare(
|
||||
`INSERT INTO auth_users(id,username,display_name,password_hash,role,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`
|
||||
)
|
||||
.run(id, username, username, passwordHash, role, Number(disabled), now, now);
|
||||
return auth.getUser(id)!;
|
||||
}
|
||||
|
||||
function exhaustPair(username: string, address = limitedAddress) {
|
||||
for (let attempt = 0; attempt < 10; attempt++) auth.consumeAuthAttempt(address, username);
|
||||
}
|
||||
|
||||
function devices() {
|
||||
return database.db().prepare('SELECT * FROM auth_login_devices ORDER BY rowid').all();
|
||||
}
|
||||
|
||||
async function loginRoute(
|
||||
input: Record<string, unknown>,
|
||||
options: { cookie?: string; address?: string; origin?: string } = {}
|
||||
) {
|
||||
const { POST } = await import('../../src/routes/api/auth/login/+server.js');
|
||||
const url = new URL('/api/auth/login', options.origin ?? 'https://ec.test');
|
||||
const deviceName = url.protocol === 'https:' ? '__Host-ec_login_device' : 'ec_login_device_dev';
|
||||
const cookies = {
|
||||
get: vi.fn((name: string) => (name === deviceName ? options.cookie : undefined)),
|
||||
set: vi.fn()
|
||||
};
|
||||
const response = await POST({
|
||||
request: new Request(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input)
|
||||
}),
|
||||
cookies,
|
||||
url,
|
||||
getClientAddress: () => options.address ?? firstAddress
|
||||
} as unknown as Parameters<typeof POST>[0]);
|
||||
return { response, cookies };
|
||||
}
|
||||
|
||||
describe('known login device proof', () => {
|
||||
it('creates an account-bound, hashed proof after password verification that cannot authenticate a session', async () => {
|
||||
const now = 2_000_000_000_000;
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
const user = seedUser();
|
||||
|
||||
const result = await auth.authenticatePassword(
|
||||
{ username: user.username, password },
|
||||
firstAddress
|
||||
);
|
||||
|
||||
const device = result.loginDevice!;
|
||||
expect(device.token).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
expect(device.token).not.toBe(result.session.token);
|
||||
expect(device.expiresAt).toBe(now + 30 * 24 * 60 * 60_000);
|
||||
expect(devices()).toEqual([
|
||||
{
|
||||
token_hash: createHash('sha256').update(device.token).digest('hex'),
|
||||
user_id: user.id,
|
||||
created_at: now,
|
||||
expires_at: device.expiresAt
|
||||
}
|
||||
]);
|
||||
expect(auth.readSession(device.token)).toBeNull();
|
||||
expect(auth.readSession(result.session.token)?.user.id).toBe(user.id);
|
||||
const persisted = JSON.stringify(devices()) + JSON.stringify(auth.listAuthAudit());
|
||||
expect(persisted).not.toContain(device.token);
|
||||
expect(persisted).not.toContain(result.session.token);
|
||||
expect(persisted).not.toContain(password);
|
||||
});
|
||||
|
||||
it('allows a proven account past an exhausted pair while still rejecting a wrong password', async () => {
|
||||
const user = seedUser();
|
||||
const initial = await auth.authenticatePassword(
|
||||
{ username: user.username, password },
|
||||
firstAddress
|
||||
);
|
||||
exhaustPair(user.username);
|
||||
await expect(
|
||||
auth.authenticatePassword({ username: user.username, password }, limitedAddress)
|
||||
).rejects.toMatchObject({ status: 429 });
|
||||
const original = devices();
|
||||
|
||||
await expect(
|
||||
auth.authenticatePassword(
|
||||
{ username: user.username, password: 'wrong password' },
|
||||
limitedAddress,
|
||||
initial.loginDevice!.token
|
||||
)
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
expect(devices()).toEqual(original);
|
||||
const result = await auth.authenticatePassword(
|
||||
{ username: user.username.toUpperCase(), password },
|
||||
limitedAddress,
|
||||
initial.loginDevice!.token
|
||||
);
|
||||
|
||||
expect(result.user.id).toBe(user.id);
|
||||
expect(result.loginDevice).toEqual(initial.loginDevice);
|
||||
expect(devices()).toEqual(original);
|
||||
});
|
||||
|
||||
it.each(['other account', 'malformed', 'unknown', 'expired', 'session token'] as const)(
|
||||
'does not bypass an exhausted pair with %s proof',
|
||||
async (kind) => {
|
||||
const user = seedUser();
|
||||
const initial = await auth.authenticatePassword(
|
||||
{ username: user.username, password },
|
||||
firstAddress
|
||||
);
|
||||
const target = kind === 'other account' ? seedUser('another-viewer') : user;
|
||||
let token = initial.loginDevice!.token;
|
||||
if (kind === 'malformed') token += '!';
|
||||
if (kind === 'unknown') token = 'x'.repeat(43);
|
||||
if (kind === 'session token') token = initial.session.token;
|
||||
if (kind === 'expired')
|
||||
database.db().prepare('UPDATE auth_login_devices SET expires_at=?').run(Date.now());
|
||||
exhaustPair(target.username);
|
||||
const original = devices();
|
||||
|
||||
await expect(
|
||||
auth.authenticatePassword(
|
||||
{ username: target.username, password },
|
||||
limitedAddress,
|
||||
token
|
||||
)
|
||||
).rejects.toMatchObject({ status: 429 });
|
||||
|
||||
expect(devices()).toEqual(original);
|
||||
}
|
||||
);
|
||||
|
||||
it('continues enforcing the shared IP ceiling for a valid device', async () => {
|
||||
const user = seedUser();
|
||||
const initial = await auth.authenticatePassword(
|
||||
{ username: user.username, password },
|
||||
firstAddress
|
||||
);
|
||||
for (let attempt = 0; attempt < 30; attempt++)
|
||||
auth.consumeAuthAttempt(limitedAddress, `missing-${attempt}`);
|
||||
|
||||
await expect(
|
||||
auth.authenticatePassword(
|
||||
{ username: user.username, password },
|
||||
limitedAddress,
|
||||
initial.loginDevice!.token
|
||||
)
|
||||
).rejects.toMatchObject({ status: 429 });
|
||||
expect(devices()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('accepts proof only from the request cookie and ignores body-only trust assertions', async () => {
|
||||
const user = seedUser();
|
||||
const initial = await auth.authenticatePassword(
|
||||
{ username: user.username, password },
|
||||
firstAddress
|
||||
);
|
||||
exhaustPair(user.username);
|
||||
const input = {
|
||||
username: user.username,
|
||||
password,
|
||||
loginDevice: initial.loginDevice!.token,
|
||||
trusted: true,
|
||||
knownDevice: true
|
||||
};
|
||||
|
||||
const rejected = await loginRoute(input, { address: limitedAddress });
|
||||
expect(rejected.response.status).toBe(429);
|
||||
expect(rejected.cookies.set).not.toHaveBeenCalled();
|
||||
const accepted = await loginRoute(input, {
|
||||
address: limitedAddress,
|
||||
cookie: initial.loginDevice!.token
|
||||
});
|
||||
expect(accepted.response.status).toBe(200);
|
||||
expect(accepted.cookies.get).toHaveBeenCalledWith('__Host-ec_login_device');
|
||||
expect(devices()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('retains the original absolute expiry when the database is reopened and the device logs in again', async () => {
|
||||
const now = 2_000_000_000_000;
|
||||
const clock = vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
const user = seedUser();
|
||||
const initial = await auth.authenticatePassword(
|
||||
{ username: user.username, password },
|
||||
firstAddress
|
||||
);
|
||||
const original = devices();
|
||||
database.closeDb();
|
||||
clock.mockReturnValue(now + 5 * 24 * 60 * 60_000);
|
||||
exhaustPair(user.username);
|
||||
|
||||
const next = await auth.authenticatePassword(
|
||||
{ username: user.username, password },
|
||||
limitedAddress,
|
||||
initial.loginDevice!.token
|
||||
);
|
||||
|
||||
expect(next.loginDevice).toEqual(initial.loginDevice);
|
||||
expect(devices()).toEqual(original);
|
||||
clock.mockReturnValue(initial.loginDevice!.expiresAt);
|
||||
exhaustPair(user.username);
|
||||
await expect(
|
||||
auth.authenticatePassword(
|
||||
{ username: user.username, password },
|
||||
limitedAddress,
|
||||
initial.loginDevice!.token
|
||||
)
|
||||
).rejects.toMatchObject({ status: 429 });
|
||||
});
|
||||
|
||||
it('does not issue device cookies or store proof for failed password logins', async () => {
|
||||
seedUser();
|
||||
seedUser('disabled', 'viewer', true);
|
||||
for (const input of [
|
||||
{ username: 'viewer', password: 'wrong password' },
|
||||
{ username: 'missing', password },
|
||||
{ username: 'disabled', password }
|
||||
]) {
|
||||
const result = await loginRoute(input);
|
||||
expect(result.response.status).toBe(401);
|
||||
expect(result.cookies.set).not.toHaveBeenCalled();
|
||||
expect(devices()).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('known login device lifecycle', () => {
|
||||
it('keeps device proof through logout while removing the session cookie and session', async () => {
|
||||
const user = seedUser();
|
||||
const initial = await auth.authenticatePassword(
|
||||
{ username: user.username, password },
|
||||
firstAddress
|
||||
);
|
||||
const original = devices();
|
||||
const { POST } = await import('../../src/routes/api/auth/logout/+server.js');
|
||||
const cookies = {
|
||||
get: vi.fn((name: string) =>
|
||||
name === '__Host-ec_session' ? initial.session.token : initial.loginDevice!.token
|
||||
),
|
||||
delete: vi.fn()
|
||||
};
|
||||
const response = await POST({
|
||||
cookies,
|
||||
url: new URL('https://ec.test/api/auth/logout'),
|
||||
locals: { user }
|
||||
} as unknown as Parameters<typeof POST>[0]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(cookies.delete.mock.calls).toEqual([['__Host-ec_session', { path: '/' }]]);
|
||||
expect(auth.readSession(initial.session.token)).toBeNull();
|
||||
expect(devices()).toEqual(original);
|
||||
exhaustPair(user.username);
|
||||
await expect(
|
||||
auth.authenticatePassword(
|
||||
{ username: user.username, password },
|
||||
limitedAddress,
|
||||
initial.loginDevice!.token
|
||||
)
|
||||
).resolves.toMatchObject({ user: { id: user.id }, loginDevice: initial.loginDevice });
|
||||
});
|
||||
|
||||
it.each(['role', 'disable', 'password reset', 'password change', 'revoke sessions'] as const)(
|
||||
'revokes device proof after an account %s operation',
|
||||
async (operation) => {
|
||||
const admin = seedUser('administrator', 'admin');
|
||||
const user = seedUser();
|
||||
const initial = await auth.authenticatePassword(
|
||||
{ username: user.username, password },
|
||||
firstAddress
|
||||
);
|
||||
if (operation === 'role')
|
||||
await auth.updateUser(admin.id, user.id, { role: 'operator' });
|
||||
if (operation === 'disable')
|
||||
await auth.updateUser(admin.id, user.id, { disabled: true });
|
||||
if (operation === 'password reset')
|
||||
await auth.updateUser(admin.id, user.id, { password: replacement });
|
||||
if (operation === 'password change')
|
||||
await auth.changePassword(
|
||||
user.id,
|
||||
password,
|
||||
replacement,
|
||||
auth.readSession(initial.session.token)!.tokenHash
|
||||
);
|
||||
if (operation === 'revoke sessions') auth.revokeUserSessions(admin.id, user.id);
|
||||
|
||||
expect(devices()).toEqual([]);
|
||||
expect(auth.readSession(initial.session.token)).toBeNull();
|
||||
exhaustPair(user.username);
|
||||
await expect(
|
||||
auth.authenticatePassword(
|
||||
{
|
||||
username: user.username,
|
||||
password: operation.startsWith('password') ? replacement : password
|
||||
},
|
||||
limitedAddress,
|
||||
initial.loginDevice!.token
|
||||
)
|
||||
).rejects.toMatchObject({ status: 429 });
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ origin: 'https://ec.test', rememberMe: false },
|
||||
{ origin: 'https://ec.test', rememberMe: true },
|
||||
{ origin: 'http://localhost', rememberMe: false }
|
||||
])('sets a separate device cookie for $origin with rememberMe=$rememberMe', async (options) => {
|
||||
const now = 2_000_000_000_000;
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
const user = seedUser();
|
||||
|
||||
const { response, cookies } = await loginRoute(
|
||||
{ username: user.username, password, rememberMe: options.rememberMe },
|
||||
options
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(cookies.set).toHaveBeenCalledTimes(2);
|
||||
const secure = options.origin.startsWith('https:');
|
||||
const deviceName = secure ? '__Host-ec_login_device' : 'ec_login_device_dev';
|
||||
const sessionName = secure ? '__Host-ec_session' : 'ec_session_dev';
|
||||
const [, deviceToken, deviceOptions] = cookies.set.mock.calls.find(
|
||||
([name]) => name === deviceName
|
||||
)!;
|
||||
const [, sessionToken, sessionOptions] = cookies.set.mock.calls.find(
|
||||
([name]) => name === sessionName
|
||||
)!;
|
||||
expect(deviceOptions).toEqual({
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: 'lax',
|
||||
expires: new Date(now + 30 * 24 * 60 * 60_000)
|
||||
});
|
||||
expect(sessionOptions.expires).toEqual(
|
||||
new Date(
|
||||
now +
|
||||
(options.rememberMe
|
||||
? auth.REMEMBERED_SESSION_LIFETIME_MS
|
||||
: auth.SESSION_LIFETIME_MS)
|
||||
)
|
||||
);
|
||||
expect(deviceToken).not.toBe(sessionToken);
|
||||
expect(auth.readSession(deviceToken)).toBeNull();
|
||||
expect(auth.readSession(sessionToken)?.remembered).toBe(options.rememberMe);
|
||||
expect(response.headers.get('Cache-Control')).toBe('no-store');
|
||||
expect(await response.json()).toEqual({ user: expect.objectContaining({ id: user.id }) });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user