Add optional 30-day remembered sessions
CI / checks (push) Successful in 6m29s

This commit is contained in:
2026-10-13 14:42:53 -04:00
parent e85dc58130
commit 82e07ea33e
13 changed files with 677 additions and 72 deletions
+24 -3
View File
@@ -76,9 +76,30 @@ passwords created or reset by administrators must be changed before any fleet
access. Password changes require the current password. access. Password changes require the current password.
Sessions use random tokens in HTTP-only cookies, with only token hashes stored in Sessions use random tokens in HTTP-only cookies, with only token hashes stored in
SQLite. HTTPS uses a Secure `__Host-` cookie. Sessions expire after 12 hours, or SQLite. HTTPS uses a Secure `__Host-` cookie. By default, sessions expire after
30 minutes without authenticated requests; background dashboard polling counts 12 hours, or 30 minutes without authenticated requests; background dashboard
as activity. Login replaces the existing browser session. Logout revokes it. polling counts as activity.
Select **Keep me signed in on this device** at sign-in to stay signed in for
30 days, including between visits from a phone's home-screen app. This option is
unchecked by default and applies only to that browser or home-screen app. Remembered
sessions have no inactivity timeout, but still expire 30 days after sign-in;
activity does not extend that deadline. Use this option only on a device you trust.
Existing sessions keep their original limits after upgrading.
Password changes always require the current password. Remembered administrator
sessions also require the administrator's current password when creating accounts
or editing users, including access changes and password resets. A successful
password change replaces the current session, preserving its remember-device
choice with a fresh lifetime, and revokes all other sessions.
For API clients, `POST /api/auth/login` accepts an optional boolean `rememberMe`
(omitted or `false` keeps the default limits). When using a remembered session,
`POST /api/users` and `PATCH /api/users/:id` require the administrator's password
in `currentPassword`; the `password` field remains the target user's temporary
password. The password-change endpoint preserves the current session's choice.
Login replaces the existing browser session. Logout revokes it.
Changing a password, changing a role, disabling an account, or revoking that Changing a password, changing a role, disabling an account, or revoking that
user's sessions invalidates their existing sessions. Live event streams check user's sessions invalidates their existing sessions. Live event streams check
revocation every 15 seconds and then close. revocation every 15 seconds and then close.
+61 -16
View File
@@ -6,6 +6,7 @@ import { systemUsername, type AuthUser, type UserRole } from '../../auth.js';
export const SESSION_COOKIE = '__Host-ec_session'; 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;
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();
@@ -231,6 +232,8 @@ export async function authenticatePassword(
input: Record<string, unknown>, input: Record<string, unknown>,
clientAddress: string clientAddress: string
): Promise<AuthResult> { ): Promise<AuthResult> {
if (input.rememberMe !== undefined && typeof input.rememberMe !== 'boolean')
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);
@@ -253,54 +256,67 @@ export async function authenticatePassword(
.prepare('UPDATE auth_users SET last_login_at=? WHERE id=?') .prepare('UPDATE auth_users SET last_login_at=? WHERE id=?')
.run(Date.now(), current.id); .run(Date.now(), current.id);
recordAudit(current.id, 'login', current.id); recordAudit(current.id, 'login', current.id);
return { user: getUser(current.id)!, session: createSession(current.id) }; return {
user: getUser(current.id)!,
session: createSession(current.id, input.rememberMe === true)
};
}) })
.immediate(); .immediate();
if (!result) throw new AuthError('Invalid username or password.', 401); if (!result) throw new AuthError('Invalid username or password.', 401);
return result; return result;
} }
export function createSession(userId: string): { token: string; expiresAt: number } { export function createSession(
userId: string,
remembered = false
): { token: string; expiresAt: number } {
const now = Date.now(); const now = Date.now();
const user = userRow(userId); const user = userRow(userId);
if (!user || user.disabled) throw new AuthError('Account is unavailable.', 401); if (!user || user.disabled) throw new AuthError('Account is unavailable.', 401);
const token = randomBytes(32).toString('base64url'); const token = randomBytes(32).toString('base64url');
const expiresAt = now + SESSION_LIFETIME_MS; const expiresAt = now + (remembered ? REMEMBERED_SESSION_LIFETIME_MS : SESSION_LIFETIME_MS);
database() database()
.prepare('DELETE FROM auth_sessions WHERE expires_at <= ? OR last_seen_at <= ?') .prepare(
'DELETE FROM auth_sessions WHERE expires_at <= ? OR (remembered=0 AND last_seen_at <= ?)'
)
.run(now, now - SESSION_IDLE_MS); .run(now, now - SESSION_IDLE_MS);
database() database()
.prepare( .prepare(
'INSERT INTO auth_sessions(token_hash,user_id,created_at,expires_at,last_seen_at) VALUES(?,?,?,?,?)' 'INSERT INTO auth_sessions(token_hash,user_id,created_at,expires_at,last_seen_at,remembered) VALUES(?,?,?,?,?,?)'
) )
.run(digest(token), userId, now, expiresAt, now); .run(digest(token), userId, now, expiresAt, now, Number(remembered));
return { token, expiresAt }; return { token, expiresAt };
} }
export function readSession( export function readSession(
token: string | undefined, token: string | undefined,
touch = true touch = true
): { user: AuthUser; tokenHash: string; expiresAt: number } | null { ): { user: AuthUser; tokenHash: string; expiresAt: number; remembered: boolean } | null {
if (!token || !/^[A-Za-z0-9_-]{43}$/.test(token)) return null; if (!token || !/^[A-Za-z0-9_-]{43}$/.test(token)) return null;
return readSessionHash(digest(token), touch); return readSessionHash(digest(token), touch);
} }
export function readSessionHash( export function readSessionHash(
tokenHash: string, tokenHash: string,
touch = false touch = false
): { user: AuthUser; tokenHash: string; expiresAt: number } | null { ): { user: AuthUser; tokenHash: string; expiresAt: number; remembered: boolean } | null {
const now = Date.now(); const now = Date.now();
const row = database() const row = database()
.prepare( .prepare(
`SELECT u.*,s.expires_at,s.last_seen_at FROM auth_sessions s JOIN auth_users u ON u.id=s.user_id `SELECT u.*,s.expires_at,s.last_seen_at,s.remembered FROM auth_sessions s JOIN auth_users u ON u.id=s.user_id
WHERE s.token_hash=? AND s.expires_at>? AND s.last_seen_at>? AND u.disabled=0` WHERE s.token_hash=? AND s.expires_at>? AND (s.remembered=1 OR s.last_seen_at>?) AND u.disabled=0`
) )
.get(tokenHash, now, now - SESSION_IDLE_MS) as .get(tokenHash, now, now - SESSION_IDLE_MS) as
(UserRow & { expires_at: number; last_seen_at: number }) | undefined; (UserRow & { expires_at: number; last_seen_at: number; remembered: number }) | undefined;
if (!row) return null; if (!row) return null;
if (touch && row.last_seen_at < now - 60_000) if (touch && row.last_seen_at < now - 60_000)
database() database()
.prepare('UPDATE auth_sessions SET last_seen_at=? WHERE token_hash=?') .prepare('UPDATE auth_sessions SET last_seen_at=? WHERE token_hash=?')
.run(now, tokenHash); .run(now, tokenHash);
return { user: present(row), tokenHash, expiresAt: row.expires_at }; return {
user: present(row),
tokenHash,
expiresAt: row.expires_at,
remembered: !!row.remembered
};
} }
export function revokeSession(token: string | undefined): void { export function revokeSession(token: string | undefined): void {
if (token) if (token)
@@ -319,11 +335,30 @@ function requireAdministrator(actorId: string) {
if (!actor || actor.disabled || actor.role !== 'admin' || actor.must_change_password) if (!actor || actor.disabled || actor.role !== 'admin' || actor.must_change_password)
throw new AuthError('Administrator access is required.', 403); throw new AuthError('Administrator access is required.', 403);
} }
type AccountSession = { tokenHash: string; clientAddress: string };
function requireSessionUser(userId: string, tokenHash: string) {
const session = readSessionHash(tokenHash);
if (!session || session.user.id !== userId) throw new AuthError('Sign in to continue.', 401);
return session;
}
async function confirmAccountPassword(
actorId: string,
currentPassword: unknown,
session: AccountSession
): Promise<void> {
if (!requireSessionUser(actorId, session.tokenHash).remembered) return;
consumeAuthAttempt(session.clientAddress, actorId, 'password');
if (!(await verifyPassword(currentPassword, userRow(actorId)?.password_hash ?? null)))
throw new AuthError('Enter your current password to confirm account changes.', 403);
requireSessionUser(actorId, session.tokenHash);
}
export async function createUser( export async function createUser(
actorId: string, actorId: string,
input: Record<string, unknown> input: Record<string, unknown>,
session?: AccountSession
): Promise<AuthUser> { ): Promise<AuthUser> {
requireAdministrator(actorId); requireAdministrator(actorId);
if (session) await confirmAccountPassword(actorId, input.currentPassword, session);
const name = username(input.username), const name = username(input.username),
label = displayName(input.displayName, name), label = displayName(input.displayName, name),
userRole = role(input.role); userRole = role(input.role);
@@ -331,6 +366,7 @@ export async function createUser(
return database() return database()
.transaction(() => { .transaction(() => {
requireAdministrator(actorId); requireAdministrator(actorId);
if (session) requireSessionUser(actorId, session.tokenHash);
const user = insertUser({ const user = insertUser({
username: name, username: name,
displayName: label, displayName: label,
@@ -346,9 +382,11 @@ export async function createUser(
export async function updateUser( export async function updateUser(
actorId: string, actorId: string,
id: string, id: string,
input: Record<string, unknown> input: Record<string, unknown>,
session?: AccountSession
): Promise<AuthUser> { ): Promise<AuthUser> {
requireAdministrator(actorId); requireAdministrator(actorId);
if (session) await confirmAccountPassword(actorId, input.currentPassword, session);
if (input.disabled !== undefined && typeof input.disabled !== 'boolean') if (input.disabled !== undefined && typeof input.disabled !== 'boolean')
throw new AuthError('Disabled must be true or false.'); throw new AuthError('Disabled must be true or false.');
if (input.password !== undefined && actorId === id) if (input.password !== undefined && actorId === id)
@@ -357,6 +395,7 @@ export async function updateUser(
return database() return database()
.transaction(() => { .transaction(() => {
requireAdministrator(actorId); requireAdministrator(actorId);
if (session) requireSessionUser(actorId, session.tokenHash);
const current = userRow(id); const current = userRow(id);
if (!current) throw new AuthError('User not found.', 404); if (!current) throw new AuthError('User not found.', 404);
const nextRole = input.role === undefined ? current.role : role(input.role); const nextRole = input.role === undefined ? current.role : role(input.role);
@@ -402,7 +441,8 @@ export async function updateUser(
export async function changePassword( export async function changePassword(
userId: string, userId: string,
currentPassword: unknown, currentPassword: unknown,
newPassword: unknown newPassword: unknown,
sessionHash?: string
): Promise<AuthResult> { ): Promise<AuthResult> {
const original = userRow(userId); const original = userRow(userId);
if ( if (
@@ -415,6 +455,8 @@ export async function changePassword(
const passwordHash = await hashPassword(newPassword); const passwordHash = await hashPassword(newPassword);
return database() return database()
.transaction(() => { .transaction(() => {
const session =
sessionHash === undefined ? null : requireSessionUser(userId, sessionHash);
const current = userRow(userId); const current = userRow(userId);
if (!current || current.disabled || current.password_hash !== original.password_hash) if (!current || current.disabled || current.password_hash !== original.password_hash)
throw new AuthError( throw new AuthError(
@@ -428,7 +470,10 @@ 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);
recordAudit(userId, 'password.changed', userId); recordAudit(userId, 'password.changed', userId);
return { user: getUser(userId)!, session: createSession(userId) }; return {
user: getUser(userId)!,
session: createSession(userId, session?.remembered ?? false)
};
}) })
.immediate(); .immediate();
} }
+9
View File
@@ -24,6 +24,15 @@ export function migrateAuthSchema(handle: Database.Database): void {
`); `);
} }
export function migrateRememberedSessions(handle: Database.Database): void {
const columns = handle.pragma('table_info(auth_sessions)') as { name: string }[];
if (columns.some((column) => column.name === 'remembered')) return;
handle.exec(`
ALTER TABLE auth_sessions ADD COLUMN remembered INTEGER NOT NULL DEFAULT 0
CHECK(remembered IN (0,1));
`);
}
/** 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
+7 -2
View File
@@ -14,7 +14,11 @@ import {
} from '../reports/revisions.js'; } from '../reports/revisions.js';
import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js'; import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js';
import { migrateHostSchema } from '../hosts/schema.js'; import { migrateHostSchema } from '../hosts/schema.js';
import { migrateAuthSchema, migrateSuperViewerRole } from '../auth/schema.js'; import {
migrateAuthSchema,
migrateRememberedSessions,
migrateSuperViewerRole
} from '../auth/schema.js';
import { migrateGameFileSchema } from '../game-files/schema.js'; import { migrateGameFileSchema } from '../game-files/schema.js';
import { migrateLeaderboardReports } from './leaderboard-reports.js'; import { migrateLeaderboardReports } from './leaderboard-reports.js';
import { migratePlayerScoreTotal } from './player-score-total.js'; import { migratePlayerScoreTotal } from './player-score-total.js';
@@ -113,7 +117,8 @@ export const migrations: readonly Migration[] = [
run: migratePlayerScoreTotal run: migratePlayerScoreTotal
}, },
{ version: 20, name: 'persistent player bank and transfer ledger', run: migrateBank }, { version: 20, name: 'persistent player bank and transfer ledger', run: migrateBank },
{ version: 21, name: 'instance-owned bank credentials', run: migrateBankCredentials } { version: 21, name: 'instance-owned bank credentials', run: migrateBankCredentials },
{ version: 22, name: 'remembered browser sessions', run: migrateRememberedSessions }
]; ];
export const databaseVersion = migrations.length; export const databaseVersion = migrations.length;
+6 -1
View File
@@ -7,7 +7,12 @@ export const POST: RequestHandler = async ({ request, cookies, locals, url, getC
consumeAuthAttempt(getClientAddress(), user.id, 'password'); consumeAuthAttempt(getClientAddress(), user.id, 'password');
const body = await authBody(request); const body = await authBody(request);
return sessionResponse( return sessionResponse(
await changePassword(user.id, body.currentPassword, body.newPassword), await changePassword(
user.id,
body.currentPassword,
body.newPassword,
locals.sessionHash ?? ''
),
cookies, cookies,
url url
); );
+7 -2
View File
@@ -2,10 +2,15 @@ import { json, type RequestHandler } from '@sveltejs/kit';
import { createUser, listUsers } from '$lib/server/auth/index.js'; import { createUser, listUsers } from '$lib/server/auth/index.js';
import { authBody, authError, requireUser } from '$lib/server/auth/http.js'; import { authBody, authError, requireUser } from '$lib/server/auth/http.js';
export const GET: RequestHandler = () => json({ users: listUsers() }); export const GET: RequestHandler = () => json({ users: listUsers() });
export const POST: RequestHandler = async ({ request, locals }) => { export const POST: RequestHandler = async ({ request, locals, getClientAddress }) => {
try { try {
return json( return json(
{ user: await createUser(requireUser(locals.user).id, await authBody(request)) }, {
user: await createUser(requireUser(locals.user).id, await authBody(request), {
tokenHash: locals.sessionHash ?? '',
clientAddress: getClientAddress()
})
},
{ status: 201 } { status: 201 }
); );
} catch (cause) { } catch (cause) {
+10 -2
View File
@@ -2,10 +2,18 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { updateUser } from '$lib/server/auth/index.js'; import { updateUser } from '$lib/server/auth/index.js';
import { authBody, authError, requireUser } from '$lib/server/auth/http.js'; import { authBody, authError, requireUser } from '$lib/server/auth/http.js';
export const PATCH: RequestHandler = async ({ request, params, locals }) => { export const PATCH: RequestHandler = async ({ request, params, locals, getClientAddress }) => {
try { try {
return json({ return json({
user: await updateUser(requireUser(locals.user).id, params.id, await authBody(request)) user: await updateUser(
requireUser(locals.user).id,
params.id,
await authBody(request),
{
tokenHash: locals.sessionHash ?? '',
clientAddress: getClientAddress()
}
)
}); });
} catch (cause) { } catch (cause) {
return authError(cause); return authError(cause);
+35 -1
View File
@@ -6,6 +6,7 @@
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
let username = $state(''); let username = $state('');
let password = $state(''); let password = $state('');
let rememberMe = $state(false);
let busy = $state(false); let busy = $state(false);
let error = $state(''); let error = $state('');
function destination(): string { function destination(): string {
@@ -28,7 +29,7 @@
const response = await fetch('/api/auth/login', { const response = await fetch('/api/auth/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }) body: JSON.stringify({ username, password, rememberMe })
}); });
const body = await response.json(); const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not sign in. Try again.'); if (!response.ok) throw new Error(body.error ?? 'Could not sign in. Try again.');
@@ -76,6 +77,20 @@
bind:value={password} bind:value={password}
/></Field /></Field
> >
<div class="remember-device">
<label>
<input
type="checkbox"
name="rememberMe"
bind:checked={rememberMe}
aria-describedby="remember-help"
/>
Keep me signed in on this device
</label>
<p id="remember-help">
Stay signed in for 30 days between visits. Use only on a device you trust.
</p>
</div>
<Button type="submit" variant="primary" loading={busy} disabled={busy}>Sign in</Button> <Button type="submit" variant="primary" loading={busy} disabled={busy}>Sign in</Button>
</fieldset> </fieldset>
</form> </form>
@@ -111,6 +126,25 @@
font-size: var(--text-sm); font-size: var(--text-sm);
line-height: 1.6; line-height: 1.6;
} }
.remember-device label {
display: flex;
align-items: center;
gap: 10px;
min-height: 44px;
font-size: var(--text-sm);
}
.remember-device input {
width: 18px;
height: 18px;
flex-shrink: 0;
accent-color: var(--accent-primary);
}
.remember-device p {
margin: 4px 0 0;
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 1.6;
}
.help { .help {
margin: 24px 0 0; margin: 24px 0 0;
} }
@@ -0,0 +1,6 @@
import { readSessionHash } from '$lib/server/auth/index.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ locals }) => ({
rememberedSession: !!(locals.sessionHash && readSessionHash(locals.sessionHash)?.remembered)
});
+33 -1
View File
@@ -23,6 +23,7 @@
let username = $state(''); let username = $state('');
let displayName = $state(''); let displayName = $state('');
let password = $state(''); let password = $state('');
let currentPassword = $state('');
let role = $state<UserRole>('viewer'); let role = $state<UserRole>('viewer');
let enabled = $state(true); let enabled = $state(true);
let showPassword = $state(false); let showPassword = $state(false);
@@ -62,7 +63,11 @@
]; ];
const roleDescription = $derived(roles.find((item) => item.value === role)?.description); const roleDescription = $derived(roles.find((item) => item.value === role)?.description);
const administrator = $derived($page.data.user?.role === 'admin'); const administrator = $derived($page.data.user?.role === 'admin');
const rememberedSession = $derived($page.data.rememberedSession);
const canViewUsers = $derived(administrator || $page.data.user?.role === 'super_viewer'); const canViewUsers = $derived(administrator || $page.data.user?.role === 'super_viewer');
$effect(() => {
if (!createOpen && !editOpen) currentPassword = '';
});
async function load() { async function load() {
loading = true; loading = true;
@@ -118,6 +123,7 @@
}); });
function openCreate() { function openCreate() {
if (!administrator) return; if (!administrator) return;
currentPassword = '';
username = ''; username = '';
displayName = ''; displayName = '';
password = ''; password = '';
@@ -130,6 +136,7 @@
} }
function openEdit(user: AuthUser) { function openEdit(user: AuthUser) {
if (!administrator) return; if (!administrator) return;
currentPassword = '';
editing = user; editing = user;
username = user.username; username = user.username;
displayName = user.displayName; displayName = user.displayName;
@@ -151,7 +158,7 @@
const response = await fetch('/api/users', { const response = await fetch('/api/users', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, displayName, password, role }) body: JSON.stringify({ username, displayName, password, role, currentPassword })
}); });
const body = await response.json(); const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not create this user.'); if (!response.ok) throw new Error(body.error ?? 'Could not create this user.');
@@ -162,6 +169,7 @@
} catch (cause) { } catch (cause) {
formError = (cause as Error).message; formError = (cause as Error).message;
} finally { } finally {
currentPassword = '';
busy = false; busy = false;
} }
} }
@@ -183,6 +191,7 @@
displayName, displayName,
role, role,
disabled: !enabled, disabled: !enabled,
currentPassword,
...(password ? { password } : {}) ...(password ? { password } : {})
}) })
}); });
@@ -200,6 +209,7 @@
} catch (cause) { } catch (cause) {
formError = (cause as Error).message; formError = (cause as Error).message;
} finally { } finally {
currentPassword = '';
busy = false; busy = false;
} }
} }
@@ -370,6 +380,26 @@
{/if} {/if}
</section> </section>
{#snippet accountPassword(id: string)}
{#if rememberedSession}
<Field forId={id} label="Your current password">
<Input
{id}
name="currentPassword"
type="password"
autocomplete="current-password"
required
maxlength={128}
bind:value={currentPassword}
aria-describedby={`${id}-help`}
/>
</Field>
<p class="hint" id={`${id}-help`}>
Confirm your password to make account changes on this remembered device.
</p>
{/if}
{/snippet}
{#if administrator}<Dialog {#if administrator}<Dialog
bind:open={createOpen} bind:open={createOpen}
title="Add user" title="Add user"
@@ -439,6 +469,7 @@
<label class="checkbox" <label class="checkbox"
><input type="checkbox" bind:checked={showPassword} />Show temporary password</label ><input type="checkbox" bind:checked={showPassword} />Show temporary password</label
> >
{@render accountPassword('create-current-password')}
<div class="form-actions"> <div class="form-actions">
<Button <Button
disabled={busy} disabled={busy}
@@ -518,6 +549,7 @@
><input type="checkbox" bind:checked={showPassword} />Show temporary ><input type="checkbox" bind:checked={showPassword} />Show temporary
password</label password</label
>{/if} >{/if}
{@render accountPassword('edit-current-password')}
<div class="form-actions"> <div class="form-actions">
<Button <Button
disabled={busy} disabled={busy}
+119
View File
@@ -0,0 +1,119 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { flushSync, mount, unmount } from 'svelte';
import { writable } from 'svelte/store';
import LoginPage from '../../src/routes/login/+page.svelte';
import UsersPage from '../../src/routes/settings/users/+page.svelte';
const page = writable({
url: new URL('https://ec.test/login?next=/hosts'),
data: {
user: { id: 'admin', username: 'administrator', role: 'admin' },
rememberedSession: false,
setupRequired: false
}
});
vi.mock('$app/stores', () => ({
page: { subscribe: (run: Parameters<typeof page.subscribe>[0]) => page.subscribe(run) }
}));
vi.mock('$app/navigation', () => ({ invalidateAll: vi.fn() }));
let component: ReturnType<typeof mount> | undefined;
beforeEach(() => {
page.update((value) => ({ ...value, data: { ...value.data, rememberedSession: false } }));
});
afterEach(async () => {
if (component) await unmount(component);
component = undefined;
document.body.replaceChildren();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
function enter(selector: string, value: string) {
const input = document.querySelector<HTMLInputElement>(selector)!;
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
flushSync();
}
it.each([false, true])(
'leaves remembering unchecked by default and submits the selected choice: %s',
async (rememberMe) => {
const fetch = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ error: 'Try again' }), { status: 401 })
);
vi.stubGlobal('fetch', fetch);
component = mount(LoginPage, { target: document.body });
flushSync();
const checkbox = document.querySelector<HTMLInputElement>('input[name="rememberMe"]')!;
expect(checkbox.checked).toBe(false);
expect(document.body.textContent).toContain('30 days');
enter('#username', 'administrator');
enter('#password', 'my current password');
checkbox.checked = rememberMe;
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
flushSync();
document
.querySelector('form')!
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce());
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
username: 'administrator',
password: 'my current password',
rememberMe
});
await vi.waitFor(() => expect(document.body.textContent).toContain('Try again'));
expect(document.querySelector<HTMLInputElement>('#password')!.value).toBe('');
expect(checkbox.checked).toBe(rememberMe);
}
);
it.each([false, true])(
'asks remembered administrators for their own password before creating an account: %s',
async (rememberedSession) => {
page.update((value) => ({ ...value, data: { ...value.data, rememberedSession } }));
const fetch = vi.fn(async (path: string, options?: RequestInit) => {
if (options?.method === 'POST')
return new Response(JSON.stringify({ error: 'Could not create user' }), {
status: 403
});
return new Response(
JSON.stringify(path.endsWith('/audit') ? { events: [] } : { users: [] })
);
});
vi.stubGlobal('fetch', fetch);
component = mount(UsersPage, { target: document.body });
flushSync();
const add = Array.from(document.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Add user'
)!;
add.click();
flushSync();
const confirmation = document.querySelector<HTMLInputElement>('#create-current-password');
expect(!!confirmation).toBe(rememberedSession);
if (rememberedSession) {
expect(confirmation!.required).toBe(true);
enter('#create-current-password', 'admin secret');
}
enter('#new-username', 'viewer');
enter('#new-name', 'Viewer');
enter('#temporary-password', 'temporary secret');
document
.querySelector('form')!
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
await vi.waitFor(() =>
expect(fetch.mock.calls.some(([, options]) => options?.method === 'POST')).toBe(true)
);
const [, options] = fetch.mock.calls.find(([, options]) => options?.method === 'POST')!;
expect(JSON.parse(String(options!.body))).toMatchObject({
password: 'temporary secret',
currentPassword: rememberedSession ? 'admin secret' : ''
});
await vi.waitFor(() =>
expect(document.body.textContent).toContain('Could not create user')
);
if (confirmation) expect(confirmation.value).toBe('');
}
);
+38 -2
View File
@@ -1,6 +1,9 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { migrateSuperViewerRole } from '../../src/lib/server/auth/schema.js'; import {
migrateRememberedSessions,
migrateSuperViewerRole
} from '../../src/lib/server/auth/schema.js';
import { runMigrations } from '../../src/lib/server/db/migration-runner.js'; 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';
@@ -29,7 +32,10 @@ beforeEach(() => {
afterEach(() => handle.close()); afterEach(() => handle.close());
function rows(table: string) { function rows(table: string) {
return handle.prepare(`SELECT * FROM ${table} ORDER BY rowid`).all(); // Compare the original session fields across migrations that add policy columns.
const columns =
table === 'auth_sessions' ? 'token_hash,user_id,created_at,expires_at,last_seen_at' : '*';
return handle.prepare(`SELECT ${columns} FROM ${table} ORDER BY rowid`).all();
} }
describe('super viewer role migration', () => { describe('super viewer role migration', () => {
@@ -117,3 +123,33 @@ describe('super viewer role migration', () => {
expect(handle.pragma('foreign_key_check')).toEqual([]); expect(handle.pragma('foreign_key_check')).toEqual([]);
}); });
}); });
describe('remembered session migration', () => {
it('preserves existing sessions as short sessions and safely replays without clearing remembered devices', () => {
const upgrade = migrations.find((entry) => entry.name === 'remembered browser sessions')!;
runMigrations(handle, migrations.slice(0, upgrade.version - 1));
const sessions = rows('auth_sessions');
const users = rows('auth_users');
runMigrations(handle, migrations);
expect(rows('auth_sessions')).toEqual(sessions);
expect(rows('auth_users')).toEqual(users);
expect(handle.prepare('SELECT remembered FROM auth_sessions').all()).toEqual([
{ remembered: 0 },
{ remembered: 0 }
]);
expect(() => handle.exec('UPDATE auth_sessions SET remembered=2')).toThrow(
'CHECK constraint failed'
);
handle.exec("UPDATE auth_sessions SET remembered=1 WHERE user_id='admin'");
const remembered = handle.prepare('SELECT * FROM auth_sessions').all();
const schemaVersion = handle.pragma('schema_version', { simple: true });
migrateRememberedSessions(handle);
handle.pragma(`user_version = ${upgrade.version - 1}`);
runMigrations(handle, migrations);
expect(handle.prepare('SELECT * FROM auth_sessions').all()).toEqual(remembered);
expect(handle.pragma('schema_version', { simple: true })).toBe(schemaVersion);
expect(handle.pragma('foreign_key_check')).toEqual([]);
});
});
+322 -42
View File
@@ -347,51 +347,150 @@ describe('administrator user management', () => {
expect(auth.getUser(second.id)?.role).toBe('admin'); expect(auth.getUser(second.id)?.role).toBe('admin');
}); });
it('revokes every target session on role, enabled-state and password changes while keeping other users signed in', async () => { it.each([false, true])(
const admin = seedUser(), 'revokes every target session on role, enabled-state and password changes (remembered=%s)',
target = seedUser('target', 'viewer'); async (remembered) => {
const administratorSession = auth.createSession(admin.id); const admin = seedUser(),
const first = auth.createSession(target.id), target = seedUser('target', 'viewer');
second = auth.createSession(target.id); const administratorSession = auth.createSession(admin.id);
await auth.updateUser(admin.id, target.id, { role: 'super_viewer' }); const first = auth.createSession(target.id, remembered),
expect(auth.getUser(target.id)?.role).toBe('super_viewer'); second = auth.createSession(target.id);
expect(auth.readSession(first.token)).toBeNull(); await auth.updateUser(admin.id, target.id, { role: 'super_viewer' });
expect(auth.readSession(second.token)).toBeNull(); expect(auth.getUser(target.id)?.role).toBe('super_viewer');
const third = auth.createSession(target.id); expect(auth.readSession(first.token)).toBeNull();
await auth.updateUser(admin.id, target.id, { disabled: true }); expect(auth.readSession(second.token)).toBeNull();
expect(auth.readSession(third.token)).toBeNull(); const third = auth.createSession(target.id, remembered);
expect(() => auth.createSession(target.id)).toThrow('unavailable'); await auth.updateUser(admin.id, target.id, { disabled: true });
await auth.updateUser(admin.id, target.id, { disabled: false }); expect(auth.readSession(third.token)).toBeNull();
expect(auth.readSession(third.token)).toBeNull(); expect(() => auth.createSession(target.id)).toThrow('unavailable');
const fourth = auth.createSession(target.id); await auth.updateUser(admin.id, target.id, { disabled: false });
const reset = await auth.updateUser(admin.id, target.id, { password: replacement }); expect(auth.readSession(third.token)).toBeNull();
expect(reset.mustChangePassword).toBe(true); const fourth = auth.createSession(target.id, remembered);
expect(auth.readSession(fourth.token)).toBeNull(); const reset = await auth.updateUser(admin.id, target.id, { password: replacement });
expect(auth.readSession(administratorSession.token)?.user.id).toBe(admin.id); expect(reset.mustChangePassword).toBe(true);
expect(storedUser(target.id).password_hash).not.toBe(passwordHash); expect(auth.readSession(fourth.token)).toBeNull();
await expect( expect(auth.readSession(administratorSession.token)?.user.id).toBe(admin.id);
auth.updateUser(admin.id, admin.id, { password: replacement }) expect(storedUser(target.id).password_hash).not.toBe(passwordHash);
).rejects.toThrow('current password'); await expect(
}); auth.updateUser(admin.id, admin.id, { password: replacement })
).rejects.toThrow('current password');
}
);
it('keeps sessions for display-only edits and supports explicit per-user and individual revocation', async () => { it.each([false, true])(
const admin = seedUser(), 'keeps sessions for display-only edits and supports per-user and individual revocation (remembered=%s)',
target = seedUser('target', 'viewer'); async (remembered) => {
const one = auth.createSession(target.id), const admin = seedUser(),
two = auth.createSession(target.id), target = seedUser('target', 'viewer');
adminSession = auth.createSession(admin.id); const one = auth.createSession(target.id, remembered),
await auth.updateUser(admin.id, target.id, { displayName: 'New display name' }); two = auth.createSession(target.id, remembered),
expect(auth.readSession(one.token)?.user.displayName).toBe('New display name'); adminSession = auth.createSession(admin.id);
auth.revokeSession(one.token); await auth.updateUser(admin.id, target.id, { displayName: 'New display name' });
expect(auth.readSession(one.token)).toBeNull(); expect(auth.readSession(one.token)?.user.displayName).toBe('New display name');
expect(auth.readSession(two.token)).not.toBeNull(); auth.revokeSession(one.token);
auth.revokeUserSessions(admin.id, target.id); expect(auth.readSession(one.token)).toBeNull();
expect(auth.readSession(two.token)).toBeNull(); expect(auth.readSession(two.token)).not.toBeNull();
expect(auth.readSession(adminSession.token)).not.toBeNull(); auth.revokeUserSessions(admin.id, target.id);
}); expect(auth.readSession(two.token)).toBeNull();
expect(auth.readSession(adminSession.token)).not.toBeNull();
}
);
}); });
describe('session secrecy and expiration', () => { describe('session secrecy and expiration', () => {
it('signs out a remembered device by deleting its cookie and server session', async () => {
const user = seedUser();
const session = auth.createSession(user.id, true);
const otherDevice = auth.createSession(user.id, true);
const { POST } = await import('../../src/routes/api/auth/logout/+server.js');
const url = new URL('https://ec.test/api/auth/logout');
const cookies = { get: vi.fn(() => session.token), delete: vi.fn() };
const response = await POST({ cookies, url, locals: { user } } as unknown as Parameters<
typeof POST
>[0]);
expect(response.status).toBe(200);
expect(cookies.delete).toHaveBeenCalledWith('__Host-ec_session', { path: '/' });
expect(auth.readSession(session.token)).toBeNull();
expect(auth.readSession(otherDevice.token)).not.toBeNull();
});
it.each([undefined, false, true])(
'requires explicit opt-in at login and sets the cookie lifetime (rememberMe=%s)',
async (rememberMe) => {
const now = 2_000_000_000_000;
vi.spyOn(Date, 'now').mockReturnValue(now);
const user = seedUser();
const previous = auth.createSession(user.id, true);
const { POST } = await import('../../src/routes/api/auth/login/+server.js');
const url = new URL('https://ec.test/api/auth/login');
const cookies = { get: vi.fn(() => previous.token), set: vi.fn() };
const response = await POST({
request: new Request(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user.username, password, rememberMe })
}),
cookies,
url,
getClientAddress: () => '192.0.2.60'
} as unknown as Parameters<typeof POST>[0]);
expect(response.status).toBe(200);
const [name, token, options] = cookies.set.mock.calls[0];
const lifetime = rememberMe ? 30 * 24 * 60 * 60_000 : 12 * 60 * 60_000;
expect(name).toBe('__Host-ec_session');
expect(options).toEqual({
path: '/',
httpOnly: true,
secure: true,
sameSite: 'lax',
expires: new Date(now + lifetime)
});
expect(auth.readSession(token)).toMatchObject({
remembered: rememberMe === true,
expiresAt: now + lifetime
});
expect(auth.readSession(previous.token)).toBeNull();
}
);
it.each(['true', 'false', 1, null, {}])(
'rejects non-boolean remember-device choices: %j',
async (rememberMe) => {
const user = seedUser();
await expect(
auth.authenticatePassword(
{ username: user.username, password, rememberMe },
'192.0.2.60'
)
).rejects.toMatchObject({ status: 400 });
expect(database.db().prepare('SELECT * FROM auth_sessions').all()).toEqual([]);
}
);
it('keeps remembered sessions across inactivity, other logins and restarts, but never beyond 30 days', () => {
let now = 2_000_000_000_000;
vi.spyOn(Date, 'now').mockImplementation(() => now);
const user = seedUser();
const remembered = auth.createSession(user.id, true);
const short = auth.createSession(user.id);
now += 2 * 24 * 60 * 60_000;
auth.createSession(user.id); // Expiry cleanup must retain inactive remembered devices.
database.closeDb();
expect(auth.readSession(short.token)).toBeNull();
expect(auth.readSession(remembered.token)).toMatchObject({
user: { id: user.id },
remembered: true
});
now = remembered.expiresAt - 1;
expect(auth.readSession(remembered.token)?.expiresAt).toBe(remembered.expiresAt);
now++;
expect(auth.readSession(remembered.token)).toBeNull();
auth.createSession(user.id);
expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({
count: 1
});
});
it('stores only opaque session hashes, never raw cookies or password hashes in public identities', () => { it('stores only opaque session hashes, never raw cookies or password hashes in public identities', () => {
const user = seedUser(); const user = seedUser();
const first = auth.createSession(user.id), const first = auth.createSession(user.id),
@@ -449,6 +548,52 @@ describe('session secrecy and expiration', () => {
}); });
describe('forced password changes and asynchronous authorization races', () => { describe('forced password changes and asynchronous authorization races', () => {
it.each([false, true])(
'preserves this device choice through a required password change and revokes all old sessions (remembered=%s)',
async (remembered) => {
let now = 2_000_000_000_000;
vi.spyOn(Date, 'now').mockImplementation(() => now);
const user = seedUser('newuser', 'viewer', { mustChangePassword: true });
const login = await auth.authenticatePassword(
{ username: user.username, password, rememberMe: remembered },
'192.0.2.61'
);
const other = auth.createSession(user.id, true);
const tokenHash = auth.readSession(login.session.token)!.tokenHash;
const { POST } = await import('../../src/routes/api/auth/password/+server.js');
const url = new URL('https://ec.test/api/auth/password');
const cookies = { set: vi.fn() };
const response = await POST({
request: new Request(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// The server must preserve the stored policy, ignoring a forged client choice.
body: JSON.stringify({
currentPassword: password,
newPassword: replacement,
rememberMe: !remembered
})
}),
cookies,
url,
locals: { user, sessionHash: tokenHash },
getClientAddress: () => '192.0.2.61'
} as unknown as Parameters<typeof POST>[0]);
expect(response.status).toBe(200);
const [, token, options] = cookies.set.mock.calls[0];
const lifetime = remembered ? 30 * 24 * 60 * 60_000 : 12 * 60 * 60_000;
expect(options.expires).toEqual(new Date(now + lifetime));
expect(auth.readSession(login.session.token)).toBeNull();
expect(auth.readSession(other.token)).toBeNull();
expect(auth.readSession(token)).toMatchObject({
remembered,
user: { mustChangePassword: false }
});
now += 24 * 60 * 60_000;
expect(!!auth.readSession(token)).toBe(remembered);
}
);
it('requires the current password, replaces old sessions atomically and clears the forced-change state', async () => { it('requires the current password, replaces old sessions atomically and clears the forced-change state', async () => {
const user = seedUser('newuser', 'viewer', { mustChangePassword: true }); const user = seedUser('newuser', 'viewer', { mustChangePassword: true });
const login = await auth.authenticatePassword( const login = await auth.authenticatePassword(
@@ -577,6 +722,138 @@ describe('forced password changes and asynchronous authorization races', () => {
}); });
}); });
describe('remembered administrator account changes', () => {
it.each([false, true])(
'enforces password confirmation in account creation and update endpoints (remembered=%s)',
async (remembered) => {
const admin = seedUser();
const login = auth.createSession(admin.id, remembered);
const sessionHash = auth.readSession(login.token)!.tokenHash;
const { POST } = await import('../../src/routes/api/users/+server.js');
const { PATCH } = await import('../../src/routes/api/users/[id]/+server.js');
function event(method: string, body: Record<string, unknown>, id = '') {
const url = new URL(`https://ec.test/api/users/${id}`);
return {
url,
request: new Request(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}),
locals: { user: admin, sessionHash },
params: { id },
getClientAddress: () => '192.0.2.62'
};
}
const input = { username: 'newviewer', role: 'viewer', password: replacement };
if (remembered) {
for (const currentPassword of [undefined, replacement]) {
const response = await POST(
event('POST', { ...input, currentPassword }) as unknown as Parameters<
typeof POST
>[0]
);
expect(response.status).toBe(403);
expect(auth.listUsers()).toHaveLength(1);
}
}
const response = await POST(
event('POST', {
...input,
...(remembered ? { currentPassword: password } : {})
}) as unknown as Parameters<typeof POST>[0]
);
expect(response.status).toBe(201);
const { user } = await response.json();
const targetSession = auth.createSession(user.id, true);
const changes = { role: 'operator', password };
if (remembered) {
for (const currentPassword of [undefined, replacement]) {
const response = await PATCH(
event(
'PATCH',
{ ...changes, currentPassword },
user.id
) as unknown as Parameters<typeof PATCH>[0]
);
expect(response.status).toBe(403);
expect(auth.getUser(user.id)?.role).toBe('viewer');
expect(auth.readSession(targetSession.token)).not.toBeNull();
}
}
const updated = await PATCH(
event(
'PATCH',
{
...changes,
...(remembered ? { currentPassword: password } : {})
},
user.id
) as unknown as Parameters<typeof PATCH>[0]
);
expect(updated.status).toBe(200);
expect(auth.getUser(user.id)?.role).toBe('operator');
expect(auth.readSession(targetSession.token)).toBeNull();
const audit = JSON.stringify(auth.listAuthAudit());
expect(audit).not.toContain(replacement);
expect(audit).not.toContain('currentPassword');
}
);
it('rejects an account change if the remembered session is revoked while confirming the password', async () => {
const admin = seedUser(),
target = seedUser('target', 'viewer');
const session = auth.createSession(admin.id, true);
const tokenHash = auth.readSession(session.token)!.tokenHash;
const passwords = await import('../../src/lib/server/auth/passwords.js');
const verifying = gate<boolean>();
vi.spyOn(passwords, 'verifyPassword').mockImplementationOnce(() => verifying.promise);
const pending = auth.updateUser(
admin.id,
target.id,
{ role: 'admin', currentPassword: password },
{
tokenHash,
clientAddress: '192.0.2.62'
}
);
auth.revokeSession(session.token);
verifying.release(true);
await expect(pending).rejects.toMatchObject({ status: 401 });
expect(auth.getUser(target.id)?.role).toBe('viewer');
});
it('rechecks the session after hashing a new user password', async () => {
const admin = seedUser();
const session = auth.createSession(admin.id, true);
const tokenHash = auth.readSession(session.token)!.tokenHash;
const passwords = await import('../../src/lib/server/auth/passwords.js');
vi.spyOn(passwords, 'verifyPassword').mockResolvedValueOnce(true);
const hashing = gate<string>();
const hash = vi
.spyOn(passwords, 'hashPassword')
.mockImplementationOnce(() => hashing.promise);
const pending = auth.createUser(
admin.id,
{
username: 'newviewer',
role: 'viewer',
password: replacement,
currentPassword: password
},
{
tokenHash,
clientAddress: '192.0.2.62'
}
);
await vi.waitFor(() => expect(hash).toHaveBeenCalled());
auth.revokeSession(session.token);
hashing.release(replacementHash);
await expect(pending).rejects.toMatchObject({ status: 401 });
expect(auth.listUsers()).toHaveLength(1);
});
});
describe('persistent authentication throttling', () => { describe('persistent authentication throttling', () => {
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',
@@ -638,7 +915,10 @@ describe('persistent authentication throttling', () => {
body: JSON.stringify({ currentPassword: password, newPassword: replacement }) body: JSON.stringify({ currentPassword: password, newPassword: replacement })
}), }),
cookies: { set: vi.fn() }, cookies: { set: vi.fn() },
locals: { user: login.user }, locals: {
user: login.user,
sessionHash: auth.readSession(login.session.token)!.tokenHash
},
url, url,
getClientAddress: () => address getClientAddress: () => address
} as unknown as Parameters<typeof POST>[0]); } as unknown as Parameters<typeof POST>[0]);