b9f3a2ca0b
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.
159 lines
4.7 KiB
TypeScript
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);
|
|
};
|