Prevent cross-address login lockouts (Refs #63)
CI / checks (push) Successful in 6m30s

This commit is contained in:
2026-10-17 10:55:28 -04:00
parent f3b9699317
commit 4501eba873
14 changed files with 922 additions and 61 deletions
+9
View File
@@ -1,5 +1,14 @@
# Controller URL: use HTTPS with a trusted certificate for enrolled hosts. # Controller URL: use HTTPS with a trusted certificate for enrolled hosts.
EC_PUBLIC_URL=https://electric-cherry.example.com 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 # Generate separate secrets with: openssl rand -hex 32
# Setup token authorizes creation of the first administrator only. Remove it # Setup token authorizes creation of the first administrator only. Remove it
# after setup. Never reuse the telemetry token as a setup or host credential. # after setup. Never reuse the telemetry token as a setup or host credential.
+3
View File
@@ -17,6 +17,9 @@ services:
EC_TELEMETRY_TOKEN: ${EC_TELEMETRY_TOKEN:-} EC_TELEMETRY_TOKEN: ${EC_TELEMETRY_TOKEN:-}
EC_BANK_MAX_BALANCE: ${EC_BANK_MAX_BALANCE:-250000} EC_BANK_MAX_BALANCE: ${EC_BANK_MAX_BALANCE:-250000}
EC_AUTH_ALLOW_HTTP: ${EC_AUTH_ALLOW_HTTP:-false} 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. # Legacy host imports stream whole game files; buffering routes enforce their own caps.
BODY_SIZE_LIMIT: 'Infinity' BODY_SIZE_LIMIT: 'Infinity'
DB_PATH: /data/cherry.db DB_PATH: /data/cherry.db
+41 -8
View File
@@ -113,9 +113,22 @@ revocation every 15 seconds and then close.
Login and password changes for enabled Administrators and Super Viewers are exempt Login and password changes for enabled Administrators and Super Viewers are exempt
from per-account and per-address rate limits, including existing exhausted windows. 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 Their attempts do not consume either limit. Other accounts use persistent
persistent 15-minute windows. Exemptions use the account's current database role; 15-minute login windows of 10 attempts per username-and-address pair and 30 per
password verification is still required. The database stores audit entries for login 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 results, account changes and successful management requests without recording
passwords, tokens or request bodies. Administrators and Super Viewers can review passwords, tokens or request bodies. Administrators and Super Viewers can review
recent entries in Users. 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 Replace `admin` with the existing administrator's username. The command prompts
without echoing the password, resets that enabled administrator only, revokes without echoing the password, resets that enabled administrator only, revokes
their sessions and records the recovery. It does not expose a remote recovery their sessions and trusted login devices, clears their account-specific login and
endpoint or accept passwords as command arguments. 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 ## 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 incoming origins with your trusted origin. Machine endpoints use their own token
checks and do not require a browser Origin. checks and do not require a browser Origin.
Use the adapter's real client-address configuration only with a trusted proxy Login throttling uses the address returned by SvelteKit's adapter. Without
that strips spoofed forwarding headers; login throttling otherwise uses the direct `ADDRESS_HEADER`, this is the direct peer address, so users behind a reverse proxy
peer address. Forwarded host or user headers do not authenticate users. 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 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 hostname (`localhost`, `127.0.0.1` or `::1`). This uses a separate development
+5
View File
@@ -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 precompresses static JavaScript and CSS assets; dynamically generated responses
need compression at the proxy. 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 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 remove `EC_SETUP_TOKEN` from `.env` and run `docker compose up -d` to recreate the
controller. Setup stays closed once an account exists. controller. Setup stays closed once an account exists.
+80 -24
View File
@@ -1,9 +1,13 @@
#!/usr/bin/env node #!/usr/bin/env node
// Local recovery only: requires filesystem/container access to the controller. // Local recovery only: requires filesystem/container access to the controller.
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { createHash } from 'node:crypto';
import { resolve } from 'node:path';
import { emitKeypressEvents } from 'node:readline'; import { emitKeypressEvents } from 'node:readline';
import { fileURLToPath } from 'node:url';
import { hashPassword } from '../src/lib/server/auth/passwords.js'; import { hashPassword } from '../src/lib/server/auth/passwords.js';
/** @param {string} prompt @returns {Promise<string>} */
function secret(prompt) { function secret(prompt) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let value = ''; let value = '';
@@ -17,6 +21,7 @@ function secret(prompt) {
process.stdin.pause(); process.stdin.pause();
process.stdout.write('\n'); process.stdout.write('\n');
}; };
/** @param {string | undefined} text @param {import('node:readline').Key | undefined} key */
const listener = (text, key) => { const listener = (text, key) => {
if (key?.ctrl && key.name === 'c') { if (key?.ctrl && key.name === 'c') {
cleanup(); 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(); const username = process.argv[2]?.toLowerCase();
if (process.argv.length !== 3 || !username || !/^[a-z0-9][a-z0-9_.-]{2,63}$/.test(username)) 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 <administrator-username>'); throw new Error('Usage: node scripts/reset-admin.mjs <administrator-username>');
@@ -51,34 +116,25 @@ try {
fileMustExist: true fileMustExist: true
}); });
database.pragma('busy_timeout=5000'); database.pragma('busy_timeout=5000');
const user = database try {
.prepare("SELECT id FROM auth_users WHERE username=? AND role='admin' AND disabled=0") administrator(database, username);
.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 password = await secret('New administrator password (8–128 characters): ');
const confirmation = await secret('Repeat password: '); const confirmation = await secret('Repeat password: ');
if (password !== confirmation) throw new Error('Passwords did not match.'); if (password !== confirmation) throw new Error('Passwords did not match.');
const passwordHash = await hashPassword(password); await recoverAdministrator(database, username, password);
database console.log(
.transaction(() => { 'Administrator password reset. Sessions and trusted login devices revoked; account rate limits cleared.'
const changed = database );
.prepare( } finally {
"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(); database.close();
console.log('Administrator password reset. Existing sessions have been revoked.'); }
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
await main();
} catch (cause) { } catch (cause) {
console.error(cause instanceof Error ? cause.message : 'Administrator recovery failed.'); console.error(cause instanceof Error ? cause.message : 'Administrator recovery failed.');
process.exitCode = 1; process.exitCode = 1;
} }
}
+12 -1
View File
@@ -1,11 +1,14 @@
import { error, isHttpError, json, type Cookies } from '@sveltejs/kit'; 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 { PasswordServiceBusyError } from './passwords.js';
import type { AuthUser } from '../../auth.js'; import type { AuthUser } from '../../auth.js';
export function sessionCookie(url: URL): string { export function sessionCookie(url: URL): string {
return url.protocol === 'https:' ? SESSION_COOKIE : 'ec_session_dev'; 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 { export function requireUserTransport(url: URL): void {
if (url.protocol === 'https:') return; if (url.protocol === 'https:') return;
if ( if (
@@ -34,6 +37,14 @@ export function sessionResponse(result: AuthResult, cookies: Cookies, url: URL)
sameSite: 'lax', sameSite: 'lax',
expires: new Date(result.session.expiresAt) 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' } }); return json({ user: result.user }, { headers: { 'Cache-Control': 'no-store' } });
} }
export function requireUser(user: AuthUser | null): AuthUser { export function requireUser(user: AuthUser | null): AuthUser {
+53 -11
View File
@@ -7,6 +7,8 @@ export const SESSION_COOKIE = '__Host-ec_session';
export const SESSION_LIFETIME_MS = 12 * 60 * 60_000; export const SESSION_LIFETIME_MS = 12 * 60 * 60_000;
export const SESSION_IDLE_MS = 30 * 60_000; export const SESSION_IDLE_MS = 30 * 60_000;
export const REMEMBERED_SESSION_LIFETIME_MS = 30 * 24 * 60 * 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'); const digest = (value: string) => createHash('sha256').update(value).digest('hex');
function database() { function database() {
return db(); return db();
@@ -111,7 +113,8 @@ export function listAuthAudit() {
export function consumeAuthAttempt( export function consumeAuthAttempt(
clientAddress: string, clientAddress: string,
account: string, account: string,
kind: 'login' | 'setup' | 'password' = 'login' kind: 'login' | 'setup' | 'password' = 'login',
loginDevice?: string
): void { ): void {
// Resolve the current role from storage; setup never has an existing account exemption. // Resolve the current role from storage; setup never has an existing account exemption.
if ( if (
@@ -127,16 +130,22 @@ export function consumeAuthAttempt(
const now = Date.now(); const now = Date.now();
const windowMs = 15 * 60_000; const windowMs = 15 * 60_000;
const ipKey = digest(`${kind}:ip:${clientAddress}`); const ipKey = digest(`${kind}:ip:${clientAddress}`);
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() database()
.transaction(() => { .transaction(() => {
database() database()
.prepare('DELETE FROM auth_rate_limits WHERE window_start < ?') .prepare('DELETE FROM auth_rate_limits WHERE window_start < ?')
.run(now - 24 * 60 * 60_000); .run(now - 24 * 60 * 60_000);
for (const [key, limit] of [ for (const [key, limit] of limits) {
[ipKey, 30],
[accountKey, 10]
] as const) {
const previous = database() const previous = database()
.prepare('SELECT window_start,attempts FROM auth_rate_limits WHERE key=?') .prepare('SELECT window_start,attempts FROM auth_rate_limits WHERE key=?')
.get(key) as { window_start: number; attempts: number } | undefined; .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); throw new AuthError('Too many attempts. Try again in 15 minutes.', 429);
} }
for (const key of [ipKey, accountKey]) for (const [key] of limits)
database() database()
.prepare( .prepare(
`INSERT INTO auth_rate_limits(key,window_start,attempts) VALUES(?,?,1) `INSERT INTO auth_rate_limits(key,window_start,attempts) VALUES(?,?,1)
@@ -189,6 +198,7 @@ function insertUser(input: {
export interface AuthResult { export interface AuthResult {
user: AuthUser; user: AuthUser;
session: { token: string; expiresAt: number }; session: { token: string; expiresAt: number };
loginDevice?: { token: string; expiresAt: number };
} }
export async function bootstrapAdmin( export async function bootstrapAdmin(
input: Record<string, unknown>, input: Record<string, unknown>,
@@ -230,13 +240,14 @@ export async function bootstrapAdmin(
} }
export async function authenticatePassword( export async function authenticatePassword(
input: Record<string, unknown>, input: Record<string, unknown>,
clientAddress: string clientAddress: string,
loginDevice?: string
): Promise<AuthResult> { ): Promise<AuthResult> {
if (input.rememberMe !== undefined && typeof input.rememberMe !== 'boolean') if (input.rememberMe !== undefined && typeof input.rememberMe !== 'boolean')
throw new AuthError('Keep me signed in must be true or false.'); throw new AuthError('Keep me signed in must be true or false.');
const name = const name =
typeof input.username === 'string' ? input.username.slice(0, 64).toLowerCase() : ''; 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 const row = database().prepare('SELECT * FROM auth_users WHERE username=?').get(name) as
UserRow | undefined; UserRow | undefined;
const valid = await verifyPassword(input.password, row?.password_hash ?? null); const valid = await verifyPassword(input.password, row?.password_hash ?? null);
@@ -258,7 +269,8 @@ export async function authenticatePassword(
recordAudit(current.id, 'login', current.id); recordAudit(current.id, 'login', current.id);
return { return {
user: getUser(current.id)!, user: getUser(current.id)!,
session: createSession(current.id, input.rememberMe === true) session: createSession(current.id, input.rememberMe === true),
loginDevice: createLoginDevice(current, loginDevice)
}; };
}) })
.immediate(); .immediate();
@@ -266,6 +278,32 @@ export async function authenticatePassword(
return result; 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( export function createSession(
userId: string, userId: string,
remembered = false remembered = false
@@ -327,6 +365,7 @@ export function revokeUserSessions(actorId: string, userId: string): void {
requireAdministrator(actorId); requireAdministrator(actorId);
if (!userRow(userId)) throw new AuthError('User not found.', 404); 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_sessions WHERE user_id=?').run(userId);
database().prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(userId);
recordAudit(actorId, 'sessions.revoked', userId); recordAudit(actorId, 'sessions.revoked', userId);
})(); })();
} }
@@ -427,8 +466,10 @@ export async function updateUser(
Date.now(), Date.now(),
id 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_sessions WHERE user_id=?').run(id);
database().prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(id);
}
recordAudit(actorId, 'user.updated', id, { recordAudit(actorId, 'user.updated', id, {
role: nextRole, role: nextRole,
disabled: !!disabled, disabled: !!disabled,
@@ -469,6 +510,7 @@ export async function changePassword(
) )
.run(passwordHash, Date.now(), userId); .run(passwordHash, Date.now(), userId);
database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(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); recordAudit(userId, 'password.changed', userId);
return { return {
user: getUser(userId)!, user: getUser(userId)!,
+11
View File
@@ -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. */ /** Run with foreign keys disabled by the migration runner before its transaction. */
export function migrateSuperViewerRole(handle: Database.Database): void { export function migrateSuperViewerRole(handle: Database.Database): void {
const schema = handle const schema = handle
+3 -1
View File
@@ -16,6 +16,7 @@ import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js'
import { migrateHostSchema } from '../hosts/schema.js'; import { migrateHostSchema } from '../hosts/schema.js';
import { import {
migrateAuthSchema, migrateAuthSchema,
migrateLoginDevices,
migrateRememberedSessions, migrateRememberedSessions,
migrateSuperViewerRole, migrateSuperViewerRole,
migrateModeratorRole migrateModeratorRole
@@ -160,7 +161,8 @@ export const migrations: readonly Migration[] = [
'ALTER TABLE moderation_runtimes ADD COLUMN session_sequence INTEGER NOT NULL DEFAULT 0' '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; export const databaseVersion = migrations.length;
+12 -2
View File
@@ -1,9 +1,19 @@
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { authenticatePassword, revokeSession } from '$lib/server/auth/index.js'; 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 }) => { export const POST: RequestHandler = async ({ request, cookies, url, getClientAddress }) => {
try { 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))); revokeSession(cookies.get(sessionCookie(url)));
return sessionResponse(result, cookies, url); return sessionResponse(result, cookies, url);
} catch (cause) { } catch (cause) {
+386
View File
@@ -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 }) });
});
});
+175
View File
@@ -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);
});
});
+90 -1
View File
@@ -1,6 +1,10 @@
import Database from 'better-sqlite3'; 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 { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { import {
migrateLoginDevices,
migrateModeratorRole, migrateModeratorRole,
migrateRememberedSessions, migrateRememberedSessions,
migrateSuperViewerRole migrateSuperViewerRole
@@ -9,6 +13,7 @@ import { runMigrations } from '../../src/lib/server/db/migration-runner.js';
import { migrations } from '../../src/lib/server/db/migrations.js'; import { migrations } from '../../src/lib/server/db/migrations.js';
let handle: Database.Database; let handle: Database.Database;
const temporaryDirectories: string[] = [];
const migration = migrations.find((entry) => entry.name === 'super viewer authentication role')!; const migration = migrations.find((entry) => entry.name === 'super viewer authentication role')!;
beforeEach(() => { 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) { function rows(table: string) {
// Compare the original session fields across migrations that add policy columns. // 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', () => { describe('moderator role migration', () => {
it('preserves all account roles and remembered sessions while extending the role constraint', () => { it('preserves all account roles and remembered sessions while extending the role constraint', () => {
const upgrade = migrations.find((entry) => entry.name === 'moderator authentication role')!; const upgrade = migrations.find((entry) => entry.name === 'moderator authentication role')!;
+34 -5
View File
@@ -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'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
// These tests intentionally use production-strength scrypt while the complete // These tests intentionally use production-strength scrypt while the complete
@@ -871,6 +871,28 @@ describe('remembered administrator account changes', () => {
}); });
describe('persistent authentication throttling', () => { 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)( it.each(['admin', 'super_viewer'] as const)(
'does not consume login or password-change limits for an enabled %s', 'does not consume login or password-change limits for an enabled %s',
(role) => { (role) => {
@@ -959,6 +981,12 @@ describe('persistent authentication throttling', () => {
const account = kind === 'login' ? user.username : user.id; const account = kind === 'login' ? user.username : user.id;
for (let attempt = 0; attempt < 10; attempt++) for (let attempt = 0; attempt < 10; attempt++)
auth.consumeAuthAttempt('192.0.2.52', account, kind); auth.consumeAuthAttempt('192.0.2.52', account, kind);
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( expect(() => auth.consumeAuthAttempt('192.0.2.53', account, kind)).toThrow(
'Too many attempts' 'Too many attempts'
); );
@@ -966,25 +994,26 @@ describe('persistent authentication throttling', () => {
await expect( await expect(
auth.authenticatePassword( auth.authenticatePassword(
{ username: user.username, password, role: 'admin' }, { username: user.username, password, role: 'admin' },
'192.0.2.53' '192.0.2.52'
) )
).rejects.toMatchObject({ status: 429 }); ).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; let now = 2_000_000_000_000;
vi.spyOn(Date, 'now').mockImplementation(() => now); vi.spyOn(Date, 'now').mockImplementation(() => now);
for (let attempt = 0; attempt < 10; attempt++) for (let attempt = 0; attempt < 10; attempt++)
auth.consumeAuthAttempt('192.0.2.30', attempt % 2 ? 'ADMIN' : 'admin'); auth.consumeAuthAttempt('192.0.2.30', attempt % 2 ? 'ADMIN' : 'admin');
database.closeDb(); 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( const persisted = JSON.stringify(
database.db().prepare('SELECT * FROM auth_rate_limits').all() database.db().prepare('SELECT * FROM auth_rate_limits').all()
); );
expect(persisted).not.toContain('192.0.2.30'); expect(persisted).not.toContain('192.0.2.30');
expect(persisted).not.toContain('admin'); expect(persisted).not.toContain('admin');
now += 15 * 60_000; 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', () => { it('limits attempts from one address across many accounts and separates setup from login windows', () => {