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

16. Аутентификация (NextAuth)

Аутентификация — это проверка “кто ты?”. Реализовывать её вручную сложно и опасно. NextAuth.js (теперь Auth.js v5) берёт эту сложность на себя: OAuth провайдеры, JWT сессии, защита роутов — всё из коробки! Как надёжный охранник 💂 для твоего приложения.


Окно терминала
npm install next-auth@beta
npx 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', // перенаправление новых пользователей
},
});

app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth';
// Auth.js обрабатывает GET и POST сам
export const { GET, POST } = handlers;

// 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;
},
},
});

types/next-auth.d.ts
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.ts
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).*)'],
};

// app/dashboard/page.tsx — Server Component
import { 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>
);
}

app/components/UserMenu.tsx
'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>
);
}

app/providers.tsx
'use client';
import { SessionProvider } from 'next-auth/react';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<SessionProvider>
{children}
</SessionProvider>
);
}
// app/layout.tsx
import { Providers } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru">
<body>
<Providers>
{children}
</Providers>
</body>
</html>
);
}

prisma/schema.prisma
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])
}

КритерийJWTDatabase
ХранениеВ куке (stateless)В БД
СкоростьБыстро (нет запроса к БД)Медленнее (запрос к БД)
ИнвалидацияСложно (нужен blacklist)Легко (удаляем запись)
МасштабированиеГоризонтально без проблемНужна общая БД
Данные сессииОграничены размером куки (~4KB)Любые данные

Вывод: JWT для простых приложений, Database для критичных (нужно мгновенно выходить из сессии).