Перейти к содержимому

26. SvelteKit: Аутентификация

🔐 SvelteKit Аутентификация: Защищаем приложение

Заголовок раздела «🔐 SvelteKit Аутентификация: Защищаем приложение»

Привет! 👋 Аутентификация — это охранник на входе в клуб 🏴. Он проверяет пригласительные (токены/куки), знает кто VIP (admin), и не пускает чужих. В SvelteKit есть несколько подходов — от простых куки до OAuth с социальными сетями.


Подход Сложность Когда использовать
──────────────────────────────────────────────────────────
Куки + JWT (вручную) Средняя Контроль над каждой деталью
Auth.js (NextAuth) Низкая OAuth + социальные сети
Lucia Auth Средняя Гибкость + DB сессии
Supabase Auth Низкая Supabase как backend
PocketBase Auth Низкая Self-hosted альтернатива

Самый прозрачный подход — понимаешь каждую строчку:

// 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.ts
import 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: проверяем аутентификацию при каждом запросе»
src/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);
};

// 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 — самый популярный способ добавить OAuth (GitHub, Google, etc.):

Окно терминала
npm install @auth/sveltekit
src/auth.ts
import { 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 handle
export { 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 — минималистичная библиотека для аутентификации с отличной поддержкой TypeScript:

Окно терминала
npm install lucia @lucia-auth/adapter-prisma
src/lib/server/lucia.ts
import { 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 токены:
✅ 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;
}
}

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. Пользователь разрешает доступ на GitHub
4. GitHub редиректит на YOUR_APP/auth/callback/github?
code=TEMP_CODE&
state=RANDOM_STRING
5. SvelteKit обменивает code на access_token (server-side!)
6. Получает данные пользователя через GitHub API
7. Создаёт/обновляет запись в БД
8. Устанавливает сессионную куку
9. Редирект на /dashboard

src/routes/api/protected/+server.ts
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: 'Секретные данные для админа' });
};