This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user