Files
libnovel/ui/src/routes/login/+page.server.ts
Admin 1eb70e9b9b
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 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
2026-03-07 11:53:16 +05:00

143 lines
4.8 KiB
TypeScript

import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
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;
export const load: PageServerLoad = async ({ locals }) => {
// Already logged in — send to home
if (locals.user) {
redirect(302, '/');
}
return {};
};
export const actions: Actions = {
login: async ({ request, cookies, locals }) => {
const data = await request.formData();
const username = (data.get('username') as string | null)?.trim() ?? '';
const password = (data.get('password') as string | null) ?? '';
if (!username || !password) {
return fail(400, { action: 'login', error: 'Username and password are required.' });
}
let user;
try {
user = await loginUser(username, password);
} catch (err) {
log.error('auth', 'login unexpected error', { username, err: String(err) });
return fail(500, { action: 'login', error: 'An error occurred. Please try again.' });
}
if (!user) {
return fail(401, { action: 'login', error: 'Invalid username or password.' });
}
// Merge any anonymous session progress into the user's account so that
// chapters read before logging in are preserved and portable across devices.
mergeSessionProgress(locals.sessionId, user.id).catch((err) =>
log.warn('auth', 'login: mergeSessionProgress failed (non-fatal)', { err: String(err) })
);
// 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,
sameSite: 'lax',
maxAge: ONE_YEAR
});
redirect(302, '/');
},
register: async ({ request, cookies, locals }) => {
const data = await request.formData();
const username = (data.get('username') as string | null)?.trim() ?? '';
const password = (data.get('password') as string | null) ?? '';
const confirm = (data.get('confirm') as string | null) ?? '';
if (!username || !password) {
return fail(400, { action: 'register', error: 'Username and password are required.' });
}
if (username.length < 3 || username.length > 32) {
return fail(400, {
action: 'register',
error: 'Username must be between 3 and 32 characters.'
});
}
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
return fail(400, {
action: 'register',
error: 'Username may only contain letters, numbers, underscores and hyphens.'
});
}
if (password.length < 8) {
return fail(400, {
action: 'register',
error: 'Password must be at least 8 characters.'
});
}
if (password !== confirm) {
return fail(400, { action: 'register', error: 'Passwords do not match.' });
}
let user;
try {
user = await createUser(username, password);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Registration failed.';
if (msg.includes('Username already taken')) {
return fail(409, { action: 'register', error: 'That username is already taken.' });
}
log.error('auth', 'register unexpected error', { username, err: String(err) });
return fail(500, { action: 'register', error: 'An error occurred. Please try again.' });
}
// Merge any anonymous session progress into the newly created account.
mergeSessionProgress(locals.sessionId, user.id).catch((err) =>
log.warn('auth', 'register: mergeSessionProgress failed (non-fatal)', { err: String(err) })
);
// 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,
sameSite: 'lax',
maxAge: ONE_YEAR
});
redirect(302, '/');
}
};