From 82e07ea33eedddd7b707f5e62c12d2e606a1e680 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Tue, 13 Oct 2026 14:42:53 -0400 Subject: [PATCH] Add optional 30-day remembered sessions --- docs/guides/authentication.md | 27 +- src/lib/server/auth/index.ts | 77 ++++- src/lib/server/auth/schema.ts | 9 + src/lib/server/db/migrations.ts | 9 +- src/routes/api/auth/password/+server.ts | 7 +- src/routes/api/users/+server.ts | 9 +- src/routes/api/users/[id]/+server.ts | 12 +- src/routes/login/+page.svelte | 36 ++- src/routes/settings/users/+page.server.ts | 6 + src/routes/settings/users/+page.svelte | 34 +- tests/client/auth-remember-device.test.ts | 119 +++++++ tests/unit/auth-schema.test.ts | 40 ++- tests/unit/auth-users.test.ts | 364 +++++++++++++++++++--- 13 files changed, 677 insertions(+), 72 deletions(-) create mode 100644 src/routes/settings/users/+page.server.ts create mode 100644 tests/client/auth-remember-device.test.ts diff --git a/docs/guides/authentication.md b/docs/guides/authentication.md index 7bf9475..46aca18 100644 --- a/docs/guides/authentication.md +++ b/docs/guides/authentication.md @@ -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. diff --git a/src/lib/server/auth/index.ts b/src/lib/server/auth/index.ts index bdc4348..fe08d88 100644 --- a/src/lib/server/auth/index.ts +++ b/src/lib/server/auth/index.ts @@ -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, clientAddress: string ): Promise { + if (input.rememberMe !== undefined && typeof input.rememberMe !== 'boolean') + throw new AuthError('Keep me signed in must be true or false.'); const name = typeof input.username === 'string' ? input.username.slice(0, 64).toLowerCase() : ''; consumeAuthAttempt(clientAddress, name); @@ -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 { + 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 + input: Record, + session?: AccountSession ): Promise { 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 + input: Record, + session?: AccountSession ): Promise { 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 { 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(); } diff --git a/src/lib/server/auth/schema.ts b/src/lib/server/auth/schema.ts index 518558b..46c3446 100644 --- a/src/lib/server/auth/schema.ts +++ b/src/lib/server/auth/schema.ts @@ -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 diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index df6fd6f..43c7a3d 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -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; diff --git a/src/routes/api/auth/password/+server.ts b/src/routes/api/auth/password/+server.ts index 23cc5d9..45365c8 100644 --- a/src/routes/api/auth/password/+server.ts +++ b/src/routes/api/auth/password/+server.ts @@ -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 ); diff --git a/src/routes/api/users/+server.ts b/src/routes/api/users/+server.ts index 6e8fa8c..68292fd 100644 --- a/src/routes/api/users/+server.ts +++ b/src/routes/api/users/+server.ts @@ -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) { diff --git a/src/routes/api/users/[id]/+server.ts b/src/routes/api/users/[id]/+server.ts index ce12eee..561703a 100644 --- a/src/routes/api/users/[id]/+server.ts +++ b/src/routes/api/users/[id]/+server.ts @@ -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); diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index 99f5fe3..441c6ca 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -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} /> +
+ +

+ Stay signed in for 30 days between visits. Use only on a device you trust. +

+
@@ -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; } diff --git a/src/routes/settings/users/+page.server.ts b/src/routes/settings/users/+page.server.ts new file mode 100644 index 0000000..7ae98bb --- /dev/null +++ b/src/routes/settings/users/+page.server.ts @@ -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) +}); diff --git a/src/routes/settings/users/+page.svelte b/src/routes/settings/users/+page.svelte index 5ca3190..dd02806 100644 --- a/src/routes/settings/users/+page.svelte +++ b/src/routes/settings/users/+page.svelte @@ -23,6 +23,7 @@ let username = $state(''); let displayName = $state(''); let password = $state(''); + let currentPassword = $state(''); let role = $state('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} +{#snippet accountPassword(id: string)} + {#if rememberedSession} + + + +

+ Confirm your password to make account changes on this remembered device. +

+ {/if} +{/snippet} + {#if administrator}Show temporary password + {@render accountPassword('create-current-password')}