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'}