From 2c001df32270632c49d6bfba28c990f5b6d77ec0 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 24 Mar 2026 20:45:14 +0300 Subject: [PATCH] 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. --- plans/mvp-web-app-launcher/CONTEXT.md | 4 +- plans/mvp-web-app-launcher/PLAN.md | 4 +- .../phase-3-authentication.md | 57 +++++---- src/hooks.server.ts | 117 ++++++++++++++++++ src/lib/server/middleware/authenticate.ts | 22 ++++ src/lib/server/middleware/authorize.ts | 25 ++++ src/lib/server/middleware/guestAccess.ts | 49 ++++++++ src/lib/server/utils/jwt.ts | 10 ++ src/lib/server/utils/password.ts | 5 + src/routes/+layout.server.ts | 7 ++ src/routes/+layout.svelte | 11 ++ src/routes/+page.server.ts | 30 +++++ src/routes/+page.svelte | 21 +++- src/routes/auth/logout/+server.ts | 21 ++++ src/routes/auth/refresh/+server.ts | 55 ++++++++ src/routes/login/+page.server.ts | 77 ++++++++++++ src/routes/login/+page.svelte | 73 +++++++++++ src/routes/register/+page.server.ts | 100 +++++++++++++++ src/routes/register/+page.svelte | 91 ++++++++++++++ 19 files changed, 751 insertions(+), 28 deletions(-) create mode 100644 src/hooks.server.ts create mode 100644 src/lib/server/middleware/authenticate.ts create mode 100644 src/lib/server/middleware/authorize.ts create mode 100644 src/lib/server/middleware/guestAccess.ts create mode 100644 src/lib/server/utils/jwt.ts create mode 100644 src/lib/server/utils/password.ts create mode 100644 src/routes/+layout.server.ts create mode 100644 src/routes/+layout.svelte create mode 100644 src/routes/+page.server.ts create mode 100644 src/routes/auth/logout/+server.ts create mode 100644 src/routes/auth/refresh/+server.ts create mode 100644 src/routes/login/+page.server.ts create mode 100644 src/routes/login/+page.svelte create mode 100644 src/routes/register/+page.server.ts create mode 100644 src/routes/register/+page.svelte diff --git a/plans/mvp-web-app-launcher/CONTEXT.md b/plans/mvp-web-app-launcher/CONTEXT.md index 7a93f7c..446cd61 100644 --- a/plans/mvp-web-app-launcher/CONTEXT.md +++ b/plans/mvp-web-app-launcher/CONTEXT.md @@ -2,7 +2,7 @@ ## Current State -Phase 2 (Database Schema & Services Layer) is complete. The Prisma schema defines 10 models (User, Group, UserGroup, App, AppStatus, Board, Section, Widget, Permission, SystemSettings). Initial migration has been applied and the SQLite database created at `data/launcher.db`. Seed data includes an admin user, default groups, 5 sample apps, and a default board with 2 sections. Six server-side services provide full CRUD operations. Zod validators, TypeScript type definitions, shared constants, and an API response envelope utility are all in place. Build does not pass yet (Big Bang strategy — expected). +Phase 3 (Authentication System) is complete. The full local authentication flow is implemented: login, registration, logout, and JWT token refresh. `hooks.server.ts` validates access tokens on every request, injects `event.locals.user`/`session`, and silently rotates expired tokens via refresh tokens. Protected routes redirect to `/login`; guest-accessible board routes are exempt. Login and registration pages use Superforms + Zod with inline validation errors. Registration respects the `SystemSettings.registrationEnabled` toggle. Reusable middleware helpers (`requireAuth`, `requireAdmin`, `requireRole`) are available for downstream phases. The root layout injects user session into all page data. The root page redirects to the default board or login. `jwt.ts` and `password.ts` are thin re-exports from `authService` (no duplication). Build does not pass yet (Big Bang strategy — expected). ## Temporary Workarounds @@ -13,7 +13,7 @@ Phase 2 (Database Schema & Services Layer) is complete. The Prisma schema define ## Cross-Phase Dependencies - Phase 2 depends on Phase 1 (project scaffolding, Prisma setup) -- Phase 3 depends on Phase 2 (user/group models, auth service) +- Phase 3 depends on Phase 2 (user/group models, auth service) ✅ - Phase 4 depends on Phase 2 (app model, services layer) - Phase 5 depends on Phase 2 (board/section/widget models) and Phase 4 (app widget references apps) - Phase 6 depends on Phases 3-5 (admin needs auth, app, board entities) diff --git a/plans/mvp-web-app-launcher/PLAN.md b/plans/mvp-web-app-launcher/PLAN.md index 8f99c0f..721ec12 100644 --- a/plans/mvp-web-app-launcher/PLAN.md +++ b/plans/mvp-web-app-launcher/PLAN.md @@ -29,7 +29,7 @@ Build a self-hosted web application launcher/dashboard for a TrueNAS server envi - [x] Phase 1: Project Scaffolding & Tooling [backend] → [subplan](./phase-1-scaffolding.md) - [x] Phase 2: Database Schema & Services Layer [backend] → [subplan](./phase-2-database-services.md) -- [ ] Phase 3: Authentication System [fullstack] → [subplan](./phase-3-authentication.md) +- [x] Phase 3: Authentication System [fullstack] → [subplan](./phase-3-authentication.md) - [ ] Phase 4: App Registry & Healthcheck [fullstack] → [subplan](./phase-4-app-healthcheck.md) - [ ] Phase 5: Board, Section & Widget System [fullstack] → [subplan](./phase-5-board-widgets.md) - [ ] Phase 6: Admin Panel [fullstack] → [subplan](./phase-6-admin-panel.md) @@ -42,7 +42,7 @@ Build a self-hosted web application launcher/dashboard for a TrueNAS server envi |-------|--------|--------|--------|-------|-----------| | Phase 1: Scaffolding | backend | ✅ Complete | ✅ | ⬜ | ⬜ | | Phase 2: Database & Services | backend | ✅ Complete | ⬜ | ⬜ | ⬜ | -| Phase 3: Authentication | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 3: Authentication | fullstack | ✅ Complete | ⬜ | ⬜ | ⬜ | | Phase 4: App & Healthcheck | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 5: Board & Widgets | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 6: Admin Panel | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | diff --git a/plans/mvp-web-app-launcher/phase-3-authentication.md b/plans/mvp-web-app-launcher/phase-3-authentication.md index 4820338..f2b6781 100644 --- a/plans/mvp-web-app-launcher/phase-3-authentication.md +++ b/plans/mvp-web-app-launcher/phase-3-authentication.md @@ -1,6 +1,6 @@ # Phase 3: Authentication System -**Status:** ⬜ Not Started +**Status:** ✅ Complete **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** fullstack @@ -9,21 +9,21 @@ Implement the full local authentication flow: login, registration, session manag ## Tasks -- [ ] Task 1: Implement `src/lib/server/utils/jwt.ts` — sign, verify, refresh token generation -- [ ] Task 2: Implement `src/lib/server/utils/password.ts` — bcrypt hash/compare -- [ ] Task 3: Implement `src/hooks.server.ts` — auth middleware, session injection into `event.locals` -- [ ] Task 4: Create `src/routes/login/+page.server.ts` — login form action (Superforms + Zod) -- [ ] Task 5: Create `src/routes/login/+page.svelte` — login page UI -- [ ] Task 6: Create `src/routes/register/+page.server.ts` — registration form action (respects admin toggle) -- [ ] Task 7: Create `src/routes/register/+page.svelte` — registration page UI -- [ ] Task 8: Create `src/routes/auth/refresh/+server.ts` — token refresh endpoint -- [ ] Task 9: Create `src/routes/+layout.server.ts` — root layout load: inject user session -- [ ] Task 10: Create `src/routes/+layout.svelte` — root layout shell (minimal, polished in Phase 7) -- [ ] Task 11: Implement `src/lib/server/middleware/authenticate.ts` — reusable auth check helper -- [ ] Task 12: Implement `src/lib/server/middleware/authorize.ts` — role-based access check -- [ ] Task 13: Implement `src/lib/server/middleware/guestAccess.ts` — guest mode board visibility -- [ ] Task 14: Create `src/routes/+page.svelte` — root page (redirect to default board or login) -- [ ] Task 15: Create logout endpoint/action — invalidate refresh token, clear cookies +- [x] Task 1: Implement `src/lib/server/utils/jwt.ts` — thin re-export from authService (already implemented in Phase 2) +- [x] Task 2: Implement `src/lib/server/utils/password.ts` — thin re-export from authService (already implemented in Phase 2) +- [x] Task 3: Implement `src/hooks.server.ts` — auth middleware, session injection into `event.locals` +- [x] Task 4: Create `src/routes/login/+page.server.ts` — login form action (Superforms + Zod) +- [x] Task 5: Create `src/routes/login/+page.svelte` — login page UI +- [x] Task 6: Create `src/routes/register/+page.server.ts` — registration form action (respects admin toggle) +- [x] Task 7: Create `src/routes/register/+page.svelte` — registration page UI +- [x] Task 8: Create `src/routes/auth/refresh/+server.ts` — token refresh endpoint +- [x] Task 9: Create `src/routes/+layout.server.ts` — root layout load: inject user session +- [x] Task 10: Create `src/routes/+layout.svelte` — root layout shell (minimal, polished in Phase 7) +- [x] Task 11: Implement `src/lib/server/middleware/authenticate.ts` — reusable auth check helper +- [x] Task 12: Implement `src/lib/server/middleware/authorize.ts` — role-based access check +- [x] Task 13: Implement `src/lib/server/middleware/guestAccess.ts` — guest mode board visibility +- [x] Task 14: Create `src/routes/+page.svelte` — root page (redirect to default board or login) +- [x] Task 15: Create logout endpoint/action — invalidate refresh token, clear cookies ## Files to Modify/Create - `src/hooks.server.ts` — auth middleware @@ -40,7 +40,7 @@ Implement the full local authentication flow: login, registration, session manag - `src/routes/+layout.server.ts` - `src/routes/+layout.svelte` - `src/routes/+page.svelte` -- `src/app.d.ts` — augment `Locals` with user session type +- `src/app.d.ts` — augment `Locals` with user session type (already done in Phase 2) ## Acceptance Criteria - Users can register (when enabled) and log in with email/password @@ -57,14 +57,27 @@ Implement the full local authentication flow: login, registration, session manag - Store refresh tokens in DB (User model) for server-side invalidation - OAuth is deferred to Phase 2 of the project (post-MVP) - Registration toggle is read from SystemSettings -- ⚠️ Big Bang: login page will be functional but unstyled/minimal until Phase 7 +- Big Bang: login page will be functional but unstyled/minimal until Phase 7 ## Review Checklist -- [ ] All tasks completed -- [ ] Code follows project conventions -- [ ] No unintended side effects +- [x] All tasks completed +- [x] Code follows project conventions +- [x] No unintended side effects - [ ] Build passes - [ ] Tests pass (new + existing) ## Handoff to Next Phase - + +**What's ready for Phase 4:** +- Full local auth flow is implemented: login, registration, logout, token refresh. +- `hooks.server.ts` validates JWT access tokens on every request and injects `event.locals.user` and `event.locals.session`. Expired access tokens are silently refreshed via refresh token rotation. +- Protected routes (anything except `/login`, `/register`, `/auth/*`, `/api/health`) redirect unauthenticated users to `/login`. +- Guest mode support: `guestAccess.ts` middleware checks `isGuestAccessible` on boards; hooks allow unauthenticated access to guest-accessible board routes. +- Reusable middleware helpers available: `requireAuth()`, `isAuthenticated()`, `requireRole()`, `requireAdmin()`. +- Login/register pages use Superforms + Zod with inline error display. +- Registration respects `SystemSettings.registrationEnabled` toggle. +- Root layout (`+layout.server.ts`) injects `user` into all page data. +- Root page (`+page.server.ts`) redirects to default board (authenticated) or guest board (unauthenticated) or `/login`. +- Logout endpoint at `POST /auth/logout` revokes refresh token and clears all auth cookies. +- `jwt.ts` and `password.ts` are thin re-exports from `authService` (no duplication). +- A `refresh_user_id` cookie is used alongside `refresh_token` to identify the user during token rotation (since refresh tokens are stored hashed per-user). diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..369d5eb --- /dev/null +++ b/src/hooks.server.ts @@ -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); +}; diff --git a/src/lib/server/middleware/authenticate.ts b/src/lib/server/middleware/authenticate.ts new file mode 100644 index 0000000..58f59e7 --- /dev/null +++ b/src/lib/server/middleware/authenticate.ts @@ -0,0 +1,22 @@ +import { redirect } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; + +/** + * Reusable authentication check helper. + * Throws a redirect to /login if the user is not authenticated. + * Returns the authenticated user from event.locals. + */ +export function requireAuth(event: RequestEvent) { + const user = event.locals.user; + if (!user) { + throw redirect(302, '/login'); + } + return user; +} + +/** + * Check if the current request has an authenticated user without redirecting. + */ +export function isAuthenticated(event: RequestEvent): boolean { + return event.locals.user !== null; +} diff --git a/src/lib/server/middleware/authorize.ts b/src/lib/server/middleware/authorize.ts new file mode 100644 index 0000000..82010f3 --- /dev/null +++ b/src/lib/server/middleware/authorize.ts @@ -0,0 +1,25 @@ +import { error } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import { requireAuth } from './authenticate.js'; +import { UserRole } from '$lib/utils/constants.js'; + +/** + * Role-based access check. Ensures the user is authenticated and has one of the required roles. + * Throws a 403 error if the user's role is not in the allowed list. + */ +export function requireRole(event: RequestEvent, ...allowedRoles: string[]) { + const user = requireAuth(event); + + if (!allowedRoles.includes(user.role)) { + throw error(403, { message: 'Insufficient permissions' }); + } + + return user; +} + +/** + * Shorthand: require admin role. + */ +export function requireAdmin(event: RequestEvent) { + return requireRole(event, UserRole.ADMIN); +} diff --git a/src/lib/server/middleware/guestAccess.ts b/src/lib/server/middleware/guestAccess.ts new file mode 100644 index 0000000..2e64427 --- /dev/null +++ b/src/lib/server/middleware/guestAccess.ts @@ -0,0 +1,49 @@ +import { prisma } from '../prisma.js'; + +/** + * Check if a board is guest-accessible (visible to unauthenticated users). + */ +export async function isBoardGuestAccessible(boardId: string): Promise { + const board = await prisma.board.findUnique({ + where: { id: boardId }, + select: { isGuestAccessible: true } + }); + return board?.isGuestAccessible ?? false; +} + +/** + * Get all guest-accessible boards. + */ +export async function getGuestAccessibleBoards() { + return prisma.board.findMany({ + where: { isGuestAccessible: true }, + orderBy: { name: 'asc' }, + select: { + id: true, + name: true, + icon: true, + description: true, + isDefault: true + } + }); +} + +/** + * Get the default guest-accessible board (if any). + * Returns the first board that is both default and guest-accessible, + * or the first guest-accessible board if none is default. + */ +export async function getDefaultGuestBoard() { + const defaultBoard = await prisma.board.findFirst({ + where: { isGuestAccessible: true, isDefault: true }, + select: { id: true, name: true } + }); + + if (defaultBoard) return defaultBoard; + + return prisma.board.findFirst({ + where: { isGuestAccessible: true }, + orderBy: { name: 'asc' }, + select: { id: true, name: true } + }); +} diff --git a/src/lib/server/utils/jwt.ts b/src/lib/server/utils/jwt.ts new file mode 100644 index 0000000..bba335d --- /dev/null +++ b/src/lib/server/utils/jwt.ts @@ -0,0 +1,10 @@ +/** + * JWT utilities — thin re-exports from authService. + * authService already handles sign, verify, and refresh token generation. + */ +export { + signAccessToken, + verifyAccessToken, + generateRefreshToken, + getRefreshTokenExpiry +} from '../services/authService.js'; diff --git a/src/lib/server/utils/password.ts b/src/lib/server/utils/password.ts new file mode 100644 index 0000000..f8772d2 --- /dev/null +++ b/src/lib/server/utils/password.ts @@ -0,0 +1,5 @@ +/** + * Password utilities — thin re-exports from authService. + * authService already handles bcrypt hash and compare. + */ +export { hashPassword, verifyPassword } from '../services/authService.js'; diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts new file mode 100644 index 0000000..5d5a2ce --- /dev/null +++ b/src/routes/+layout.server.ts @@ -0,0 +1,7 @@ +import type { LayoutServerLoad } from './$types.js'; + +export const load: LayoutServerLoad = async ({ locals }) => { + return { + user: locals.user + }; +}; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..8b8c3cd --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,11 @@ + + +
+ {@render children()} +
diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts new file mode 100644 index 0000000..6545935 --- /dev/null +++ b/src/routes/+page.server.ts @@ -0,0 +1,30 @@ +import type { PageServerLoad } from './$types.js'; +import { redirect } from '@sveltejs/kit'; +import { prisma } from '$lib/server/prisma.js'; +import { getDefaultGuestBoard } from '$lib/server/middleware/guestAccess.js'; + +export const load: PageServerLoad = async ({ locals }) => { + if (locals.user) { + // Authenticated user: redirect to their default board + const defaultBoard = await prisma.board.findFirst({ + where: { isDefault: true }, + select: { id: true } + }); + + if (defaultBoard) { + throw redirect(302, `/boards/${defaultBoard.id}`); + } + + // No default board — stay on root page + return { user: locals.user }; + } + + // Unauthenticated: check for guest-accessible board + const guestBoard = await getDefaultGuestBoard(); + if (guestBoard) { + throw redirect(302, `/boards/${guestBoard.id}`); + } + + // No guest board available — redirect to login + throw redirect(302, '/login'); +}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 1441679..1274518 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,5 +1,7 @@ @@ -7,5 +9,20 @@
-

Web App Launcher

+
+

Web App Launcher

+ {#if data.user} +

+ Welcome, {data.user.displayName}. No default board is configured yet. +

+
+ +
+ {/if} +
diff --git a/src/routes/auth/logout/+server.ts b/src/routes/auth/logout/+server.ts new file mode 100644 index 0000000..4da8938 --- /dev/null +++ b/src/routes/auth/logout/+server.ts @@ -0,0 +1,21 @@ +import { redirect } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import * as authService from '$lib/server/services/authService.js'; + +export const POST: RequestHandler = async ({ cookies, locals }) => { + // Revoke refresh token in database + if (locals.user) { + try { + await authService.revokeRefreshToken(locals.user.id); + } catch { + // Best-effort revocation — continue with cookie cleanup + } + } + + // Clear all auth cookies + cookies.delete('access_token', { path: '/' }); + cookies.delete('refresh_token', { path: '/' }); + cookies.delete('refresh_user_id', { path: '/' }); + + throw redirect(302, '/login'); +}; diff --git a/src/routes/auth/refresh/+server.ts b/src/routes/auth/refresh/+server.ts new file mode 100644 index 0000000..c40b496 --- /dev/null +++ b/src/routes/auth/refresh/+server.ts @@ -0,0 +1,55 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import * as authService from '$lib/server/services/authService.js'; +import * as userService from '$lib/server/services/userService.js'; +import { error as apiError } from '$lib/server/utils/response.js'; + +const COOKIE_BASE = { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' as const, + path: '/' +}; + +export const POST: RequestHandler = async ({ cookies }) => { + const refreshToken = cookies.get('refresh_token'); + const userId = cookies.get('refresh_user_id'); + + if (!refreshToken || !userId) { + return json(apiError('No refresh token provided'), { status: 401 }); + } + + try { + const isValid = await authService.validateRefreshToken(userId, refreshToken); + if (!isValid) { + // Clear stale cookies + cookies.delete('access_token', { path: '/' }); + cookies.delete('refresh_token', { path: '/' }); + cookies.delete('refresh_user_id', { path: '/' }); + return json(apiError('Invalid or expired refresh token'), { status: 401 }); + } + + const user = await userService.findById(userId); + const tokens = await authService.rotateTokens(user.id, user.email, user.role); + + cookies.set('access_token', tokens.accessToken, { + ...COOKIE_BASE, + maxAge: 900 + }); + cookies.set('refresh_token', tokens.refreshToken, { + ...COOKIE_BASE, + maxAge: 604800 + }); + + return json({ + success: true, + data: { expiresIn: 900 }, + error: null + }); + } catch { + cookies.delete('access_token', { path: '/' }); + cookies.delete('refresh_token', { path: '/' }); + cookies.delete('refresh_user_id', { path: '/' }); + return json(apiError('Token refresh failed'), { status: 401 }); + } +}; diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts new file mode 100644 index 0000000..abd8493 --- /dev/null +++ b/src/routes/login/+page.server.ts @@ -0,0 +1,77 @@ +import type { Actions, PageServerLoad } from './$types.js'; +import { superValidate, setError } from 'sveltekit-superforms'; +import { zod } from 'sveltekit-superforms/adapters'; +import { fail, redirect } from '@sveltejs/kit'; +import { loginSchema } from '$lib/utils/validators.js'; +import * as userService from '$lib/server/services/userService.js'; +import * as authService from '$lib/server/services/authService.js'; + +const COOKIE_BASE = { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' as const, + path: '/' +}; + +export const load: PageServerLoad = async ({ locals }) => { + // If already logged in, redirect to home + if (locals.user) { + throw redirect(302, '/'); + } + + const form = await superValidate(zod(loginSchema)); + return { form }; +}; + +export const actions: Actions = { + default: async ({ request, cookies }) => { + const form = await superValidate(request, zod(loginSchema)); + + if (!form.valid) { + return fail(400, { form }); + } + + const { email, password } = form.data; + + // Find user by email + const user = await userService.findByEmail(email); + if (!user) { + return setError(form, 'email', 'Invalid email or password'); + } + + // Verify password + if (!user.password) { + return setError(form, 'email', 'This account does not use password authentication'); + } + + const passwordValid = await authService.verifyPassword(password, user.password); + if (!passwordValid) { + return setError(form, 'email', 'Invalid email or password'); + } + + // Generate tokens + const accessToken = authService.signAccessToken({ + userId: user.id, + email: user.email, + role: user.role + }); + const refreshToken = authService.generateRefreshToken(); + await authService.saveRefreshToken(user.id, refreshToken); + + // Set cookies + cookies.set('access_token', accessToken, { + ...COOKIE_BASE, + maxAge: 900 // 15 minutes + }); + cookies.set('refresh_token', refreshToken, { + ...COOKIE_BASE, + maxAge: 604800 // 7 days + }); + cookies.set('refresh_user_id', user.id, { + ...COOKIE_BASE, + maxAge: 604800 // 7 days + }); + + throw redirect(302, '/'); + } +}; diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte new file mode 100644 index 0000000..c68604a --- /dev/null +++ b/src/routes/login/+page.svelte @@ -0,0 +1,73 @@ + + + + Login — Web App Launcher + + +
+
+

Sign In

+ +
+
+ + + {#if $errors.email} +

{$errors.email[0]}

+ {/if} +
+ +
+ + + {#if $errors.password} +

{$errors.password[0]}

+ {/if} +
+ + +
+ +

+ Don't have an account? + Register +

+
+
diff --git a/src/routes/register/+page.server.ts b/src/routes/register/+page.server.ts new file mode 100644 index 0000000..2e27f48 --- /dev/null +++ b/src/routes/register/+page.server.ts @@ -0,0 +1,100 @@ +import type { Actions, PageServerLoad } from './$types.js'; +import { superValidate, setError } from 'sveltekit-superforms'; +import { zod } from 'sveltekit-superforms/adapters'; +import { fail, redirect, error } from '@sveltejs/kit'; +import { registerSchema } from '$lib/utils/validators.js'; +import { prisma } from '$lib/server/prisma.js'; +import * as userService from '$lib/server/services/userService.js'; +import * as authService from '$lib/server/services/authService.js'; +import * as groupService from '$lib/server/services/groupService.js'; +import { DEFAULTS } from '$lib/utils/constants.js'; + +const COOKIE_BASE = { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' as const, + path: '/' +}; + +async function isRegistrationEnabled(): Promise { + const settings = await prisma.systemSettings.findUnique({ + where: { id: DEFAULTS.SYSTEM_SETTINGS_ID }, + select: { registrationEnabled: true } + }); + return settings?.registrationEnabled ?? true; +} + +export const load: PageServerLoad = async ({ locals }) => { + // If already logged in, redirect to home + if (locals.user) { + throw redirect(302, '/'); + } + + const registrationEnabled = await isRegistrationEnabled(); + if (!registrationEnabled) { + throw error(403, { message: 'Registration is currently disabled' }); + } + + const form = await superValidate(zod(registerSchema)); + return { form, registrationEnabled }; +}; + +export const actions: Actions = { + default: async ({ request, cookies }) => { + const registrationEnabled = await isRegistrationEnabled(); + if (!registrationEnabled) { + throw error(403, { message: 'Registration is currently disabled' }); + } + + const form = await superValidate(request, zod(registerSchema)); + + if (!form.valid) { + return fail(400, { form }); + } + + const { email, password, displayName } = form.data; + + // Check email uniqueness + const existingUser = await userService.findByEmail(email); + if (existingUser) { + return setError(form, 'email', 'An account with this email already exists'); + } + + // Create user + const user = await userService.create({ + email, + password, + displayName, + authProvider: 'local', + role: 'user' + }); + + // Add user to default groups + await groupService.addUserToDefaultGroups(user.id); + + // Auto-login: generate tokens + const accessToken = authService.signAccessToken({ + userId: user.id, + email: user.email, + role: user.role + }); + const refreshToken = authService.generateRefreshToken(); + await authService.saveRefreshToken(user.id, refreshToken); + + // Set cookies + cookies.set('access_token', accessToken, { + ...COOKIE_BASE, + maxAge: 900 + }); + cookies.set('refresh_token', refreshToken, { + ...COOKIE_BASE, + maxAge: 604800 + }); + cookies.set('refresh_user_id', user.id, { + ...COOKIE_BASE, + maxAge: 604800 + }); + + throw redirect(302, '/'); + } +}; diff --git a/src/routes/register/+page.svelte b/src/routes/register/+page.svelte new file mode 100644 index 0000000..09ab372 --- /dev/null +++ b/src/routes/register/+page.svelte @@ -0,0 +1,91 @@ + + + + Register — Web App Launcher + + +
+
+

Create Account

+ +
+
+ + + {#if $errors.displayName} +

{$errors.displayName[0]}

+ {/if} +
+ +
+ + + {#if $errors.email} +

{$errors.email[0]}

+ {/if} +
+ +
+ + + {#if $errors.password} +

{$errors.password[0]}

+ {/if} +
+ + +
+ +

+ Already have an account? + Sign in +

+
+