diff --git a/.env.example b/.env.example index 3184ea1..46de84d 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,14 @@ # Controller URL: use HTTPS with a trusted certificate for enrolled hosts. EC_PUBLIC_URL=https://electric-cherry.example.com +# Client addresses for login throttling behind a trusted reverse proxy. +# Leave ADDRESS_HEADER empty for direct connections. Enable only when all access +# goes through a proxy that replaces untrusted forwarding headers. +# For one trusted proxy using X-Forwarded-For: +# ADDRESS_HEADER=x-forwarded-for +# XFF_DEPTH=1 +# Set XFF_DEPTH to the number of trusted proxy hops; see the access guide. +ADDRESS_HEADER= +XFF_DEPTH=1 # Generate separate secrets with: openssl rand -hex 32 # Setup token authorizes creation of the first administrator only. Remove it # after setup. Never reuse the telemetry token as a setup or host credential. diff --git a/docker-compose.yml b/docker-compose.yml index 2939f06..279a868 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,9 @@ services: EC_TELEMETRY_TOKEN: ${EC_TELEMETRY_TOKEN:-} EC_BANK_MAX_BALANCE: ${EC_BANK_MAX_BALANCE:-250000} EC_AUTH_ALLOW_HTTP: ${EC_AUTH_ALLOW_HTTP:-false} + # Enable only behind a trusted proxy that sets the client-address header. + ADDRESS_HEADER: ${ADDRESS_HEADER:-} + XFF_DEPTH: ${XFF_DEPTH:-1} # Legacy host imports stream whole game files; buffering routes enforce their own caps. BODY_SIZE_LIMIT: 'Infinity' DB_PATH: /data/cherry.db diff --git a/docs/guides/authentication.md b/docs/guides/authentication.md index fab5c38..d965a18 100644 --- a/docs/guides/authentication.md +++ b/docs/guides/authentication.md @@ -113,9 +113,22 @@ revocation every 15 seconds and then close. 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 +Their attempts do not consume either limit. Other accounts use persistent +15-minute login windows of 10 attempts per username-and-address pair and 30 per +address across accounts. Attempts from one address do not exhaust another +address's allowance for the same username. Initial setup and password changes +retain their limits of 10 per account and 30 per address. Exemptions use the +account's current database role; password verification is still required. + +A successful password login also sets a separate, HTTP-only login-device cookie +for 30 days. This cookie lets that browser skip its username-and-address limit, +while the password and shared address limit still apply. It does not sign you in, +and is independent of **Keep me signed in**. It survives ordinary sign-out; +password changes/resets, role changes, disabling an account, and revoking all of +its sessions invalidate its login-device cookies. Only token hashes are stored in +SQLite. Reusing a valid cookie does not extend its original expiry. + +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. @@ -138,8 +151,10 @@ DB_PATH=./data/cherry.db node scripts/reset-admin.mjs admin Replace `admin` with the existing administrator's username. The command prompts without echoing the password, resets that enabled administrator only, revokes -their sessions and records the recovery. It does not expose a remote recovery -endpoint or accept passwords as command arguments. +their sessions and trusted login devices, clears their account-specific login and +password-change rate limits, and records the recovery. Other users' limits and +shared address limits remain intact. It does not expose a remote recovery endpoint +or accept passwords as command arguments. ## Agent and telemetry connections @@ -181,9 +196,27 @@ JSON requests and multipart uploads. Do not configure a proxy to replace arbitra incoming origins with your trusted origin. Machine endpoints use their own token checks and do not require a browser Origin. -Use the adapter's real client-address configuration only with a trusted proxy -that strips spoofed forwarding headers; login throttling otherwise uses the direct -peer address. Forwarded host or user headers do not authenticate users. +Login throttling uses the address returned by SvelteKit's adapter. Without +`ADDRESS_HEADER`, this is the direct peer address, so users behind a reverse proxy +share the proxy's address limit. Compose passes `ADDRESS_HEADER` and `XFF_DEPTH` +from `.env` to the controller; direct Node deployments must set them in the +server's environment. For one trusted proxy that sets `X-Forwarded-For`, use: + +```dotenv +ADDRESS_HEADER=x-forwarded-for +XFF_DEPTH=1 +``` + +Recreate the Compose controller after changing these values with +`docker compose up -d`. For a proxy chain, set `XFF_DEPTH` to the number of trusted +proxies: the adapter selects that many positions from the right of the header. +For example, `client, proxy1` with two trusted proxies requires `XFF_DEPTH=2`. +The public-facing proxy must replace untrusted forwarding headers, and every +trusted hop must supply the expected address chain. Restrict direct access to +port 3000 so requests cannot bypass those proxies. Do not enable these settings +for an untrusted proxy or copy a depth that does not match your deployment. +See the [adapter's client-address settings](https://svelte.dev/docs/kit/adapter-node#Environment-variables-ADDRESS_HEADER-and-XFF_DEPTH). +Forwarded host or user headers do not authenticate users. Local HTTP browser development requires `EC_AUTH_ALLOW_HTTP=true` and a loopback hostname (`localhost`, `127.0.0.1` or `::1`). This uses a separate development diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index e2a981b..543c12e 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -56,6 +56,11 @@ including server-rendered pages, page data, and API responses. The build already precompresses static JavaScript and CSS assets; dynamically generated responses need compression at the proxy. +Configure `ADDRESS_HEADER` and `XFF_DEPTH` for your trusted proxy so login limits +use each client's address. Leave `ADDRESS_HEADER` empty for direct connections; +see the [proxy configuration guide](authentication.md#proxy-and-local-development) +before enabling forwarded addresses. + Open the dashboard and follow `/setup` to create the first administrator. Then remove `EC_SETUP_TOKEN` from `.env` and run `docker compose up -d` to recreate the controller. Setup stays closed once an account exists. diff --git a/scripts/reset-admin.mjs b/scripts/reset-admin.mjs index c511745..da87c22 100644 --- a/scripts/reset-admin.mjs +++ b/scripts/reset-admin.mjs @@ -1,9 +1,13 @@ #!/usr/bin/env node // Local recovery only: requires filesystem/container access to the controller. import Database from 'better-sqlite3'; +import { createHash } from 'node:crypto'; +import { resolve } from 'node:path'; import { emitKeypressEvents } from 'node:readline'; +import { fileURLToPath } from 'node:url'; import { hashPassword } from '../src/lib/server/auth/passwords.js'; +/** @param {string} prompt @returns {Promise} */ function secret(prompt) { return new Promise((resolve, reject) => { let value = ''; @@ -17,6 +21,7 @@ function secret(prompt) { process.stdin.pause(); process.stdout.write('\n'); }; + /** @param {string | undefined} text @param {import('node:readline').Key | undefined} key */ const listener = (text, key) => { if (key?.ctrl && key.name === 'c') { cleanup(); @@ -39,7 +44,67 @@ function secret(prompt) { }); } -try { +/** @param {import('better-sqlite3').Database} database @param {string} username */ +function administrator(database, username) { + const user = /** @type {{ id: string, username: string } | undefined} */ ( + database + .prepare( + "SELECT id,username FROM auth_users WHERE username=? AND role='admin' AND disabled=0" + ) + .get(username) + ); + if (!user) throw new Error('An enabled administrator with that username was not found.'); + return user; +} + +/** + * Local recovery shared with regression tests; the CLI only accepts passwords on its TTY. + * @param {import('better-sqlite3').Database} database + * @param {string} username + * @param {string} password + */ +export async function recoverAdministrator(database, username, password) { + const user = administrator(database, username); + const passwordHash = await hashPassword(password); + const digest = (/** @type {string} */ value) => + createHash('sha256').update(value).digest('hex'); + database + .transaction(() => { + const changed = database + .prepare( + "UPDATE auth_users SET password_hash=?,must_change_password=0,updated_at=? WHERE id=? AND role='admin' AND disabled=0" + ) + .run(passwordHash, Date.now(), user.id); + if (changed.changes !== 1) + throw new Error('Administrator changed during recovery. Try again.'); + database.prepare('DELETE FROM auth_sessions WHERE user_id=?').run(user.id); + // Older databases may not have device credentials yet; recovery still works. + if ( + database + .prepare( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='auth_login_devices'" + ) + .get() + ) { + database.prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(user.id); + } + database + .prepare('DELETE FROM auth_rate_limits WHERE key GLOB ? OR key IN (?,?)') + .run( + `login:account:${digest(user.username.toLowerCase())}:ip:*`, + digest(`login:account:${user.username.toLowerCase()}`), + digest(`password:account:${user.id.toLowerCase()}`) + ); + database + .prepare( + 'INSERT INTO auth_audit(actor_id,action,target_id,details,created_at) VALUES(NULL,?,?,?,?)' + ) + .run('password.recovered', user.id, '{"source":"local-cli"}', Date.now()); + }) + .immediate(); +} + +async function main() { const username = process.argv[2]?.toLowerCase(); if (process.argv.length !== 3 || !username || !/^[a-z0-9][a-z0-9_.-]{2,63}$/.test(username)) throw new Error('Usage: node scripts/reset-admin.mjs '); @@ -51,34 +116,25 @@ try { fileMustExist: true }); database.pragma('busy_timeout=5000'); - const user = database - .prepare("SELECT id FROM auth_users WHERE username=? AND role='admin' AND disabled=0") - .get(username); - if (!user) throw new Error('An enabled administrator with that username was not found.'); - const password = await secret('New administrator password (8–128 characters): '); - const confirmation = await secret('Repeat password: '); - if (password !== confirmation) throw new Error('Passwords did not match.'); - const passwordHash = await hashPassword(password); - database - .transaction(() => { - const changed = database - .prepare( - "UPDATE auth_users SET password_hash=?,must_change_password=0,updated_at=? WHERE id=? AND role='admin' AND disabled=0" - ) - .run(passwordHash, Date.now(), user.id); - if (changed.changes !== 1) - throw new Error('Administrator changed during recovery. Try again.'); - database.prepare('DELETE FROM auth_sessions WHERE user_id=?').run(user.id); - database - .prepare( - 'INSERT INTO auth_audit(actor_id,action,target_id,details,created_at) VALUES(NULL,?,?,?,?)' - ) - .run('password.recovered', user.id, '{"source":"local-cli"}', Date.now()); - }) - .immediate(); - database.close(); - console.log('Administrator password reset. Existing sessions have been revoked.'); -} catch (cause) { - console.error(cause instanceof Error ? cause.message : 'Administrator recovery failed.'); - process.exitCode = 1; + try { + administrator(database, username); + const password = await secret('New administrator password (8–128 characters): '); + const confirmation = await secret('Repeat password: '); + if (password !== confirmation) throw new Error('Passwords did not match.'); + await recoverAdministrator(database, username, password); + console.log( + 'Administrator password reset. Sessions and trusted login devices revoked; account rate limits cleared.' + ); + } finally { + database.close(); + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + await main(); + } catch (cause) { + console.error(cause instanceof Error ? cause.message : 'Administrator recovery failed.'); + process.exitCode = 1; + } } diff --git a/src/lib/server/auth/http.ts b/src/lib/server/auth/http.ts index 36486cd..3adb911 100644 --- a/src/lib/server/auth/http.ts +++ b/src/lib/server/auth/http.ts @@ -1,11 +1,14 @@ import { error, isHttpError, json, type Cookies } from '@sveltejs/kit'; -import { AuthError, SESSION_COOKIE, type AuthResult } from './index.js'; +import { AuthError, LOGIN_DEVICE_COOKIE, SESSION_COOKIE, type AuthResult } from './index.js'; import { PasswordServiceBusyError } from './passwords.js'; import type { AuthUser } from '../../auth.js'; export function sessionCookie(url: URL): string { return url.protocol === 'https:' ? SESSION_COOKIE : 'ec_session_dev'; } +export function loginDeviceCookie(url: URL): string { + return url.protocol === 'https:' ? LOGIN_DEVICE_COOKIE : 'ec_login_device_dev'; +} export function requireUserTransport(url: URL): void { if (url.protocol === 'https:') return; if ( @@ -34,6 +37,14 @@ export function sessionResponse(result: AuthResult, cookies: Cookies, url: URL) sameSite: 'lax', expires: new Date(result.session.expiresAt) }); + if (result.loginDevice) + cookies.set(loginDeviceCookie(url), result.loginDevice.token, { + path: '/', + httpOnly: true, + secure: url.protocol === 'https:', + sameSite: 'lax', + expires: new Date(result.loginDevice.expiresAt) + }); return json({ user: result.user }, { headers: { 'Cache-Control': 'no-store' } }); } export function requireUser(user: AuthUser | null): AuthUser { diff --git a/src/lib/server/auth/index.ts b/src/lib/server/auth/index.ts index e22f31f..0701fb9 100644 --- a/src/lib/server/auth/index.ts +++ b/src/lib/server/auth/index.ts @@ -7,6 +7,8 @@ export const SESSION_COOKIE = '__Host-ec_session'; export const SESSION_LIFETIME_MS = 12 * 60 * 60_000; export const SESSION_IDLE_MS = 30 * 60_000; export const REMEMBERED_SESSION_LIFETIME_MS = 30 * 24 * 60 * 60_000; +export const LOGIN_DEVICE_COOKIE = '__Host-ec_login_device'; +export const LOGIN_DEVICE_LIFETIME_MS = 30 * 24 * 60 * 60_000; const digest = (value: string) => createHash('sha256').update(value).digest('hex'); function database() { return db(); @@ -111,7 +113,8 @@ export function listAuthAudit() { export function consumeAuthAttempt( clientAddress: string, account: string, - kind: 'login' | 'setup' | 'password' = 'login' + kind: 'login' | 'setup' | 'password' = 'login', + loginDevice?: string ): void { // Resolve the current role from storage; setup never has an existing account exemption. if ( @@ -127,16 +130,22 @@ export function consumeAuthAttempt( const now = Date.now(); const windowMs = 15 * 60_000; const ipKey = digest(`${kind}:ip:${clientAddress}`); - const accountKey = digest(`${kind}:account:${account.toLowerCase()}`); + // Keep the hashed account prefix identifiable for local account recovery. + const accountKey = + kind === 'login' + ? `login:account:${digest(account.toLowerCase())}:ip:${digest(clientAddress)}` + : digest(`${kind}:account:${account.toLowerCase()}`); + // A prior successful login permits another password check even when this + // account/address pair is exhausted. The shared address limit still applies. + const knownDevice = kind === 'login' && readLoginDevice(account, loginDevice); + const limits: [string, number][] = [[ipKey, 30]]; + if (!knownDevice) limits.push([accountKey, 10]); database() .transaction(() => { database() .prepare('DELETE FROM auth_rate_limits WHERE window_start < ?') .run(now - 24 * 60 * 60_000); - for (const [key, limit] of [ - [ipKey, 30], - [accountKey, 10] - ] as const) { + for (const [key, limit] of limits) { const previous = database() .prepare('SELECT window_start,attempts FROM auth_rate_limits WHERE key=?') .get(key) as { window_start: number; attempts: number } | undefined; @@ -147,7 +156,7 @@ export function consumeAuthAttempt( ) throw new AuthError('Too many attempts. Try again in 15 minutes.', 429); } - for (const key of [ipKey, accountKey]) + for (const [key] of limits) database() .prepare( `INSERT INTO auth_rate_limits(key,window_start,attempts) VALUES(?,?,1) @@ -189,6 +198,7 @@ function insertUser(input: { export interface AuthResult { user: AuthUser; session: { token: string; expiresAt: number }; + loginDevice?: { token: string; expiresAt: number }; } export async function bootstrapAdmin( input: Record, @@ -230,13 +240,14 @@ export async function bootstrapAdmin( } export async function authenticatePassword( input: Record, - clientAddress: string + clientAddress: string, + loginDevice?: string ): Promise { if (input.rememberMe !== undefined && typeof input.rememberMe !== 'boolean') throw new AuthError('Keep me signed in must be true or false.'); const name = typeof input.username === 'string' ? input.username.slice(0, 64).toLowerCase() : ''; - consumeAuthAttempt(clientAddress, name); + consumeAuthAttempt(clientAddress, name, 'login', loginDevice); const row = database().prepare('SELECT * FROM auth_users WHERE username=?').get(name) as UserRow | undefined; const valid = await verifyPassword(input.password, row?.password_hash ?? null); @@ -258,7 +269,8 @@ export async function authenticatePassword( recordAudit(current.id, 'login', current.id); return { user: getUser(current.id)!, - session: createSession(current.id, input.rememberMe === true) + session: createSession(current.id, input.rememberMe === true), + loginDevice: createLoginDevice(current, loginDevice) }; }) .immediate(); @@ -266,6 +278,32 @@ export async function authenticatePassword( return result; } +function readLoginDevice(account: string, token: string | undefined) { + if (!token || !/^[A-Za-z0-9_-]{43}$/.test(token)) return null; + return database() + .prepare( + `SELECT d.expires_at FROM auth_login_devices d JOIN auth_users u ON u.id=d.user_id + WHERE d.token_hash=? AND d.expires_at>? AND u.username=? AND u.disabled=0` + ) + .get(digest(token), Date.now(), account) as { expires_at: number } | undefined; +} + +/** Proof of a previous password login only; this token never authenticates a session. */ +function createLoginDevice(user: UserRow, previousToken?: string) { + const now = Date.now(); + database().prepare('DELETE FROM auth_login_devices WHERE expires_at<=?').run(now); + const previous = readLoginDevice(user.username, previousToken); + if (previous && previousToken) return { token: previousToken, expiresAt: previous.expires_at }; + const token = randomBytes(32).toString('base64url'); + const expiresAt = now + LOGIN_DEVICE_LIFETIME_MS; + database() + .prepare( + 'INSERT INTO auth_login_devices(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)' + ) + .run(digest(token), user.id, now, expiresAt); + return { token, expiresAt }; +} + export function createSession( userId: string, remembered = false @@ -327,6 +365,7 @@ export function revokeUserSessions(actorId: string, userId: string): void { requireAdministrator(actorId); if (!userRow(userId)) throw new AuthError('User not found.', 404); database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(userId); + database().prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(userId); recordAudit(actorId, 'sessions.revoked', userId); })(); } @@ -427,8 +466,10 @@ export async function updateUser( Date.now(), id ); - if (passwordHash || disabled !== current.disabled || nextRole !== current.role) + if (passwordHash || disabled !== current.disabled || nextRole !== current.role) { database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(id); + database().prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(id); + } recordAudit(actorId, 'user.updated', id, { role: nextRole, disabled: !!disabled, @@ -469,6 +510,7 @@ export async function changePassword( ) .run(passwordHash, Date.now(), userId); database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(userId); + database().prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(userId); recordAudit(userId, 'password.changed', userId); return { user: getUser(userId)!, diff --git a/src/lib/server/auth/schema.ts b/src/lib/server/auth/schema.ts index 7c6c412..cc2bacf 100644 --- a/src/lib/server/auth/schema.ts +++ b/src/lib/server/auth/schema.ts @@ -33,6 +33,17 @@ export function migrateRememberedSessions(handle: Database.Database): void { `); } +export function migrateLoginDevices(handle: Database.Database): void { + handle.exec(` + CREATE TABLE IF NOT EXISTS auth_login_devices ( + token_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS auth_login_devices_user ON auth_login_devices(user_id); + CREATE INDEX IF NOT EXISTS auth_login_devices_expires_at ON auth_login_devices(expires_at); + `); +} + /** Run with foreign keys disabled by the migration runner before its transaction. */ export function migrateSuperViewerRole(handle: Database.Database): void { const schema = handle diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index 0dea7b8..8ac7a86 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -16,6 +16,7 @@ import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js' import { migrateHostSchema } from '../hosts/schema.js'; import { migrateAuthSchema, + migrateLoginDevices, migrateRememberedSessions, migrateSuperViewerRole, migrateModeratorRole @@ -160,7 +161,8 @@ export const migrations: readonly Migration[] = [ 'ALTER TABLE moderation_runtimes ADD COLUMN session_sequence INTEGER NOT NULL DEFAULT 0' ); } - } + }, + { version: 28, name: 'known login devices', run: migrateLoginDevices } ]; export const databaseVersion = migrations.length; diff --git a/src/routes/api/auth/login/+server.ts b/src/routes/api/auth/login/+server.ts index 34ffd01..254a400 100644 --- a/src/routes/api/auth/login/+server.ts +++ b/src/routes/api/auth/login/+server.ts @@ -1,9 +1,19 @@ import type { RequestHandler } from './$types'; import { authenticatePassword, revokeSession } from '$lib/server/auth/index.js'; -import { authBody, authError, sessionCookie, sessionResponse } from '$lib/server/auth/http.js'; +import { + authBody, + authError, + loginDeviceCookie, + sessionCookie, + sessionResponse +} from '$lib/server/auth/http.js'; export const POST: RequestHandler = async ({ request, cookies, url, getClientAddress }) => { try { - const result = await authenticatePassword(await authBody(request), getClientAddress()); + const result = await authenticatePassword( + await authBody(request), + getClientAddress(), + cookies.get(loginDeviceCookie(url)) + ); revokeSession(cookies.get(sessionCookie(url))); return sessionResponse(result, cookies, url); } catch (cause) { diff --git a/tests/unit/auth-login-devices.test.ts b/tests/unit/auth-login-devices.test.ts new file mode 100644 index 0000000..69cdf36 --- /dev/null +++ b/tests/unit/auth-login-devices.test.ts @@ -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, + 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[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[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 }) }); + }); +}); diff --git a/tests/unit/auth-recovery.test.ts b/tests/unit/auth-recovery.test.ts new file mode 100644 index 0000000..bb446e8 --- /dev/null +++ b/tests/unit/auth-recovery.test.ts @@ -0,0 +1,175 @@ +import Database from 'better-sqlite3'; +import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { recoverAdministrator } from '../../scripts/reset-admin.mjs'; +import { verifyPassword } from '../../src/lib/server/auth/passwords.js'; +import { migrateAuthSchema } from '../../src/lib/server/auth/schema.js'; + +vi.setConfig({ testTimeout: 20_000 }); +let database: Database.Database; +const password = 'replacement administrator passphrase'; +const digest = (value: string) => createHash('sha256').update(value).digest('hex'); +const pairKey = (kind: string, account: string, ip: string) => + `${kind}:account:${digest(account.toLowerCase())}:ip:${digest(ip)}`; + +beforeEach(() => { + database = new Database(':memory:'); + database.pragma('foreign_keys = ON'); + migrateAuthSchema(database); + database.exec(` + INSERT INTO auth_users VALUES + ('admin-id','Admin_One','Admin','original-hash','admin',0,1,10,20,NULL), + ('other-id','Other_Admin','Other','other-hash','admin',0,0,10,20,NULL), + ('viewer-id','Viewer','Viewer','viewer-hash','viewer',0,0,10,20,NULL), + ('disabled-id','Disabled_Admin','Disabled','disabled-hash','admin',1,0,10,20,NULL); + INSERT INTO auth_sessions VALUES + ('admin-session','admin-id',10,1000,100), + ('other-session','other-id',10,1000,100); + `); +}); + +afterEach(() => database.close()); + +function rateKeys() { + return database.prepare('SELECT key FROM auth_rate_limits ORDER BY key').all(); +} + +function addRates(keys: string[]) { + const insert = database.prepare('INSERT INTO auth_rate_limits VALUES (?,100,30)'); + for (const key of keys) insert.run(key); +} + +function addDevices() { + database.exec(` + CREATE TABLE auth_login_devices ( + token_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE, + expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL + ); + INSERT INTO auth_login_devices VALUES + ('admin-device','admin-id',1000,10), + ('other-device','other-id',1000,10); + `); +} + +describe('local administrator recovery', () => { + it('resets only the target administrator and clears their sessions, devices and account limits', async () => { + addDevices(); + const cleared = [ + pairKey('login', 'admin_one', '192.0.2.1'), + pairKey('login', 'admin_one', '192.0.2.2'), + digest('login:account:admin_one'), + digest('password:account:admin-id') + ]; + const preserved = [ + pairKey('login', 'other_admin', '192.0.2.1'), + pairKey('setup', 'admin_one', '192.0.2.1'), + digest('login:account:other_admin'), + digest('password:account:other-id'), + digest('login:ip:192.0.2.1'), + digest('password:ip:192.0.2.1'), + digest('setup:ip:192.0.2.1') + ]; + addRates([...cleared, ...preserved]); + const others = database.prepare("SELECT * FROM auth_users WHERE id!='admin-id'").all(); + + await recoverAdministrator(database, 'ADMIN_ONE', password); + + const user = database + .prepare( + "SELECT password_hash,must_change_password,updated_at FROM auth_users WHERE id='admin-id'" + ) + .get() as { password_hash: string; must_change_password: number; updated_at: number }; + expect(await verifyPassword(password, user.password_hash)).toBe(true); + expect(user.must_change_password).toBe(0); + expect(user.updated_at).toBeGreaterThan(20); + expect(database.prepare("SELECT * FROM auth_users WHERE id!='admin-id'").all()).toEqual( + others + ); + expect(database.prepare('SELECT token_hash,user_id FROM auth_sessions').all()).toEqual([ + { token_hash: 'other-session', user_id: 'other-id' } + ]); + expect(database.prepare('SELECT token_hash,user_id FROM auth_login_devices').all()).toEqual( + [{ token_hash: 'other-device', user_id: 'other-id' }] + ); + expect(rateKeys()).toEqual(preserved.sort().map((key) => ({ key }))); + expect( + database.prepare('SELECT actor_id,action,target_id,details FROM auth_audit').all() + ).toEqual([ + { + actor_id: null, + action: 'password.recovered', + target_id: 'admin-id', + details: '{"source":"local-cli"}' + } + ]); + }); + + it('supports older databases without trusted-device tables and clears their legacy account limits', async () => { + addRates([digest('login:account:admin_one'), digest('password:account:admin-id')]); + + await recoverAdministrator(database, 'admin_one', password); + + expect(rateKeys()).toEqual([]); + expect( + database.prepare("SELECT * FROM auth_sessions WHERE user_id='admin-id'").all() + ).toEqual([]); + expect( + database.prepare("SELECT name FROM sqlite_master WHERE name='auth_login_devices'").get() + ).toBeUndefined(); + }); + + it('rejects disabled, non-administrator and missing accounts without making changes', async () => { + const users = database.prepare('SELECT * FROM auth_users').all(); + const sessions = database.prepare('SELECT * FROM auth_sessions').all(); + for (const username of ['disabled_admin', 'viewer', 'missing']) { + await expect(recoverAdministrator(database, username, password)).rejects.toThrow( + 'An enabled administrator with that username was not found.' + ); + } + expect(database.prepare('SELECT * FROM auth_users').all()).toEqual(users); + expect(database.prepare('SELECT * FROM auth_sessions').all()).toEqual(sessions); + expect(database.prepare('SELECT * FROM auth_audit').all()).toEqual([]); + }); + + it('rolls back the password, session, device and rate changes when recovery cannot be audited', async () => { + addDevices(); + addRates([pairKey('login', 'admin_one', '192.0.2.1')]); + database.exec(` + CREATE TRIGGER reject_recovery BEFORE INSERT ON auth_audit + BEGIN SELECT RAISE(ABORT, 'audit unavailable'); END; + `); + const tables = ['auth_users', 'auth_sessions', 'auth_login_devices', 'auth_rate_limits']; + const before = tables.map((table) => database.prepare(`SELECT * FROM ${table}`).all()); + + await expect(recoverAdministrator(database, 'admin_one', password)).rejects.toThrow( + 'audit unavailable' + ); + + expect(tables.map((table) => database.prepare(`SELECT * FROM ${table}`).all())).toEqual( + before + ); + expect(database.prepare('SELECT * FROM auth_audit').all()).toEqual([]); + }); + + it('keeps the command restricted to interactive password entry', () => { + const command = spawnSync(process.execPath, ['scripts/reset-admin.mjs', 'admin_one'], { + encoding: 'utf8', + input: password + }); + expect(command.status).toBe(1); + expect(command.stderr).toContain('Run this command in an interactive terminal.'); + expect(command.stdout).not.toContain(password); + const argument = spawnSync( + process.execPath, + ['scripts/reset-admin.mjs', 'admin_one', password], + { + encoding: 'utf8' + } + ); + expect(argument.status).toBe(1); + expect(argument.stderr).toContain('Usage: node scripts/reset-admin.mjs'); + expect(argument.stderr).not.toContain(password); + }); +}); diff --git a/tests/unit/auth-schema.test.ts b/tests/unit/auth-schema.test.ts index a30c615..8e106d4 100644 --- a/tests/unit/auth-schema.test.ts +++ b/tests/unit/auth-schema.test.ts @@ -1,6 +1,10 @@ import Database from 'better-sqlite3'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + migrateLoginDevices, migrateModeratorRole, migrateRememberedSessions, migrateSuperViewerRole @@ -9,6 +13,7 @@ import { runMigrations } from '../../src/lib/server/db/migration-runner.js'; import { migrations } from '../../src/lib/server/db/migrations.js'; let handle: Database.Database; +const temporaryDirectories: string[] = []; const migration = migrations.find((entry) => entry.name === 'super viewer authentication role')!; beforeEach(() => { @@ -30,7 +35,11 @@ beforeEach(() => { `); }); -afterEach(() => handle.close()); +afterEach(() => { + handle.close(); + for (const directory of temporaryDirectories.splice(0)) + rmSync(directory, { recursive: true, force: true }); +}); function rows(table: string) { // Compare the original session fields across migrations that add policy columns. @@ -155,6 +164,86 @@ describe('remembered session migration', () => { }); }); +describe('known login device migration', () => { + it('preserves existing authentication data and cascades device removal when an account is deleted', () => { + const upgrade = migrations.find((entry) => entry.name === 'known login devices')!; + runMigrations(handle, migrations.slice(0, upgrade.version - 1)); + handle.exec("UPDATE auth_sessions SET remembered=1 WHERE user_id='admin'"); + const tables = ['auth_users', 'auth_audit', 'auth_rate_limits', 'servers']; + const original = tables.map(rows); + const sessions = handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all(); + + runMigrations(handle, migrations); + + expect(tables.map(rows)).toEqual(original); + expect(handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all()).toEqual( + sessions + ); + expect(rows('auth_login_devices')).toEqual([]); + expect(handle.pragma('foreign_key_list(auth_login_devices)')).toEqual([ + expect.objectContaining({ table: 'auth_users', from: 'user_id', on_delete: 'CASCADE' }) + ]); + expect(handle.pragma('index_info(auth_login_devices_user)')).toEqual([ + expect.objectContaining({ name: 'user_id' }) + ]); + expect(handle.pragma('index_info(auth_login_devices_expires_at)')).toEqual([ + expect.objectContaining({ name: 'expires_at' }) + ]); + handle.exec(` + INSERT INTO auth_login_devices VALUES + ('admin-device-hash','admin',100,3000), + ('operator-device-hash','operator',200,4000); + `); + expect(() => + handle.exec("INSERT INTO auth_login_devices VALUES ('missing-device','missing',0,1)") + ).toThrow('FOREIGN KEY constraint failed'); + expect(() => + handle.exec("INSERT INTO auth_login_devices VALUES ('admin-device-hash','admin',0,1)") + ).toThrow('UNIQUE constraint failed'); + handle.exec("DELETE FROM auth_users WHERE id='operator'"); + expect(rows('auth_login_devices')).toEqual([ + { token_hash: 'admin-device-hash', user_id: 'admin', created_at: 100, expires_at: 3000 } + ]); + expect(handle.prepare('SELECT user_id FROM auth_sessions').all()).toEqual([ + { user_id: 'admin' } + ]); + expect(handle.pragma('foreign_key_check')).toEqual([]); + }); + + it('keeps device hashes and existing authentication data when replayed or reopened', () => { + const upgrade = migrations.find((entry) => entry.name === 'known login devices')!; + runMigrations(handle, migrations); + handle.exec("INSERT INTO auth_login_devices VALUES ('device-hash','admin',100,3000)"); + const tables = ['auth_users', 'auth_audit', 'auth_rate_limits', 'auth_login_devices']; + const original = tables.map(rows); + const sessions = handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all(); + const schemaVersion = handle.pragma('schema_version', { simple: true }); + + migrateLoginDevices(handle); + handle.pragma(`user_version = ${upgrade.version - 1}`); + runMigrations(handle, migrations); + + expect(tables.map(rows)).toEqual(original); + expect(handle.pragma('schema_version', { simple: true })).toBe(schemaVersion); + const directory = mkdtempSync(join(tmpdir(), 'electric-cherry-auth-schema-')); + temporaryDirectories.push(directory); + const databasePath = join(directory, 'auth.sqlite'); + writeFileSync(databasePath, handle.serialize()); + handle.close(); + handle = new Database(databasePath); + handle.pragma('foreign_keys = ON'); + runMigrations(handle, migrations); + + expect(handle.pragma('user_version', { simple: true })).toBe(migrations.length); + expect(handle.pragma('schema_version', { simple: true })).toBe(schemaVersion); + expect(tables.map(rows)).toEqual(original); + expect(handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all()).toEqual( + sessions + ); + expect(handle.pragma('foreign_key_check')).toEqual([]); + }); +}); + describe('moderator role migration', () => { it('preserves all account roles and remembered sessions while extending the role constraint', () => { const upgrade = migrations.find((entry) => entry.name === 'moderator authentication role')!; diff --git a/tests/unit/auth-users.test.ts b/tests/unit/auth-users.test.ts index a550fe6..b5081c9 100644 --- a/tests/unit/auth-users.test.ts +++ b/tests/unit/auth-users.test.ts @@ -1,4 +1,4 @@ -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; // These tests intentionally use production-strength scrypt while the complete @@ -871,6 +871,28 @@ describe('remembered administrator account changes', () => { }); describe('persistent authentication throttling', () => { + it('blocks an exhausted account/address pair without locking out the account at another address', async () => { + const user = seedUser('target', 'viewer'); + const input = { username: user.username.toUpperCase(), password }; + for (let attempt = 0; attempt < 10; attempt++) + auth.consumeAuthAttempt('192.0.2.70', input.username); + // A pre-upgrade account-wide bucket must not continue a global lockout. + database + .db() + .prepare('INSERT INTO auth_rate_limits(key,window_start,attempts) VALUES(?,?,?)') + .run( + createHash('sha256').update(`login:account:${user.username}`).digest('hex'), + Date.now(), + 10 + ); + await expect(auth.authenticatePassword(input, '192.0.2.70')).rejects.toMatchObject({ + status: 429 + }); + await expect(auth.authenticatePassword(input, '192.0.2.71')).resolves.toMatchObject({ + user: { id: user.id } + }); + }); + it.each(['admin', 'super_viewer'] as const)( 'does not consume login or password-change limits for an enabled %s', (role) => { @@ -959,32 +981,39 @@ describe('persistent authentication throttling', () => { 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( + expect(() => auth.consumeAuthAttempt('192.0.2.52', account, kind)).toThrow( 'Too many attempts' ); + if (kind === 'login') + expect(() => auth.consumeAuthAttempt('192.0.2.53', account, kind)).not.toThrow(); + else + 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' + '192.0.2.52' ) ).rejects.toMatchObject({ status: 429 }); }); - it('retains case-insensitive per-account limits across controller database reopen and resets after the window', () => { + it('retains case-insensitive account/address limits across 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'); + expect(() => auth.consumeAuthAttempt('192.0.2.30', 'Admin')).toThrow('Too many attempts'); + expect(() => auth.consumeAuthAttempt('192.0.2.31', 'Admin')).not.toThrow(); 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(); + expect(() => auth.consumeAuthAttempt('192.0.2.30', 'Admin')).not.toThrow(); }); it('limits attempts from one address across many accounts and separates setup from login windows', () => {