From 54ec5b983346097ea03a1eb1c9bd7d76bf9dfed0 Mon Sep 17 00:00:00 2001 From: Frederic Cabassut Date: Mon, 9 Mar 2026 00:33:09 +0100 Subject: [PATCH] feat(auth): session list and revocation Track session metadata (createdAt, userAgent, ip, lastUsedAt). Add API for listing and revoking sessions. Settings page shows active sessions with "Revoke" per session and "Log out everywhere else" option. Co-Authored-By: Claude Opus 4.6 --- src/lib/server/auth.ts | 66 +++++++++++++- src/lib/server/db/index.ts | 19 +++- src/lib/server/db/schema.ts | 6 +- src/lib/server/db/test-helpers.ts | 6 +- src/routes/api/auth/login/+server.ts | 3 +- src/routes/api/auth/sessions/+server.ts | 33 +++++++ src/routes/api/auth/setup/+server.ts | 6 +- src/routes/settings/+page.svelte | 114 ++++++++++++++++++++++++ 8 files changed, 245 insertions(+), 8 deletions(-) create mode 100644 src/routes/api/auth/sessions/+server.ts diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 24f27d9..ab8c882 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -65,13 +65,21 @@ export async function verifyPassword( return valid ? toUser(user) : null; } -export async function createSession(userId: number): Promise { +export async function createSession( + userId: number, + meta?: { userAgent?: string; ip?: string } +): Promise { const token = randomBytes(32).toString('hex'); + const now = new Date(); db.insert(sessions) .values({ id: token, userId, - expiresAt: new Date(Date.now() + SESSION_DURATION_MS) + expiresAt: new Date(Date.now() + SESSION_DURATION_MS), + createdAt: now, + userAgent: meta?.userAgent || null, + ip: meta?.ip || null, + lastUsedAt: now }) .run(); return token; @@ -108,6 +116,12 @@ export async function validateSession( return { valid: false }; } + // Update lastUsedAt + db.update(sessions) + .set({ lastUsedAt: new Date() }) + .where(eq(sessions.id, token)) + .run(); + return { valid: true, user: { @@ -127,6 +141,54 @@ export async function cleanExpiredSessions(): Promise { db.delete(sessions).where(lt(sessions.expiresAt, new Date())).run(); } +export type SessionInfo = { + id: string; + createdAt: Date | null; + userAgent: string | null; + ip: string | null; + lastUsedAt: Date | null; + expiresAt: Date; + isCurrent: boolean; +}; + +export function listUserSessions(userId: number, currentToken: string): SessionInfo[] { + return db + .select() + .from(sessions) + .where(eq(sessions.userId, userId)) + .all() + .map((s) => ({ + id: s.id, + createdAt: s.createdAt, + userAgent: s.userAgent, + ip: s.ip, + lastUsedAt: s.lastUsedAt, + expiresAt: s.expiresAt, + isCurrent: s.id === currentToken + })); +} + +export function revokeSession(userId: number, sessionId: string): boolean { + const session = db + .select() + .from(sessions) + .where(and(eq(sessions.id, sessionId), eq(sessions.userId, userId))) + .get(); + if (!session) return false; + db.delete(sessions).where(eq(sessions.id, sessionId)).run(); + return true; +} + +export function revokeAllSessions(userId: number, exceptToken?: string): void { + if (exceptToken) { + sqlite + .prepare('DELETE FROM sessions WHERE user_id = ? AND id != ?') + .run(userId, exceptToken); + } else { + db.delete(sessions).where(eq(sessions.userId, userId)).run(); + } +} + // --- User CRUD (admin operations) --- export async function createUser( diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index 5c26c02..e5f3a44 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -36,7 +36,11 @@ sqlite.exec(` CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id), - expires_at INTEGER NOT NULL + expires_at INTEGER NOT NULL, + created_at INTEGER, + user_agent TEXT, + ip TEXT, + last_used_at INTEGER ); CREATE TABLE IF NOT EXISTS notes ( @@ -141,6 +145,19 @@ if (!hasEmail) { } } +// Migration: add session tracking columns +const sessionColumns = sqlite + .prepare("PRAGMA table_info('sessions')") + .all() as { name: string }[]; +if (!sessionColumns.some((col) => col.name === 'created_at')) { + sqlite.exec(` + ALTER TABLE sessions ADD COLUMN created_at INTEGER; + ALTER TABLE sessions ADD COLUMN user_agent TEXT; + ALTER TABLE sessions ADD COLUMN ip TEXT; + ALTER TABLE sessions ADD COLUMN last_used_at INTEGER; + `); +} + // Create indexes AFTER migration ensures columns exist sqlite.exec(` CREATE INDEX IF NOT EXISTS notes_user_id_idx ON notes(user_id); diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 401dd48..eeb5002 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -16,7 +16,11 @@ export const sessions = sqliteTable('sessions', { userId: integer('user_id') .references(() => users.id) .notNull(), - expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull() + expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(), + createdAt: integer('created_at', { mode: 'timestamp' }), + userAgent: text('user_agent'), + ip: text('ip'), + lastUsedAt: integer('last_used_at', { mode: 'timestamp' }) }); export const notes = sqliteTable( diff --git a/src/lib/server/db/test-helpers.ts b/src/lib/server/db/test-helpers.ts index 5bd3cd4..765929b 100644 --- a/src/lib/server/db/test-helpers.ts +++ b/src/lib/server/db/test-helpers.ts @@ -27,7 +27,11 @@ export function createTestDb() { CREATE TABLE sessions ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id), - expires_at INTEGER NOT NULL + expires_at INTEGER NOT NULL, + created_at INTEGER, + user_agent TEXT, + ip TEXT, + last_used_at INTEGER ); CREATE TABLE notes ( diff --git a/src/routes/api/auth/login/+server.ts b/src/routes/api/auth/login/+server.ts index 98ef2d0..670993d 100644 --- a/src/routes/api/auth/login/+server.ts +++ b/src/routes/api/auth/login/+server.ts @@ -27,7 +27,8 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress recordLoginAttempt(ip, email, true); - const token = await createSession(user.id); + const userAgent = request.headers.get('user-agent') || undefined; + const token = await createSession(user.id, { userAgent, ip }); cookies.set('session', token, { path: '/', httpOnly: true, diff --git a/src/routes/api/auth/sessions/+server.ts b/src/routes/api/auth/sessions/+server.ts new file mode 100644 index 0000000..9216442 --- /dev/null +++ b/src/routes/api/auth/sessions/+server.ts @@ -0,0 +1,33 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { getUserId } from '$lib/server/api-utils.js'; +import { listUserSessions, revokeSession, revokeAllSessions } from '$lib/server/auth.js'; + +export const GET: RequestHandler = async (event) => { + const userId = getUserId(event); + const currentToken = event.cookies.get('session') || ''; + const sessions = listUserSessions(userId, currentToken); + return json(sessions); +}; + +export const DELETE: RequestHandler = async (event) => { + const userId = getUserId(event); + const { sessionId, all } = await event.request.json(); + + if (all) { + const currentToken = event.cookies.get('session') || ''; + revokeAllSessions(userId, currentToken); + return json({ success: true }); + } + + if (!sessionId) { + throw error(400, 'sessionId is required'); + } + + const revoked = revokeSession(userId, sessionId); + if (!revoked) { + throw error(404, 'Session not found'); + } + + return json({ success: true }); +}; diff --git a/src/routes/api/auth/setup/+server.ts b/src/routes/api/auth/setup/+server.ts index e5fa96e..2f69540 100644 --- a/src/routes/api/auth/setup/+server.ts +++ b/src/routes/api/auth/setup/+server.ts @@ -2,7 +2,7 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; import { setupUser, createSession, isSetupComplete } from '$lib/server/auth.js'; -export const POST: RequestHandler = async ({ request, cookies }) => { +export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => { const alreadySetup = await isSetupComplete(); if (alreadySetup) { throw error(400, 'Setup already completed'); @@ -23,7 +23,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => { throw error(500, 'Failed to create user'); } - const token = await createSession(user.id); + const userAgent = request.headers.get('user-agent') || undefined; + const ip = getClientAddress(); + const token = await createSession(user.id, { userAgent, ip }); cookies.set('session', token, { path: '/', httpOnly: true, diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 589b992..73016dc 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -2,6 +2,16 @@ import { goto } from '$app/navigation'; import PasswordStrengthMeter from '$lib/components/PasswordStrengthMeter.svelte'; + type SessionInfo = { + id: string; + createdAt: string | null; + userAgent: string | null; + ip: string | null; + lastUsedAt: string | null; + expiresAt: string; + isCurrent: boolean; + }; + let { data } = $props(); let displayName = $state(data.user?.displayName || ''); @@ -15,6 +25,63 @@ let passwordMsg = $state(''); let passwordError = $state(false); + let activeSessions = $state([]); + let sessionsLoaded = $state(false); + + async function loadSessions() { + try { + const res = await fetch('/api/auth/sessions'); + if (res.ok) activeSessions = await res.json(); + sessionsLoaded = true; + } catch { + // ignore + } + } + + async function revokeSession(sessionId: string) { + try { + await fetch('/api/auth/sessions', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId }) + }); + activeSessions = activeSessions.filter((s) => s.id !== sessionId); + } catch { + // ignore + } + } + + async function revokeAllOtherSessions() { + try { + await fetch('/api/auth/sessions', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ all: true }) + }); + activeSessions = activeSessions.filter((s) => s.isCurrent); + } catch { + // ignore + } + } + + function formatAgent(ua: string | null): string { + if (!ua) return 'Unknown'; + if (ua.includes('Firefox')) return 'Firefox'; + if (ua.includes('Edg/')) return 'Edge'; + if (ua.includes('Chrome')) return 'Chrome'; + if (ua.includes('Safari')) return 'Safari'; + return ua.slice(0, 40); + } + + function formatDate(d: string | null): string { + if (!d) return 'Unknown'; + return new Date(d).toLocaleString(); + } + + $effect(() => { + loadSessions(); + }); + async function saveProfile() { profileMsg = ''; try { @@ -157,6 +224,53 @@ + +
+
+

Active Sessions

+ {#if activeSessions.length > 1} + + {/if} +
+ + {#if !sessionsLoaded} +

Loading sessions...

+ {:else if activeSessions.length === 0} +

No active sessions

+ {:else} +
+ {#each activeSessions as session (session.id)} +
+
+

+ {formatAgent(session.userAgent)} + {#if session.isCurrent} + current + {/if} +

+

+ {session.ip || 'Unknown IP'} ยท Last active {formatDate(session.lastUsedAt)} +

+
+ {#if !session.isCurrent} + + {/if} +
+ {/each} +
+ {/if} +
+ {#if data.user?.role === 'admin'}