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.
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
30 minutes without authenticated requests; background dashboard polling counts
as activity. Login replaces the existing browser session. Logout revokes it.
SQLite. HTTPS uses a Secure `__Host-` cookie. By default, sessions expire after
12 hours, or 30 minutes without authenticated requests; background dashboard
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
user's sessions invalidates their existing sessions. Live event streams check
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_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;
const digest = (value: string) => createHash('sha256').update(value).digest('hex');
function database() {
return db();
@@ -231,6 +232,8 @@ export async function authenticatePassword(
input: Record<string, unknown>,
clientAddress: 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);
@@ -253,54 +256,67 @@ export async function authenticatePassword(
.prepare('UPDATE auth_users SET last_login_at=? WHERE id=?')
.run(Date.now(), 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();
if (!result) throw new AuthError('Invalid username or password.', 401);
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 user = userRow(userId);
if (!user || user.disabled) throw new AuthError('Account is unavailable.', 401);
const token = randomBytes(32).toString('base64url');
const expiresAt = now + SESSION_LIFETIME_MS;
const expiresAt = now + (remembered ? REMEMBERED_SESSION_LIFETIME_MS : SESSION_LIFETIME_MS);
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);
database()
.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 };
}
export function readSession(
token: string | undefined,
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;
return readSessionHash(digest(token), touch);
}
export function readSessionHash(
tokenHash: string,
touch = false
): { user: AuthUser; tokenHash: string; expiresAt: number } | null {
): { user: AuthUser; tokenHash: string; expiresAt: number; remembered: boolean } | null {
const now = Date.now();
const row = database()
.prepare(
`SELECT u.*,s.expires_at,s.last_seen_at 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`
`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.remembered=1 OR s.last_seen_at>?) AND u.disabled=0`
)
.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 (touch && row.last_seen_at < now - 60_000)
database()
.prepare('UPDATE auth_sessions SET last_seen_at=? WHERE token_hash=?')
.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 {
if (token)
@@ -319,11 +335,30 @@ function requireAdministrator(actorId: string) {
if (!actor || actor.disabled || actor.role !== 'admin' || actor.must_change_password)
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(
actorId: string,
input: Record<string, unknown>
input: Record<string, unknown>,
session?: AccountSession
): Promise<AuthUser> {
requireAdministrator(actorId);
if (session) await confirmAccountPassword(actorId, input.currentPassword, session);
const name = username(input.username),
label = displayName(input.displayName, name),
userRole = role(input.role);
@@ -331,6 +366,7 @@ export async function createUser(
return database()
.transaction(() => {
requireAdministrator(actorId);
if (session) requireSessionUser(actorId, session.tokenHash);
const user = insertUser({
username: name,
displayName: label,
@@ -346,9 +382,11 @@ export async function createUser(
export async function updateUser(
actorId: string,
id: string,
input: Record<string, unknown>
input: Record<string, unknown>,
session?: AccountSession
): Promise<AuthUser> {
requireAdministrator(actorId);
if (session) await confirmAccountPassword(actorId, input.currentPassword, session);
if (input.disabled !== undefined && typeof input.disabled !== 'boolean')
throw new AuthError('Disabled must be true or false.');
if (input.password !== undefined && actorId === id)
@@ -357,6 +395,7 @@ export async function updateUser(
return database()
.transaction(() => {
requireAdministrator(actorId);
if (session) requireSessionUser(actorId, session.tokenHash);
const current = userRow(id);
if (!current) throw new AuthError('User not found.', 404);
const nextRole = input.role === undefined ? current.role : role(input.role);
@@ -402,7 +441,8 @@ export async function updateUser(
export async function changePassword(
userId: string,
currentPassword: unknown,
newPassword: unknown
newPassword: unknown,
sessionHash?: string
): Promise<AuthResult> {
const original = userRow(userId);
if (
@@ -415,6 +455,8 @@ export async function changePassword(
const passwordHash = await hashPassword(newPassword);
return database()
.transaction(() => {
const session =
sessionHash === undefined ? null : requireSessionUser(userId, sessionHash);
const current = userRow(userId);
if (!current || current.disabled || current.password_hash !== original.password_hash)
throw new AuthError(
@@ -428,7 +470,10 @@ export async function changePassword(
.run(passwordHash, Date.now(), userId);
database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(userId);
recordAudit(userId, 'password.changed', userId);
return { user: getUser(userId)!, session: createSession(userId) };
return {
user: getUser(userId)!,
session: createSession(userId, session?.remembered ?? false)
};
})
.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. */
export function migrateSuperViewerRole(handle: Database.Database): void {
const schema = handle
+7 -2
View File
@@ -14,7 +14,11 @@ import {
} from '../reports/revisions.js';
import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.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 { migrateLeaderboardReports } from './leaderboard-reports.js';
import { migratePlayerScoreTotal } from './player-score-total.js';
@@ -113,7 +117,8 @@ export const migrations: readonly Migration[] = [
run: migratePlayerScoreTotal
},
{ 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;
+6 -1
View File
@@ -7,7 +7,12 @@ export const POST: RequestHandler = async ({ request, cookies, locals, url, getC
consumeAuthAttempt(getClientAddress(), user.id, 'password');
const body = await authBody(request);
return sessionResponse(
await changePassword(user.id, body.currentPassword, body.newPassword),
await changePassword(
user.id,
body.currentPassword,
body.newPassword,
locals.sessionHash ?? ''
),
cookies,
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 { authBody, authError, requireUser } from '$lib/server/auth/http.js';
export const GET: RequestHandler = () => json({ users: listUsers() });
export const POST: RequestHandler = async ({ request, locals }) => {
export const POST: RequestHandler = async ({ request, locals, getClientAddress }) => {
try {
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 }
);
} catch (cause) {
+10 -2
View File
@@ -2,10 +2,18 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { updateUser } from '$lib/server/auth/index.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 {
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) {
return authError(cause);
+35 -1
View File
@@ -6,6 +6,7 @@
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
let username = $state('');
let password = $state('');
let rememberMe = $state(false);
let busy = $state(false);
let error = $state('');
function destination(): string {
@@ -28,7 +29,7 @@
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
body: JSON.stringify({ username, password, rememberMe })
});
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not sign in. Try again.');
@@ -76,6 +77,20 @@
bind:value={password}
/></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>
</fieldset>
</form>
@@ -111,6 +126,25 @@
font-size: var(--text-sm);
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 {
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 displayName = $state('');
let password = $state('');
let currentPassword = $state('');
let role = $state<UserRole>('viewer');
let enabled = $state(true);
let showPassword = $state(false);
@@ -62,7 +63,11 @@
];
const roleDescription = $derived(roles.find((item) => item.value === role)?.description);
const administrator = $derived($page.data.user?.role === 'admin');
const rememberedSession = $derived($page.data.rememberedSession);
const canViewUsers = $derived(administrator || $page.data.user?.role === 'super_viewer');
$effect(() => {
if (!createOpen && !editOpen) currentPassword = '';
});
async function load() {
loading = true;
@@ -118,6 +123,7 @@
});
function openCreate() {
if (!administrator) return;
currentPassword = '';
username = '';
displayName = '';
password = '';
@@ -130,6 +136,7 @@
}
function openEdit(user: AuthUser) {
if (!administrator) return;
currentPassword = '';
editing = user;
username = user.username;
displayName = user.displayName;
@@ -151,7 +158,7 @@
const response = await fetch('/api/users', {
method: 'POST',
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();
if (!response.ok) throw new Error(body.error ?? 'Could not create this user.');
@@ -162,6 +169,7 @@
} catch (cause) {
formError = (cause as Error).message;
} finally {
currentPassword = '';
busy = false;
}
}
@@ -183,6 +191,7 @@
displayName,
role,
disabled: !enabled,
currentPassword,
...(password ? { password } : {})
})
});
@@ -200,6 +209,7 @@
} catch (cause) {
formError = (cause as Error).message;
} finally {
currentPassword = '';
busy = false;
}
}
@@ -370,6 +380,26 @@
{/if}
</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
bind:open={createOpen}
title="Add user"
@@ -439,6 +469,7 @@
<label class="checkbox"
><input type="checkbox" bind:checked={showPassword} />Show temporary password</label
>
{@render accountPassword('create-current-password')}
<div class="form-actions">
<Button
disabled={busy}
@@ -518,6 +549,7 @@
><input type="checkbox" bind:checked={showPassword} />Show temporary
password</label
>{/if}
{@render accountPassword('edit-current-password')}
<div class="form-actions">
<Button
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 { 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 { migrations } from '../../src/lib/server/db/migrations.js';
@@ -29,7 +32,10 @@ beforeEach(() => {
afterEach(() => handle.close());
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', () => {
@@ -117,3 +123,33 @@ describe('super viewer role migration', () => {
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([]);
});
});
+290 -10
View File
@@ -347,23 +347,25 @@ describe('administrator user management', () => {
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])(
'revokes every target session on role, enabled-state and password changes (remembered=%s)',
async (remembered) => {
const admin = seedUser(),
target = seedUser('target', 'viewer');
const administratorSession = auth.createSession(admin.id);
const first = auth.createSession(target.id),
const first = auth.createSession(target.id, remembered),
second = auth.createSession(target.id);
await auth.updateUser(admin.id, target.id, { role: 'super_viewer' });
expect(auth.getUser(target.id)?.role).toBe('super_viewer');
expect(auth.readSession(first.token)).toBeNull();
expect(auth.readSession(second.token)).toBeNull();
const third = auth.createSession(target.id);
const third = auth.createSession(target.id, remembered);
await auth.updateUser(admin.id, target.id, { disabled: true });
expect(auth.readSession(third.token)).toBeNull();
expect(() => auth.createSession(target.id)).toThrow('unavailable');
await auth.updateUser(admin.id, target.id, { disabled: false });
expect(auth.readSession(third.token)).toBeNull();
const fourth = auth.createSession(target.id);
const fourth = auth.createSession(target.id, remembered);
const reset = await auth.updateUser(admin.id, target.id, { password: replacement });
expect(reset.mustChangePassword).toBe(true);
expect(auth.readSession(fourth.token)).toBeNull();
@@ -372,13 +374,16 @@ describe('administrator user management', () => {
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])(
'keeps sessions for display-only edits and supports per-user and individual revocation (remembered=%s)',
async (remembered) => {
const admin = seedUser(),
target = seedUser('target', 'viewer');
const one = auth.createSession(target.id),
two = auth.createSession(target.id),
const one = auth.createSession(target.id, remembered),
two = auth.createSession(target.id, remembered),
adminSession = auth.createSession(admin.id);
await auth.updateUser(admin.id, target.id, { displayName: 'New display name' });
expect(auth.readSession(one.token)?.user.displayName).toBe('New display name');
@@ -388,10 +393,104 @@ describe('administrator user management', () => {
auth.revokeUserSessions(admin.id, target.id);
expect(auth.readSession(two.token)).toBeNull();
expect(auth.readSession(adminSession.token)).not.toBeNull();
});
}
);
});
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', () => {
const user = seedUser();
const first = auth.createSession(user.id),
@@ -449,6 +548,52 @@ describe('session secrecy and expiration', () => {
});
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 () => {
const user = seedUser('newuser', 'viewer', { mustChangePassword: true });
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', () => {
it.each(['admin', 'super_viewer'] as const)(
'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 })
}),
cookies: { set: vi.fn() },
locals: { user: login.user },
locals: {
user: login.user,
sessionHash: auth.readSession(login.session.token)!.tokenHash
},
url,
getClientAddress: () => address
} as unknown as Parameters<typeof POST>[0]);