16. Аутентификация (NextAuth)
🔐 Аутентификация с NextAuth.js (Auth.js v5)
Заголовок раздела «🔐 Аутентификация с NextAuth.js (Auth.js v5)»Аутентификация — это проверка “кто ты?”. Реализовывать её вручную сложно и опасно. NextAuth.js (теперь Auth.js v5) берёт эту сложность на себя: OAuth провайдеры, JWT сессии, защита роутов — всё из коробки! Как надёжный охранник 💂 для твоего приложения.
📦 Установка и базовая конфигурация
Заголовок раздела «📦 Установка и базовая конфигурация»npm install next-auth@betanpx auth secret// auth.ts — главный конфиг (в корне проекта)import NextAuth from 'next-auth';import GitHub from 'next-auth/providers/github';import Google from 'next-auth/providers/google';import Credentials from 'next-auth/providers/credentials';import { PrismaAdapter } from '@auth/prisma-adapter';import { db } from '@/lib/db';import bcrypt from 'bcryptjs';
export const { handlers, auth, signIn, signOut } = NextAuth({ adapter: PrismaAdapter(db), // сохраняем сессии в БД
providers: [ // OAuth провайдеры GitHub({ clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET, }),
Google({ clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, }),
// Логин через логин/пароль Credentials({ credentials: { email: { label: 'Email', type: 'email' }, password: { label: 'Пароль', type: 'password' }, }, async authorize(credentials) { if (!credentials?.email || !credentials?.password) return null;
const user = await db.user.findUnique({ where: { email: credentials.email as string }, });
if (!user?.hashedPassword) return null;
const isValid = await bcrypt.compare( credentials.password as string, user.hashedPassword );
if (!isValid) return null;
return { id: user.id, name: user.name, email: user.email }; }, }), ],
session: { strategy: 'jwt', // 'jwt' или 'database' maxAge: 30 * 24 * 60 * 60, // 30 дней },
pages: { signIn: '/login', // кастомная страница логина signOut: '/goodbye', // кастомная страница выхода error: '/auth/error', // страница ошибок newUser: '/welcome', // перенаправление новых пользователей },});🌐 Route Handler для Auth
Заголовок раздела «🌐 Route Handler для Auth»import { handlers } from '@/auth';
// Auth.js обрабатывает GET и POST самexport const { GET, POST } = handlers;📋 Callbacks: настройка токенов и сессий
Заголовок раздела «📋 Callbacks: настройка токенов и сессий»// auth.ts — расширенная конфигурацияexport const { handlers, auth, signIn, signOut } = NextAuth({ // ...
callbacks: { // jwt — вызывается при создании/обновлении токена async jwt({ token, user, account, profile }) { // При первом входе (user существует) if (user) { token.id = user.id; token.role = user.role ?? 'user'; }
// При OAuth входе — сохраняем провайдера if (account) { token.provider = account.provider; token.accessToken = account.access_token; }
return token; },
// session — вызывается при запросе сессии async session({ session, token }) { // Переносим данные из токена в объект сессии if (session.user) { session.user.id = token.id as string; session.user.role = token.role as string; session.user.provider = token.provider as string; } return session; },
// signIn — позволяет/запрещает вход async signIn({ user, account, profile }) { // Разрешаем только корпоративные email if (account?.provider === 'google') { return profile?.email?.endsWith('@company.com') ?? false; } // Проверяем что email верифицирован if (!user.email) return false;
return true; // разрешаем вход },
// redirect — куда перенаправить после входа/выхода async redirect({ url, baseUrl }) { if (url.startsWith('/')) return `${baseUrl}${url}`; if (new URL(url).origin === baseUrl) return url; return baseUrl; }, },});🏷️ TypeScript: расширяем типы
Заголовок раздела «🏷️ TypeScript: расширяем типы»import type { DefaultSession } from 'next-auth';
declare module 'next-auth' { interface Session { user: { id: string; role: 'user' | 'admin' | 'moderator'; provider: string; } & DefaultSession['user']; }
interface User { role?: 'user' | 'admin' | 'moderator'; }}
declare module 'next-auth/jwt' { interface JWT { id: string; role: string; provider: string; accessToken?: string; }}🛡️ Защита роутов в Middleware
Заголовок раздела «🛡️ Защита роутов в Middleware»import { auth } from '@/auth';import { NextResponse } from 'next/server';
export default auth((request) => { const { auth: session, nextUrl } = request; const isLoggedIn = !!session?.user; const { pathname } = nextUrl;
// Защищённые маршруты const isProtectedRoute = pathname.startsWith('/dashboard') || pathname.startsWith('/profile') || pathname.startsWith('/settings');
// Только для авторизованных const isAdminRoute = pathname.startsWith('/admin');
if (isAdminRoute) { if (!isLoggedIn) { return NextResponse.redirect(new URL('/login', request.url)); } if (session?.user?.role !== 'admin') { return NextResponse.redirect(new URL('/403', request.url)); } }
if (isProtectedRoute && !isLoggedIn) { const loginUrl = new URL('/login', request.url); loginUrl.searchParams.set('callbackUrl', pathname); return NextResponse.redirect(loginUrl); }
// Уже залогинен, но открывает /login — редиректим if (pathname === '/login' && isLoggedIn) { return NextResponse.redirect(new URL('/dashboard', request.url)); }
return NextResponse.next();});
export const config = { matcher: ['/((?!api/auth|_next/static|_next/image|favicon.ico).*)'],};🖥️ Чтение сессии в Server Components
Заголовок раздела «🖥️ Чтение сессии в Server Components»// app/dashboard/page.tsx — Server Componentimport { auth } from '@/auth';import { redirect } from 'next/navigation';
export default async function DashboardPage() { // auth() — это Server-Side функция const session = await auth();
// Если нет сессии — редирект if (!session?.user) { redirect('/login'); }
// Используем данные пользователя const user = session.user;
return ( <div> <h1>Привет, {user.name}!</h1> <p>Email: {user.email}</p> <p>Роль: {user.role}</p> <img src={user.image ?? '/default-avatar.png'} alt="Аватар" /> </div> );}💻 Клиентская сессия: useSession
Заголовок раздела «💻 Клиентская сессия: useSession»'use client';
import { useSession, signIn, signOut } from 'next-auth/react';import Image from 'next/image';
export function UserMenu() { const { data: session, status } = useSession();
if (status === 'loading') { return <div className="animate-pulse w-8 h-8 rounded-full bg-gray-300" />; }
if (status === 'unauthenticated' || !session) { return ( <div className="flex gap-2"> <button onClick={() => signIn('github')} className="flex items-center gap-2 px-4 py-2 bg-gray-900 text-white rounded-lg" > <GitHubIcon /> Войти через GitHub </button> <button onClick={() => signIn('google')} className="flex items-center gap-2 px-4 py-2 border rounded-lg" > <GoogleIcon /> Войти через Google </button> </div> ); }
return ( <div className="flex items-center gap-3"> <Image src={session.user?.image ?? '/default-avatar.png'} alt={session.user?.name ?? 'User'} width={32} height={32} className="rounded-full" /> <div> <p className="font-medium text-sm">{session.user?.name}</p> <p className="text-xs text-gray-500">{session.user?.role}</p> </div> <button onClick={() => signOut({ callbackUrl: '/' })} className="text-sm text-red-500 hover:underline" > Выйти </button> </div> );}🔒 SessionProvider: оборачиваем приложение
Заголовок раздела «🔒 SessionProvider: оборачиваем приложение»'use client';
import { SessionProvider } from 'next-auth/react';
export function Providers({ children }: { children: React.ReactNode }) { return ( <SessionProvider> {children} </SessionProvider> );}
// app/layout.tsximport { Providers } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="ru"> <body> <Providers> {children} </Providers> </body> </html> );}🗄️ Prisma Schema для Auth.js
Заголовок раздела «🗄️ Prisma Schema для Auth.js»model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? role String @default("user") hashedPassword String? accounts Account[] sessions Session[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt}
model Account { id String @id @default(cuid()) userId String type String provider String providerAccountId String refresh_token String? @db.Text access_token String? @db.Text expires_at Int? token_type String? scope String? id_token String? @db.Text session_state String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([provider, providerAccountId])}
model Session { id String @id @default(cuid()) sessionToken String @unique userId String expires DateTime user User @relation(fields: [userId], references: [id], onDelete: Cascade)}
model VerificationToken { identifier String token String @unique expires DateTime @@unique([identifier, token])}⚖️ JWT vs Database Sessions
Заголовок раздела «⚖️ JWT vs Database Sessions»| Критерий | JWT | Database |
|---|---|---|
| Хранение | В куке (stateless) | В БД |
| Скорость | Быстро (нет запроса к БД) | Медленнее (запрос к БД) |
| Инвалидация | Сложно (нужен blacklist) | Легко (удаляем запись) |
| Масштабирование | Горизонтально без проблем | Нужна общая БД |
| Данные сессии | Ограничены размером куки (~4KB) | Любые данные |
Вывод: JWT для простых приложений, Database для критичных (нужно мгновенно выходить из сессии).