This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
});
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+290
-10
@@ -347,23 +347,25 @@ 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])(
|
||||||
|
'revokes every target session on role, enabled-state and password changes (remembered=%s)',
|
||||||
|
async (remembered) => {
|
||||||
const admin = seedUser(),
|
const admin = seedUser(),
|
||||||
target = seedUser('target', 'viewer');
|
target = seedUser('target', 'viewer');
|
||||||
const administratorSession = auth.createSession(admin.id);
|
const administratorSession = auth.createSession(admin.id);
|
||||||
const first = auth.createSession(target.id),
|
const first = auth.createSession(target.id, remembered),
|
||||||
second = auth.createSession(target.id);
|
second = auth.createSession(target.id);
|
||||||
await auth.updateUser(admin.id, target.id, { role: 'super_viewer' });
|
await auth.updateUser(admin.id, target.id, { role: 'super_viewer' });
|
||||||
expect(auth.getUser(target.id)?.role).toBe('super_viewer');
|
expect(auth.getUser(target.id)?.role).toBe('super_viewer');
|
||||||
expect(auth.readSession(first.token)).toBeNull();
|
expect(auth.readSession(first.token)).toBeNull();
|
||||||
expect(auth.readSession(second.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 });
|
await auth.updateUser(admin.id, target.id, { disabled: true });
|
||||||
expect(auth.readSession(third.token)).toBeNull();
|
expect(auth.readSession(third.token)).toBeNull();
|
||||||
expect(() => auth.createSession(target.id)).toThrow('unavailable');
|
expect(() => auth.createSession(target.id)).toThrow('unavailable');
|
||||||
await auth.updateUser(admin.id, target.id, { disabled: false });
|
await auth.updateUser(admin.id, target.id, { disabled: false });
|
||||||
expect(auth.readSession(third.token)).toBeNull();
|
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 });
|
const reset = await auth.updateUser(admin.id, target.id, { password: replacement });
|
||||||
expect(reset.mustChangePassword).toBe(true);
|
expect(reset.mustChangePassword).toBe(true);
|
||||||
expect(auth.readSession(fourth.token)).toBeNull();
|
expect(auth.readSession(fourth.token)).toBeNull();
|
||||||
@@ -372,13 +374,16 @@ describe('administrator user management', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
auth.updateUser(admin.id, admin.id, { password: replacement })
|
auth.updateUser(admin.id, admin.id, { password: replacement })
|
||||||
).rejects.toThrow('current password');
|
).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(),
|
const admin = seedUser(),
|
||||||
target = seedUser('target', 'viewer');
|
target = seedUser('target', 'viewer');
|
||||||
const one = auth.createSession(target.id),
|
const one = auth.createSession(target.id, remembered),
|
||||||
two = auth.createSession(target.id),
|
two = auth.createSession(target.id, remembered),
|
||||||
adminSession = auth.createSession(admin.id);
|
adminSession = auth.createSession(admin.id);
|
||||||
await auth.updateUser(admin.id, target.id, { displayName: 'New display name' });
|
await auth.updateUser(admin.id, target.id, { displayName: 'New display name' });
|
||||||
expect(auth.readSession(one.token)?.user.displayName).toBe('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);
|
auth.revokeUserSessions(admin.id, target.id);
|
||||||
expect(auth.readSession(two.token)).toBeNull();
|
expect(auth.readSession(two.token)).toBeNull();
|
||||||
expect(auth.readSession(adminSession.token)).not.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]);
|
||||||
|
|||||||
Reference in New Issue
Block a user