Add session management: track active sessions, show on profile, allow revocation
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 1s
Deploy / Cleanup Preview (push) Has been skipped
CI / UI / Build (pull_request) Failing after 13s
CI / Scraper / Lint (pull_request) Successful in 19s
CI / Scraper / Test (pull_request) Successful in 20s
CI / Scraper / Build (pull_request) Successful in 12s

- 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
This commit is contained in:
Admin
2026-03-07 11:53:16 +05:00
parent 70dd14e5c8
commit 1eb70e9b9b
9 changed files with 406 additions and 20 deletions

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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,

View File

@@ -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<ReturnType<typeof listUserSessions>> = [];
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
}))
};
};

View File

@@ -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<Session[]>(data.sessions ?? []);
let revokingId = $state<string | null>(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 ? '…' : '');
}
</script>
<svelte:head>
<title>Profile — libnovel</title>
</svelte:head>
<!-- Hidden logout form used when user ends their own session -->
<form id="logout-form" method="POST" action="/logout" class="hidden"></form>
<div class="max-w-xl mx-auto space-y-10">
<div>
<h1 class="text-2xl font-bold text-zinc-100">Profile</h1>
@@ -151,6 +220,56 @@
</div>
</section>
<!-- ── Active sessions ──────────────────────────────────────────────────── -->
<section class="bg-zinc-800 rounded-xl border border-zinc-700 p-6 space-y-4">
<h2 class="text-lg font-semibold text-zinc-100">Active sessions</h2>
<p class="text-sm text-zinc-400">These are all devices currently signed into your account. End any session you don't recognise.</p>
{#if revokeError}
<div class="rounded-lg bg-red-900/40 border border-red-700 px-4 py-2.5 text-sm text-red-300">
{revokeError}
</div>
{/if}
{#if sessions.length === 0}
<p class="text-sm text-zinc-500 italic">No session records found. Sessions are tracked from the next login.</p>
{:else}
<ul class="space-y-2">
{#each sessions as session (session.id)}
<li class="flex items-start justify-between gap-3 rounded-lg px-4 py-3 {session.is_current ? 'bg-amber-400/10 border border-amber-400/30' : 'bg-zinc-700/50 border border-zinc-600/50'}">
<div class="min-w-0 space-y-0.5">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-medium text-zinc-100 truncate">{parseUA(session.user_agent)}</span>
{#if session.is_current}
<span class="shrink-0 text-xs font-semibold px-1.5 py-0.5 rounded bg-amber-400/20 text-amber-300 border border-amber-400/40">This session</span>
{/if}
</div>
{#if session.ip}
<p class="text-xs text-zinc-400 font-mono">{session.ip}</p>
{/if}
<p class="text-xs text-zinc-500">
Signed in {formatDate(session.created_at)}
{#if session.last_seen && session.last_seen !== session.created_at}
· Last seen {formatDate(session.last_seen)}
{/if}
</p>
</div>
<button
onclick={() => revokeSession(session)}
disabled={revokingId === session.id}
class="shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50
{session.is_current
? 'bg-red-900/40 text-red-300 border border-red-700/60 hover:bg-red-900/70'
: 'bg-zinc-600/60 text-zinc-300 border border-zinc-500/50 hover:bg-zinc-600'}"
>
{revokingId === session.id ? '…' : session.is_current ? 'Sign out' : 'End'}
</button>
</li>
{/each}
</ul>
{/if}
</section>
<!-- ── Change password ──────────────────────────────────────────────────── -->
<section class="bg-zinc-800 rounded-xl border border-zinc-700 p-6 space-y-4">
<h2 class="text-lg font-semibold text-zinc-100">Change password</h2>