26. SvelteKit: Аутентификация
🔐 SvelteKit Аутентификация: Защищаем приложение
Заголовок раздела «🔐 SvelteKit Аутентификация: Защищаем приложение»Привет! 👋 Аутентификация — это охранник на входе в клуб 🏴. Он проверяет пригласительные (токены/куки), знает кто VIP (admin), и не пускает чужих. В SvelteKit есть несколько подходов — от простых куки до OAuth с социальными сетями.
🎯 Обзор подходов к аутентификации
Заголовок раздела «🎯 Обзор подходов к аутентификации»Подход Сложность Когда использовать──────────────────────────────────────────────────────────Куки + JWT (вручную) Средняя Контроль над каждой детальюAuth.js (NextAuth) Низкая OAuth + социальные сетиLucia Auth Средняя Гибкость + DB сессииSupabase Auth Низкая Supabase как backendPocketBase Auth Низкая Self-hosted альтернатива🍪 Cookie-based аутентификация с нуля
Заголовок раздела «🍪 Cookie-based аутентификация с нуля»Самый прозрачный подход — понимаешь каждую строчку:
// src/lib/server/auth.ts — утилиты аутентификацииimport { db } from './database';import { createHash, randomBytes } from 'crypto';
// Хэшируем пароль:export async function hashPassword(password: string): Promise<string> { const salt = randomBytes(16).toString('hex'); const hash = createHash('sha256') .update(password + salt) .digest('hex'); return `${salt}:${hash}`;}
export async function verifyPassword(password: string, stored: string): Promise<boolean> { const [salt, hash] = stored.split(':'); const attempt = createHash('sha256') .update(password + salt) .digest('hex'); return attempt === hash;}
// Создаём сессию:export async function createSession(userId: string) { const token = randomBytes(32).toString('hex'); const session = await db.session.create({ data: { token, userId, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 дней }, }); return session;}
// Верифицируем сессию:export async function verifySession(token: string) { const session = await db.session.findUnique({ where: { token }, include: { user: true }, });
if (!session) return null; if (session.expiresAt < new Date()) { await db.session.delete({ where: { token } }); return null; }
return session.user;}
// Удаляем сессию:export async function deleteSession(token: string) { await db.session.delete({ where: { token } }).catch(() => {});}// src/routes/(auth)/login/+page.server.tsimport type { Actions, PageServerLoad } from './$types';import { fail, redirect } from '@sveltejs/kit';import { hashPassword, verifyPassword, createSession } from '$lib/server/auth';
export const load: PageServerLoad = async ({ locals }) => { if (locals.user) redirect(307, '/dashboard'); return {};};
export const actions: Actions = { login: async ({ request, cookies }) => { const data = await request.formData(); const email = data.get('email') as string; const password = data.get('password') as string;
if (!email || !password) { return fail(400, { error: 'Заполните все поля' }); }
const user = await db.user.findUnique({ where: { email } });
if (!user || !(await verifyPassword(password, user.passwordHash))) { // Намеренно не говорим что именно неверно — безопасность! return fail(401, { error: 'Неверный email или пароль' }); }
const session = await createSession(user.id);
cookies.set('session', session.token, { path: '/', httpOnly: true, // JavaScript не может читать secure: true, // Только HTTPS sameSite: 'lax', // Защита от CSRF maxAge: 60 * 60 * 24 * 7, // 7 дней });
redirect(303, '/dashboard'); },
register: async ({ request, cookies }) => { const data = await request.formData(); const name = data.get('name') as string; const email = data.get('email') as string; const password = data.get('password') as string;
// Валидация: const errors: Record<string, string> = {}; if (!name?.trim()) errors.name = 'Имя обязательно'; if (!email?.includes('@')) errors.email = 'Неверный email'; if (password?.length < 8) errors.password = 'Минимум 8 символов';
if (Object.keys(errors).length > 0) { return fail(400, { errors }); }
// Проверяем дубликат: const existing = await db.user.findUnique({ where: { email } }); if (existing) return fail(409, { errors: { email: 'Email уже занят' } });
const user = await db.user.create({ data: { name, email, passwordHash: await hashPassword(password) }, });
const session = await createSession(user.id); cookies.set('session', session.token, { path: '/', httpOnly: true, secure: true, sameSite: 'lax', maxAge: 60 * 60 * 24 * 7, });
redirect(303, '/dashboard'); },};🪝 hooks.server.ts: проверяем аутентификацию при каждом запросе
Заголовок раздела «🪝 hooks.server.ts: проверяем аутентификацию при каждом запросе»import type { Handle } from '@sveltejs/kit';import { verifySession } from '$lib/server/auth';
export const handle: Handle = async ({ event, resolve }) => { const token = event.cookies.get('session');
if (token) { const user = await verifySession(token);
if (user) { // Добавляем пользователя в locals — доступен во всех load функциях event.locals.user = { id: user.id, name: user.name, email: user.email, role: user.role, }; } else { // Удаляем невалидную куку: event.cookies.delete('session', { path: '/' }); } }
return resolve(event);};🛡️ Защита маршрутов через +layout.server.ts
Заголовок раздела «🛡️ Защита маршрутов через +layout.server.ts»// src/routes/(app)/+layout.server.ts// Этот layout защищает все страницы внутри (app) группыimport type { LayoutServerLoad } from './$types';import { redirect } from '@sveltejs/kit';
export const load: LayoutServerLoad = async ({ locals, url }) => { if (!locals.user) { // Сохраняем URL куда пытались попасть: const redirectTo = url.pathname; redirect(307, `/login?redirectTo=${encodeURIComponent(redirectTo)}`); }
return { user: locals.user, };};// src/routes/(admin)/+layout.server.ts// Дополнительная проверка роли:import type { LayoutServerLoad } from './$types';import { error, redirect } from '@sveltejs/kit';
export const load: LayoutServerLoad = async ({ locals }) => { if (!locals.user) redirect(307, '/login');
if (locals.user.role !== 'admin') { error(403, 'Только для администраторов'); }
return { user: locals.user };};🔑 Auth.js (бывший NextAuth) для SvelteKit
Заголовок раздела «🔑 Auth.js (бывший NextAuth) для SvelteKit»Auth.js — самый популярный способ добавить OAuth (GitHub, Google, etc.):
npm install @auth/sveltekitimport { SvelteKitAuth } from '@auth/sveltekit';import GitHub from '@auth/sveltekit/providers/github';import Google from '@auth/sveltekit/providers/google';import { PrismaAdapter } from '@auth/prisma-adapter';import { db } from '$lib/server/database';
export const { handle, signIn, signOut } = SvelteKitAuth({ adapter: PrismaAdapter(db),
providers: [ GitHub({ clientId: process.env.GITHUB_ID, clientSecret: process.env.GITHUB_SECRET, }), Google({ clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, }), ],
// Callbacks для кастомизации: callbacks: { session: async ({ session, user }) => { // Добавляем id и role в сессию: if (session.user && user) { session.user.id = user.id; session.user.role = (user as any).role ?? 'user'; } return session; },
authorized: async ({ auth }) => { // Возвращает true если авторизован return !!auth; }, },
pages: { signIn: '/login', // Кастомная страница входа error: '/auth/error', // Страница ошибки },});// src/hooks.server.ts — подключаем Auth.js handleexport { handle } from './auth';<!-- src/routes/(auth)/login/+page.svelte --><script lang="ts"> import { signIn } from '@auth/sveltekit/client';</script>
<h1>Войти</h1>
<button onclick={() => signIn('github', { redirectTo: '/dashboard' })}> 🐙 Войти через GitHub</button>
<button onclick={() => signIn('google', { redirectTo: '/dashboard' })}> 🔵 Войти через Google</button>// Использование в load функциях:import { auth } from '../auth';import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => { const session = await auth(event);
if (!session?.user) { redirect(307, '/login'); }
return { session };};🦙 Lucia Auth: современный подход
Заголовок раздела «🦙 Lucia Auth: современный подход»Lucia — минималистичная библиотека для аутентификации с отличной поддержкой TypeScript:
npm install lucia @lucia-auth/adapter-prismaimport { Lucia } from 'lucia';import { PrismaAdapter } from '@lucia-auth/adapter-prisma';import { db } from './database';import { dev } from '$app/environment';
const adapter = new PrismaAdapter(db.session, db.user);
export const lucia = new Lucia(adapter, { sessionCookie: { attributes: { secure: !dev, // Только HTTPS в продакшне }, },
// Что передаётся в сессию: getUserAttributes: (attributes) => { return { username: attributes.username, email: attributes.email, role: attributes.role, }; },});
declare module 'lucia' { interface Register { Lucia: typeof lucia; DatabaseUserAttributes: { username: string; email: string; role: 'user' | 'admin'; }; }}// src/hooks.server.ts с Lucia:import { lucia } from '$lib/server/lucia';import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => { const sessionId = event.cookies.get(lucia.sessionCookieName);
if (!sessionId) { event.locals.user = null; event.locals.session = null; return resolve(event); }
const { session, user } = await lucia.validateSession(sessionId);
if (session?.fresh) { // Обновляем куку если сессия продлена: const sessionCookie = lucia.createSessionCookie(session.id); event.cookies.set(sessionCookie.name, sessionCookie.value, { path: '.', ...sessionCookie.attributes, }); }
if (!session) { const blankCookie = lucia.createBlankSessionCookie(); event.cookies.set(blankCookie.name, blankCookie.value, { path: '.', ...blankCookie.attributes, }); }
event.locals.user = user; event.locals.session = session;
return resolve(event);};🎫 JWT vs Database сессии
Заголовок раздела «🎫 JWT vs Database сессии»JWT токены: ✅ Stateless — не нужна БД для проверки ✅ Масштабируемость — любой сервер может проверить ✅ Можно передать данные в payload ❌ Нельзя инвалидировать до истечения ❌ Нужно хранить refresh токены ❌ Если украли — воспользуются до истечения
Database сессии: ✅ Мгновенная инвалидация (logout везде) ✅ Полный контроль над сессиями ✅ Можно видеть активные устройства ❌ Нужен запрос в БД при каждом запросе ❌ Менее масштабируемы (решается через Redis)// JWT подход:import * as jose from 'jose';
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET);
export async function createJWT(payload: { userId: string; role: string }) { return await new jose.SignJWT(payload) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() .setExpirationTime('7d') .sign(SECRET);}
export async function verifyJWT(token: string) { try { const { payload } = await jose.jwtVerify(token, SECRET); return payload as { userId: string; role: string }; } catch { return null; }}🔄 OAuth flow: понимаем что происходит
Заголовок раздела «🔄 OAuth flow: понимаем что происходит»1. Пользователь нажимает "Войти через GitHub"2. Редирект на https://github.com/login/oauth/authorize? client_id=YOUR_CLIENT_ID& redirect_uri=YOUR_APP/auth/callback/github& scope=user:email& state=RANDOM_STRING ← CSRF защита3. Пользователь разрешает доступ на GitHub4. GitHub редиректит на YOUR_APP/auth/callback/github? code=TEMP_CODE& state=RANDOM_STRING5. SvelteKit обменивает code на access_token (server-side!)6. Получает данные пользователя через GitHub API7. Создаёт/обновляет запись в БД8. Устанавливает сессионную куку9. Редирект на /dashboard🌐 Защита API routes
Заголовок раздела «🌐 Защита API routes»import type { RequestHandler } from './$types';import { json, error } from '@sveltejs/kit';
export const GET: RequestHandler = async ({ locals }) => { if (!locals.user) { error(401, { message: 'Не авторизован', code: 'UNAUTHORIZED', }); }
if (locals.user.role !== 'admin') { error(403, { message: 'Доступ запрещён', code: 'FORBIDDEN', }); }
return json({ secret: 'Секретные данные для админа' });};