This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+87
-31
@@ -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<string>} */
|
||||
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 <administrator-username>');
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
@@ -230,13 +240,14 @@ export async function bootstrapAdmin(
|
||||
}
|
||||
export async function authenticatePassword(
|
||||
input: Record<string, unknown>,
|
||||
clientAddress: string
|
||||
clientAddress: string,
|
||||
loginDevice?: string
|
||||
): Promise<AuthResult> {
|
||||
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)!,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }) });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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')!;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user