Files
web-app-launcher/src/hooks.server.ts
T
alexei.dolgolyov b9f3a2ca0b feat(auth): Session model + remember-me
Replace the single `user.refreshToken` column with a proper Session
table so users can have multiple concurrent sessions (phone, laptop,
etc.), each with their own refresh token, expiry, label, and
remember-me flag.

- Add Session model (id, userId, tokenHash, label, userAgent,
  ipAddress, rememberMe, lastUsedAt, expiresAt).
- Drop `User.refreshToken` and `User.refreshTokenExpiresAt`.
- authService: new createSession/validateSession/rotateSession/
  revokeSession/listUserSessions helpers; remove refresh-token-on-user
  functions.
- sessionCookies helper now issues a session_id cookie alongside
  access_token and refresh_token; rotateSessionCookies keeps the same
  session id on refresh.
- Login form adds a "Keep me signed in for 30 days" checkbox;
  TTL is 7d by default, 30d with remember-me.
- User-Agent parsed into a friendly label ("Chrome on Windows") for
  the upcoming sessions page.
- hooks.server.ts, refresh endpoint, logout, register, oauth callback,
  and onboarding all switched to the new session API.
2026-04-16 03:41:52 +03:00

159 lines
4.7 KiB
TypeScript

import type { Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import { verifyAccessToken } from '$lib/server/services/authService.js';
import * as authService from '$lib/server/services/authService.js';
import * as userService from '$lib/server/services/userService.js';
import * as apiTokenService from '$lib/server/services/apiTokenService.js';
import { extractBearerToken } from '$lib/server/middleware/authenticate.js';
import { isBoardGuestAccessible } from '$lib/server/middleware/guestAccess.js';
import { initBackupScheduler } from '$lib/server/jobs/backupScheduler.js';
import {
clearSessionCookies,
rotateSessionCookies,
COOKIE_NAMES
} from '$lib/server/utils/sessionCookies.js';
// Initialize backup scheduler on server startup
initBackupScheduler();
const PUBLIC_PATHS = ['/login', '/register', '/auth/', '/api/health', '/api/onboarding', '/status'];
function isPublicPath(pathname: string): boolean {
return PUBLIC_PATHS.some((path) => pathname === path || pathname.startsWith(path));
}
export const handle: Handle = async ({ event, resolve }) => {
event.locals.user = null;
event.locals.session = null;
event.locals.apiTokenScope = null;
const accessToken = event.cookies.get(COOKIE_NAMES.ACCESS_TOKEN);
const refreshToken = event.cookies.get(COOKIE_NAMES.REFRESH_TOKEN);
const sessionId = event.cookies.get(COOKIE_NAMES.SESSION_ID);
if (accessToken) {
try {
const payload = verifyAccessToken(accessToken);
const user = await userService.findById(payload.userId);
event.locals.user = {
id: user.id,
email: user.email,
displayName: user.displayName,
role: user.role as 'admin' | 'user'
};
event.locals.session = {
id: sessionId ?? payload.userId,
expiresAt: new Date(Date.now() + 15 * 60 * 1000)
};
} catch {
// Access token invalid/expired — try refresh below
}
}
// If no valid session but refresh + session id exist, try to rotate.
if (!event.locals.user && refreshToken && sessionId) {
try {
const session = await authService.validateSession(sessionId, refreshToken);
if (session) {
const user = await userService.findById(session.userId);
await rotateSessionCookies(
event.cookies,
session.id,
{ id: user.id, email: user.email, role: user.role },
session.rememberMe
);
event.locals.user = {
id: user.id,
email: user.email,
displayName: user.displayName,
role: user.role as 'admin' | 'user'
};
event.locals.session = {
id: session.id,
expiresAt: new Date(Date.now() + 15 * 60 * 1000)
};
}
} catch {
clearSessionCookies(event.cookies);
}
}
// Bearer API tokens (no session cookie).
if (!event.locals.user) {
const bearerToken = extractBearerToken(event);
if (bearerToken) {
try {
const tokenResult = await apiTokenService.validateToken(bearerToken);
if (tokenResult) {
const user = await userService.findById(tokenResult.userId);
event.locals.user = {
id: user.id,
email: user.email,
displayName: user.displayName,
role: user.role as 'admin' | 'user'
};
event.locals.session = {
id: user.id,
expiresAt: new Date(Date.now() + 15 * 60 * 1000)
};
event.locals.apiTokenScope = tokenResult.scope as 'read' | 'write' | 'admin';
}
} catch {
// API token validation failed — continue as unauthenticated
}
}
}
const { pathname } = event.url;
// API token scope enforcement
if (event.locals.apiTokenScope) {
const method = event.request.method;
const scope = event.locals.apiTokenScope;
const isWriteMethod =
method === 'POST' || method === 'PATCH' || method === 'PUT' || method === 'DELETE';
const isAdminPath = pathname.startsWith('/api/admin') || pathname.startsWith('/admin');
if (scope === 'read' && isWriteMethod) {
return new Response(
JSON.stringify({
success: false,
data: null,
error: 'API token scope "read" does not allow write operations'
}),
{ status: 403, headers: { 'Content-Type': 'application/json' } }
);
}
if (scope !== 'admin' && isAdminPath) {
return new Response(
JSON.stringify({
success: false,
data: null,
error: 'API token scope does not allow admin operations'
}),
{ status: 403, headers: { 'Content-Type': 'application/json' } }
);
}
}
if (!event.locals.user && !isPublicPath(pathname)) {
const boardMatch = pathname.match(/^\/boards\/([^/]+)/);
if (boardMatch) {
const boardId = boardMatch[1];
const isGuestAccessible = await isBoardGuestAccessible(boardId);
if (isGuestAccessible) {
return resolve(event);
}
}
if (pathname === '/') {
return resolve(event);
}
throw redirect(302, '/login');
}
return resolve(event);
};