feat(mvp): phase 3 - authentication system

Implement local auth flow: login, registration, logout, JWT access/refresh
tokens in HTTP-only cookies, hooks.server.ts middleware, guest mode support,
Superforms + Zod validation, and reusable auth/authorize middleware.
This commit is contained in:
2026-03-24 20:45:14 +03:00
parent f1b1aa5975
commit 2c001df322
19 changed files with 751 additions and 28 deletions
+117
View File
@@ -0,0 +1,117 @@
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 { isBoardGuestAccessible } from '$lib/server/middleware/guestAccess.js';
const PUBLIC_PATHS = ['/login', '/register', '/auth/', '/api/health'];
function isPublicPath(pathname: string): boolean {
return PUBLIC_PATHS.some((path) => pathname === path || pathname.startsWith(path));
}
const ACCESS_TOKEN_COOKIE = 'access_token';
const REFRESH_TOKEN_COOKIE = 'refresh_token';
const COOKIE_BASE = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/'
};
export const handle: Handle = async ({ event, resolve }) => {
// Initialize locals
event.locals.user = null;
event.locals.session = null;
const accessToken = event.cookies.get(ACCESS_TOKEN_COOKIE);
const refreshToken = event.cookies.get(REFRESH_TOKEN_COOKIE);
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: payload.userId,
expiresAt: new Date(Date.now() + 15 * 60 * 1000)
};
} catch {
// Access token invalid/expired — try refresh below
}
}
// If no valid session but refresh token exists, attempt rotation
if (!event.locals.user && refreshToken) {
try {
// We need to find the user by refresh token.
// The refresh token is stored hashed per-user, so we need
// a userId from somewhere. We store it in a separate cookie.
const userIdFromCookie = event.cookies.get('refresh_user_id');
if (userIdFromCookie) {
const isValid = await authService.validateRefreshToken(userIdFromCookie, refreshToken);
if (isValid) {
const user = await userService.findById(userIdFromCookie);
const tokens = await authService.rotateTokens(user.id, user.email, user.role);
// Set new cookies
event.cookies.set(ACCESS_TOKEN_COOKIE, tokens.accessToken, {
...COOKIE_BASE,
maxAge: 900 // 15 minutes
});
event.cookies.set(REFRESH_TOKEN_COOKIE, tokens.refreshToken, {
...COOKIE_BASE,
maxAge: 604800 // 7 days
});
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)
};
}
}
} catch {
// Refresh failed — clear stale cookies
event.cookies.delete(ACCESS_TOKEN_COOKIE, { path: '/' });
event.cookies.delete(REFRESH_TOKEN_COOKIE, { path: '/' });
event.cookies.delete('refresh_user_id', { path: '/' });
}
}
// Route protection
const { pathname } = event.url;
if (!event.locals.user && !isPublicPath(pathname)) {
// Check if this is a guest-accessible board route
const boardMatch = pathname.match(/^\/boards\/([^/]+)/);
if (boardMatch) {
const boardId = boardMatch[1];
const isGuestAccessible = await isBoardGuestAccessible(boardId);
if (isGuestAccessible) {
return resolve(event);
}
}
// Root path — allow through so +page.server.ts can handle redirect logic
if (pathname === '/') {
return resolve(event);
}
throw redirect(302, '/login');
}
return resolve(event);
};