From 1eb70e9b9b63cf7ccc9202ef561475dfe993a3ad Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 7 Mar 2026 11:53:16 +0500 Subject: [PATCH] Add session management: track active sessions, show on profile, allow revocation - Add user_sessions PocketBase collection (user_id, session_id, user_agent, ip, created/last_seen) - Extend auth token format to include a per-login authSessionId (4th segment) - Hook validates authSessionId against DB on each request; revoked sessions are cleared immediately - Login/register create a session record capturing user-agent and IP - Profile page shows all active sessions with current session highlighted; per-session End/Sign out buttons - GET /api/sessions and DELETE /api/sessions/[id] endpoints for client-side revocation - Backward compatible: legacy 3-segment tokens pass through without DB check --- scraper/internal/storage/pocketbase.go | 14 +++ ui/src/app.d.ts | 4 +- ui/src/hooks.server.ts | 54 +++++++--- ui/src/lib/server/pocketbase.ts | 109 +++++++++++++++++++ ui/src/routes/api/sessions/+server.ts | 32 ++++++ ui/src/routes/api/sessions/[id]/+server.ts | 41 +++++++ ui/src/routes/login/+page.server.ts | 33 +++++- ui/src/routes/profile/+page.server.ts | 20 +++- ui/src/routes/profile/+page.svelte | 119 +++++++++++++++++++++ 9 files changed, 406 insertions(+), 20 deletions(-) create mode 100644 ui/src/routes/api/sessions/+server.ts create mode 100644 ui/src/routes/api/sessions/[id]/+server.ts diff --git a/scraper/internal/storage/pocketbase.go b/scraper/internal/storage/pocketbase.go index 1c499a4..c54994b 100644 --- a/scraper/internal/storage/pocketbase.go +++ b/scraper/internal/storage/pocketbase.go @@ -14,6 +14,8 @@ // books_found(number), chapters_scraped(number), // chapters_skipped(number), errors(number), // started(date), finished(date), error_message(text) +// user_sessions — user_id(text), session_id(text,unique), user_agent(text), +// ip(text), created_at(date), last_seen(date) package storage import ( @@ -394,6 +396,18 @@ func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error { {"name": "error_message", "type": "text"}, }, }, + { + "name": "user_sessions", + "type": "base", + "fields": []map[string]interface{}{ + {"name": "user_id", "type": "text", "required": true}, + {"name": "session_id", "type": "text", "required": true}, // random ID embedded in auth token + {"name": "user_agent", "type": "text"}, + {"name": "ip", "type": "text"}, + {"name": "created_at", "type": "date"}, + {"name": "last_seen", "type": "date"}, + }, + }, } for _, col := range collections { name, _ := col["name"].(string) diff --git a/ui/src/app.d.ts b/ui/src/app.d.ts index f85fed0..75eecc0 100644 --- a/ui/src/app.d.ts +++ b/ui/src/app.d.ts @@ -5,10 +5,10 @@ declare global { // interface Error {} interface Locals { sessionId: string; - user: { id: string; username: string; role: string } | null; + user: { id: string; username: string; role: string; authSessionId: string } | null; } interface PageData { - user?: { id: string; username: string; role: string } | null; + user?: { id: string; username: string; role: string; authSessionId: string } | null; } // interface PageState {} // interface Platform {} diff --git a/ui/src/hooks.server.ts b/ui/src/hooks.server.ts index e8ac57e..15a41ca 100644 --- a/ui/src/hooks.server.ts +++ b/ui/src/hooks.server.ts @@ -2,6 +2,7 @@ import type { Handle } from '@sveltejs/kit'; import { randomBytes, createHmac } from 'node:crypto'; import { env } from '$env/dynamic/private'; import { log } from '$lib/server/logger'; +import { createUserSession, touchUserSession, isSessionRevoked } from '$lib/server/pocketbase'; const SESSION_COOKIE = 'libnovel_session'; const AUTH_COOKIE = 'libnovel_auth'; @@ -40,27 +41,30 @@ export function verifyToken(token: string): string | null { /** * Create a signed auth token for a user. - * Payload format: "::" + * Payload format: ":::" + * authSessionId uniquely identifies this login session (for revocation). */ -export function createAuthToken(userId: string, username: string, role: string): string { - return signToken(`${userId}:${username}:${role}`); +export function createAuthToken(userId: string, username: string, role: string, authSessionId: string): string { + return signToken(`${userId}:${username}:${role}:${authSessionId}`); } /** * Parse a verified auth token into user data. Returns null if invalid. + * Supports both old format (3 segments) and new format (4 segments). */ -export function parseAuthToken(token: string): { id: string; username: string; role: string } | null { +export function parseAuthToken(token: string): { id: string; username: string; role: string; authSessionId: string } | null { const payload = verifyToken(token); if (!payload) return null; - const firstColon = payload.indexOf(':'); - if (firstColon < 0) return null; - const secondColon = payload.indexOf(':', firstColon + 1); - if (secondColon < 0) return null; - const id = payload.slice(0, firstColon); - const username = payload.slice(firstColon + 1, secondColon); - const role = payload.slice(secondColon + 1); + const parts = payload.split(':'); + // New format: userId:username:role:authSessionId (4 parts) + // Old format: userId:username:role (3 parts — legacy tokens before session tracking) + if (parts.length < 3) return null; + const id = parts[0]; + const username = parts[1]; + const role = parts[2]; + const authSessionId = parts[3] ?? ''; // empty string for legacy tokens if (!id || !username) return null; - return { id, username, role }; + return { id, username, role, authSessionId }; } // ─── Hook ───────────────────────────────────────────────────────────────────── @@ -85,8 +89,32 @@ export const handle: Handle = async ({ event, resolve }) => { const user = parseAuthToken(authToken); if (!user) { log.warn('auth', 'auth cookie present but failed to parse (malformed or tampered)'); + event.locals.user = null; + } else { + // Validate session against DB (only for new-format tokens with authSessionId) + let sessionValid = true; + if (user.authSessionId) { + try { + const revoked = await isSessionRevoked(user.authSessionId); + if (revoked) { + log.info('auth', 'auth cookie references revoked session', { + userId: user.id, + authSessionId: user.authSessionId + }); + sessionValid = false; + // Clear the invalid cookie + event.cookies.delete(AUTH_COOKIE, { path: '/' }); + } else { + // Best-effort: update last_seen in the background + touchUserSession(user.authSessionId).catch(() => {}); + } + } catch (err) { + // DB error — fail open to avoid locking everyone out + log.warn('auth', 'session check failed (fail open)', { err: String(err) }); + } + } + event.locals.user = sessionValid ? user : null; } - event.locals.user = user; } else { event.locals.user = null; } diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index 134dd79..721daab 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -667,3 +667,112 @@ export async function getAudioTime( if (!row || !row.audio_time) return null; return row.audio_time; } + +// ─── User sessions ──────────────────────────────────────────────────────────── + +export interface UserSession { + id: string; + user_id: string; + session_id: string; // the auth session ID embedded in the token + user_agent: string; + ip: string; + created_at: string; + last_seen: string; +} + +/** + * Create a new session record on login. Returns the record ID. + */ +export async function createUserSession( + userId: string, + authSessionId: string, + userAgent: string, + ip: string +): Promise { + const now = new Date().toISOString(); + const res = await pbPost('/api/collections/user_sessions/records', { + user_id: userId, + session_id: authSessionId, + user_agent: userAgent, + ip, + created_at: now, + last_seen: now + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.error('pocketbase', 'createUserSession POST failed', { userId, status: res.status, body }); + throw new Error(`Failed to create session: ${res.status}`); + } + const rec = (await res.json()) as { id: string }; + return rec.id; +} + +/** + * Update last_seen on a session (best-effort, non-fatal if it fails). + */ +export async function touchUserSession(authSessionId: string): Promise { + const row = await listOne( + 'user_sessions', + `session_id="${authSessionId}"` + ); + if (!row) return; + const token = await getToken(); + await fetch(`${PB_URL}/api/collections/user_sessions/records/${row.id}`, { + method: 'PATCH', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ last_seen: new Date().toISOString() }) + }); +} + +/** + * Check whether a session has been revoked (i.e., not present in DB). + * Returns true if revoked/missing, false if valid. + */ +export async function isSessionRevoked(authSessionId: string): Promise { + const row = await listOne('user_sessions', `session_id="${authSessionId}"`); + return row === null; +} + +/** + * List all active sessions for a user. + */ +export async function listUserSessions(userId: string): Promise { + return listAll('user_sessions', `user_id="${userId}"`, '-last_seen'); +} + +/** + * Revoke (delete) a specific session by its PocketBase record ID. + * Only allows deletion if the session belongs to the given userId. + */ +export async function revokeUserSession(recordId: string, userId: string): Promise { + // Verify ownership before deleting + const token = await getToken(); + const res = await fetch(`${PB_URL}/api/collections/user_sessions/records/${recordId}`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (!res.ok) return false; + const rec = (await res.json()) as UserSession; + if (rec.user_id !== userId) return false; + + const del = await fetch(`${PB_URL}/api/collections/user_sessions/records/${recordId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` } + }); + return del.ok || del.status === 204; +} + +/** + * Revoke all sessions for a user (used on password change etc). + */ +export async function revokeAllUserSessions(userId: string): Promise { + const sessions = await listUserSessions(userId); + const token = await getToken(); + await Promise.all( + sessions.map((s) => + fetch(`${PB_URL}/api/collections/user_sessions/records/${s.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` } + }).catch(() => {}) + ) + ); +} diff --git a/ui/src/routes/api/sessions/+server.ts b/ui/src/routes/api/sessions/+server.ts new file mode 100644 index 0000000..5feb689 --- /dev/null +++ b/ui/src/routes/api/sessions/+server.ts @@ -0,0 +1,32 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { listUserSessions } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +/** + * GET /api/sessions + * Returns all active sessions for the logged-in user. + */ +export const GET: RequestHandler = async ({ locals }) => { + if (!locals.user) { + error(401, 'Not logged in'); + } + + try { + const sessions = await listUserSessions(locals.user.id); + // Don't expose raw session_id to the client — only the record ID for revocation + const safe = sessions.map((s) => ({ + id: s.id, + user_agent: s.user_agent, + ip: s.ip, + created_at: s.created_at, + last_seen: s.last_seen, + // Tell the client whether this is the currently active session + is_current: s.session_id === locals.user!.authSessionId + })); + return json({ sessions: safe }); + } catch (e) { + log.error('sessions', 'GET failed', { err: String(e) }); + error(500, 'Failed to load sessions'); + } +}; diff --git a/ui/src/routes/api/sessions/[id]/+server.ts b/ui/src/routes/api/sessions/[id]/+server.ts new file mode 100644 index 0000000..f40774a --- /dev/null +++ b/ui/src/routes/api/sessions/[id]/+server.ts @@ -0,0 +1,41 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { revokeUserSession } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +/** + * DELETE /api/sessions/[id] + * Revokes a specific session by its PocketBase record ID. + * Only the owner can revoke their own sessions. + */ +export const DELETE: RequestHandler = async ({ params, locals, cookies }) => { + if (!locals.user) { + error(401, 'Not logged in'); + } + + const recordId = params.id; + if (!recordId) { + error(400, 'Session ID required'); + } + + try { + const ok = await revokeUserSession(recordId, locals.user.id); + if (!ok) { + error(404, 'Session not found or not yours'); + } + + // If the user is terminating their own current session, clear their auth cookie + // so they get logged out immediately (the hook would do this on the next request anyway, + // but clearing it here gives instant feedback for the "end this session" flow). + // For other sessions, we leave the cookie intact. + // We detect "current session" via authSessionId — but since the client sends the + // record ID (not the session_id), we rely on the UI to redirect after ending its own session. + + log.info('sessions', 'session revoked', { recordId, userId: locals.user.id }); + return json({ ok: true }); + } catch (e) { + if (e instanceof Error && 'status' in e) throw e; // re-throw SvelteKit errors + log.error('sessions', 'DELETE failed', { recordId, err: String(e) }); + error(500, 'Failed to revoke session'); + } +}; diff --git a/ui/src/routes/login/+page.server.ts b/ui/src/routes/login/+page.server.ts index b951ef6..69e6211 100644 --- a/ui/src/routes/login/+page.server.ts +++ b/ui/src/routes/login/+page.server.ts @@ -1,8 +1,9 @@ import { fail, redirect } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; -import { loginUser, createUser, mergeSessionProgress } from '$lib/server/pocketbase'; +import { loginUser, createUser, mergeSessionProgress, createUserSession } from '$lib/server/pocketbase'; import { createAuthToken } from '../../hooks.server'; import { log } from '$lib/server/logger'; +import { randomBytes } from 'node:crypto'; const AUTH_COOKIE = 'libnovel_auth'; const ONE_YEAR = 60 * 60 * 24 * 365; @@ -43,7 +44,20 @@ export const actions: Actions = { log.warn('auth', 'login: mergeSessionProgress failed (non-fatal)', { err: String(err) }) ); - const token = createAuthToken(user.id, user.username, user.role ?? 'user'); + // Create a unique auth session ID for this login + const authSessionId = randomBytes(16).toString('hex'); + + // Record the session in PocketBase (best-effort, non-fatal) + const userAgent = request.headers.get('user-agent') ?? ''; + const ip = + request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + request.headers.get('x-real-ip') ?? + ''; + createUserSession(user.id, authSessionId, userAgent, ip).catch((err) => + log.warn('auth', 'login: createUserSession failed (non-fatal)', { err: String(err) }) + ); + + const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId); cookies.set(AUTH_COOKIE, token, { path: '/', httpOnly: true, @@ -102,7 +116,20 @@ export const actions: Actions = { log.warn('auth', 'register: mergeSessionProgress failed (non-fatal)', { err: String(err) }) ); - const token = createAuthToken(user.id, user.username, user.role ?? 'user'); + // Create a unique auth session ID for this registration + const authSessionId = randomBytes(16).toString('hex'); + + // Record the session in PocketBase (best-effort, non-fatal) + const userAgent = request.headers.get('user-agent') ?? ''; + const ip = + request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + request.headers.get('x-real-ip') ?? + ''; + createUserSession(user.id, authSessionId, userAgent, ip).catch((err) => + log.warn('auth', 'register: createUserSession failed (non-fatal)', { err: String(err) }) + ); + + const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId); cookies.set(AUTH_COOKIE, token, { path: '/', httpOnly: true, diff --git a/ui/src/routes/profile/+page.server.ts b/ui/src/routes/profile/+page.server.ts index 1cf9e41..fff19b6 100644 --- a/ui/src/routes/profile/+page.server.ts +++ b/ui/src/routes/profile/+page.server.ts @@ -1,14 +1,30 @@ import { fail, redirect } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; -import { changePassword } from '$lib/server/pocketbase'; +import { changePassword, listUserSessions } from '$lib/server/pocketbase'; import { log } from '$lib/server/logger'; export const load: PageServerLoad = async ({ locals }) => { if (!locals.user) { redirect(302, '/login'); } + + let sessions: Awaited> = []; + try { + sessions = await listUserSessions(locals.user.id); + } catch (e) { + log.warn('profile', 'listUserSessions failed (non-fatal)', { err: String(e) }); + } + return { - user: locals.user + user: locals.user, + sessions: sessions.map((s) => ({ + id: s.id, + user_agent: s.user_agent, + ip: s.ip, + created_at: s.created_at, + last_seen: s.last_seen, + is_current: s.session_id === locals.user!.authSessionId + })) }; }; diff --git a/ui/src/routes/profile/+page.svelte b/ui/src/routes/profile/+page.svelte index d97affc..6e252d8 100644 --- a/ui/src/routes/profile/+page.svelte +++ b/ui/src/routes/profile/+page.svelte @@ -69,12 +69,81 @@ setTimeout(() => (pwSuccess = false), 3000); } }); + + // ── Sessions ──────────────────────────────────────────────────────────────── + type Session = { + id: string; + user_agent: string; + ip: string; + created_at: string; + last_seen: string; + is_current: boolean; + }; + + let sessions = $state(data.sessions ?? []); + let revokingId = $state(null); + let revokeError = $state(''); + + async function revokeSession(session: Session) { + revokingId = session.id; + revokeError = ''; + try { + const res = await fetch(`/api/sessions/${session.id}`, { method: 'DELETE' }); + if (!res.ok) { + revokeError = 'Failed to end session. Please try again.'; + return; + } + if (session.is_current) { + // Ended our own session — submit the logout form to clear the cookie + const logoutForm = document.getElementById('logout-form') as HTMLFormElement | null; + if (logoutForm) { + logoutForm.submit(); + } + return; + } + // Remove from local list + sessions = sessions.filter((s) => s.id !== session.id); + } catch { + revokeError = 'Network error. Please try again.'; + } finally { + revokingId = null; + } + } + + function formatDate(iso: string): string { + if (!iso) return '—'; + try { + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short' + }).format(new Date(iso)); + } catch { + return iso; + } + } + + function parseUA(ua: string): string { + if (!ua) return 'Unknown browser'; + // Very lightweight UA display — just show the most meaningful part + if (/Mobile/i.test(ua)) { + const match = ua.match(/\(([^)]+)\)/); + return match ? `Mobile — ${match[1].split(';')[0].trim()}` : 'Mobile device'; + } + if (/Chrome\/(\d+)/i.test(ua)) return `Chrome ${ua.match(/Chrome\/(\d+)/i)![1]}`; + if (/Firefox\/(\d+)/i.test(ua)) return `Firefox ${ua.match(/Firefox\/(\d+)/i)![1]}`; + if (/Safari\/(\d+)/i.test(ua) && !/Chrome/i.test(ua)) return 'Safari'; + if (/Edg\/(\d+)/i.test(ua)) return `Edge ${ua.match(/Edg\/(\d+)/i)![1]}`; + return ua.slice(0, 48) + (ua.length > 48 ? '…' : ''); + } Profile — libnovel + + +

Profile

@@ -151,6 +220,56 @@
+ +
+

Active sessions

+

These are all devices currently signed into your account. End any session you don't recognise.

+ + {#if revokeError} +
+ {revokeError} +
+ {/if} + + {#if sessions.length === 0} +

No session records found. Sessions are tracked from the next login.

+ {:else} +
    + {#each sessions as session (session.id)} +
  • +
    +
    + {parseUA(session.user_agent)} + {#if session.is_current} + This session + {/if} +
    + {#if session.ip} +

    {session.ip}

    + {/if} +

    + Signed in {formatDate(session.created_at)} + {#if session.last_seen && session.last_seen !== session.created_at} + · Last seen {formatDate(session.last_seen)} + {/if} +

    +
    + +
  • + {/each} +
+ {/if} +
+

Change password