Попытка защитить всё веб-приложение исключительно через проверку токена в Edge Middleware создает иллюзию безопасности: злоумышленник может вызвать приватный Server Action напрямую в обход веб-страницы. Архитектура эшелонированной обороны (Defense-in-Depth) в Next.js App Router разделяет ответственность: Edge Middleware выполняет оптимистичный пре-роутинг и фильтрацию URL за менее 5 мс, а слой доступа к данным (Data Access Layer (DAL)) принудительно проверяет криптографическую сессию Jose JWT непосредственно внутри Server Components и Server Actions.
Неграмотная реализация авторизации в современных fullstack-фреймворках — источник 70% критических уязвимостей в корпоративных веб-системах:
window.localStorage открывает доступ к ним любому стороннему скрипту (аналитика, баннеры, зараженные npm-пакеты). В Fullstack Agentic Lab сессии упаковываются строго в зашифрованные куки с флагами HttpOnly; Secure; SameSite=Lax, делая чтение токена через JavaScript невозможным в принципе.jsonwebtoken, bcrypt) полагаются на бинарные C++ модули Node.js. При попытке их запуска в Next.js Edge Middleware сервер падает с ошибкой Module not found: Can't resolve 'crypto'. Решение — переход на современный стандарт jose, использующий нативный браузерно-пограничный SubtleCrypto.if (user.role === 'admin') в десятках компонентов.Двухуровневая модель jose + Edge Middleware + DAL verifySession() + RBAC гарантирует максимальную производительность CDN и нулевую вероятность несанкционированного доступа.
Сессия пользователя не хранится в оперативной памяти сервера (Stateless Architecture). Вместо этого клиент предъявляет криптографически подписанный токен JWT, содержащий идентификатор пользователя, роль и метку времени истечения (exp).
[ Incoming Browser Request ] (Кука: session=eyJhbGciOiJIUzI1NiIs...)
│
▼
[ 1. Edge Middleware (Edge Runtime) ]
│
├── Проверка пути: /dashboard (Private Route)
├── Быстрая расшифровка jose: jwtVerify(cookie, SECRET)
│ ├─ Невалиден ──> Редирект на /login?from=/dashboard
│ └─ Валиден ───> Передача запроса дальше: NextResponse.next()
▼
[ 2. Next.js App Router (Server Environment) ]
│
├── Layout / Page (Server Components)
│ │
│ ▼
│ [ Data Access Layer (DAL) ]
│ │ verifySession() (Мемоизирован через React.cache)
│ │ hasRole(['admin', 'manager'])
│ ▼
│ [ Database Query (Prisma / Drizzle) ]
│ └─ Только после подтверждения прав на чтение
│
└── Server Actions ('use server')
│
▼
[ Mandatory DAL verifySession() ]
└─ Защита от прямого вызова через POST Next-ActionКлючевой примитив безопасности — функция verifySession():
React.cache(): если макет layout.tsx и страница page.tsx оба вызывают verifySession(), расшифровка JWT и проверка в БД выполняются ровно один раз за запрос.redirect('/login') на уровне сервера, прерывая дальнейший рендеринг опасного поддерева.Генерация секретного ключа из process.env, подпись JWT через SignJWT и валидация через jwtVerify с поддержкой 7-дневной ротации.
Создание изолированной функции verifySession() с мемоизацией контекста запроса и валидацией ролей RBAC.
Настройка сопоставления маршрутов (matcher), защита приватных зон (/dashboard, /admin) и редирект неавторизованных пользователей.
Проверка учетных данных, установка cookie с параметрами HttpOnly, Secure, SameSite=Lax и безопасная инвалидация.
Внедрение вызова verifySession() в критические мутации для предотвращения атак прямого вызова (BOLA/IDOR).
Браузер отправляет запрос с автоматическим прикреплением HttpOnly-куки session
Middleware за 3 мс выполняет предварительную проверку наличия и валидности подписи токена
Запрос маршрутизируется в Server Component страницы /admin/users
Слой доступа извлекает сессию из React.cache и проверяет роль: user.role === 'admin'
Выполняется строго изолированный запрос к данным с фильтром по текущей организации
Клиент получает готовый серверный HTML без утечки приватных токенов в браузерный JS
Почему опасно проверять права доступа только в файле middleware.ts, полностью исключая вызов verifySession() из серверных действий Server Actions?
Какую проблему решает оборачивание функции проверки сессии в React.cache(async () => { ... }) в приложении Next.js с множеством Server Components?
src/lib/session.ts#Промышленная реализация шифрования, валидации и управления жизненным циклом сессионных кук на базе jose.
/**
* src/lib/session.ts
* Управление сессиями на базе Web Crypto API и библиотеки jose.
* Полностью совместимо с Node.js и Edge Runtime.
*/
import { SignJWT, jwtVerify } from 'jose'
import { cookies } from 'next/headers'
export type UserRole = 'superadmin' | 'admin' | 'manager' | 'customer'
export interface SessionPayload {
userId: string
email: string
role: UserRole
expiresAt: Date
[key: string]: unknown
}
const SECRET_KEY = process.env.SESSION_SECRET || 'agenticlab_super_secret_jwt_key_minimum_32_characters_long'
const encodedKey = new TextEncoder().encode(SECRET_KEY)
const COOKIE_NAME = 'agenticlab_session'
const SESSION_DURATION_DAYS = 7
/**
* Криптографическая подпись данных сессии (JWT HMAC-SHA256).
*/
export async function encryptSession(payload: SessionPayload): Promise<string> {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(`${SESSION_DURATION_DAYS}d`)
.sign(encodedKey)
}
/**
* Расшифровка и проверка подлинности токена сессии.
*/
export async function decryptSession(sessionToken: string | undefined): Promise<SessionPayload | null> {
if (!sessionToken) return null
try {
const { payload } = await jwtVerify(sessionToken, encodedKey, {
algorithms: ['HS256'],
})
return payload as unknown as SessionPayload
} catch (error) {
// Токен истек, поврежден или скомпрометирован
return null
}
}
/**
* Создание новой сессии и установка безопасной HttpOnly куки.
*/
export async function createSession(userId: string, email: string, role: UserRole): Promise<void> {
const expiresAt = new Date(Date.now() + SESSION_DURATION_DAYS * 24 * 60 * 60 * 1000)
const sessionToken = await encryptSession({ userId, email, role, expiresAt })
const cookieStore = await cookies()
cookieStore.set(COOKIE_NAME, sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
expires: expiresAt,
})
}
/**
* Уничтожение сессии при выходе из аккаунта.
*/
export async function deleteSession(): Promise<void> {
const cookieStore = await cookies()
cookieStore.delete(COOKIE_NAME)
}
/**
* Продление срока действия куки (Sliding Session Window).
*/
export async function updateSession(): Promise<void> {
const cookieStore = await cookies()
const session = cookieStore.get(COOKIE_NAME)?.value
const payload = await decryptSession(session)
if (!payload) return
const expiresAt = new Date(Date.now() + SESSION_DURATION_DAYS * 24 * 60 * 60 * 1000)
payload.expiresAt = expiresAt
const newToken = await encryptSession(payload)
cookieStore.set(COOKIE_NAME, newToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
expires: expiresAt,
})
}src/lib/dal.ts#Data Access Layer с мемоизацией запроса через React.cache() и ролевыми проверками RBAC.
import 'server-only'
import { cache } from 'react'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { decryptSession, UserRole, SessionPayload } from './session'
/**
* Проверка сессии с мемоизацией на уровне одного HTTP-запроса (React.cache).
* Если сессия недействительна, принудительно перенаправляет на страницу авторизации.
*/
export const verifySession = cache(async (): Promise<SessionPayload> => {
const cookieStore = await cookies()
const sessionCookie = cookieStore.get('agenticlab_session')?.value
const session = await decryptSession(sessionCookie)
if (!session?.userId) {
redirect('/login')
}
return session
})
/**
* Безопасное получение сессии без автоматического редиректа (для опционально публичных зон).
*/
export const getOptionalSession = cache(async (): Promise<SessionPayload | null> => {
const cookieStore = await cookies()
const sessionCookie = cookieStore.get('agenticlab_session')?.value
return decryptSession(sessionCookie)
})
/**
* Проверка прав доступа в соответствии с моделью ролей RBAC.
*/
export async function requireRole(allowedRoles: UserRole[]): Promise<SessionPayload> {
const session = await verifySession()
if (!allowedRoles.includes(session.role)) {
// Пользователь авторизован, но его прав недостаточно для ресурса
redirect('/forbidden')
}
return session
}middleware.ts#Edge Middleware для оптимистичной защиты приватных URL и предотвращения несанкционированной навигации.
import { NextRequest, NextResponse } from 'next/server'
import { decryptSession } from '@/lib/session'
// Список защищенных префиксов путей
const protectedPrefixes = ['/dashboard', '/account', '/settings']
const adminPrefixes = ['/admin']
const authRoutes = ['/login', '/register']
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
const sessionCookie = request.cookies.get('agenticlab_session')?.value
const session = await decryptSession(sessionCookie)
const isProtectedPath = protectedPrefixes.some((p) => pathname.startsWith(p))
const isAdminPath = adminPrefixes.some((p) => pathname.startsWith(p))
const isAuthRoute = authRoutes.some((p) => pathname.startsWith(p))
// 1. Попытка входа на защищенную страницу без сессии
if ((isProtectedPath || isAdminPath) && !session) {
const loginUrl = new URL('/login', request.url)
// Сохраняем целевой путь для обратного редиректа после логина
loginUrl.searchParams.set('returnUrl', pathname)
return NextResponse.redirect(loginUrl)
}
// 2. Попытка доступа к панели администрирования без роли admin/superadmin
if (isAdminPath && session && !['admin', 'superadmin'].includes(session.role)) {
return NextResponse.redirect(new URL('/forbidden', request.url))
}
// 3. Авторизованный пользователь пытается зайти на страницу /login
if (isAuthRoute && session) {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
return NextResponse.next()
}
// Исключаем статические ассеты, фавиконки и публичные изображения из обработки
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
}src/actions/auth-actions.ts#Серверные экшены логина, логаута и защищенной мутации с интеграцией useActionState.
'use server'
import { redirect } from 'next/navigation'
import { createSession, deleteSession } from '@/lib/session'
import { verifySession, requireRole } from '@/lib/dal'
export interface AuthActionResult {
success: boolean
message?: string
}
/**
* Серверное действие: Авторизация пользователя.
*/
export async function loginAction(
_prevState: AuthActionResult,
formData: FormData
): Promise<AuthActionResult> {
const email = (formData.get('email') as string)?.trim().toLowerCase()
const password = formData.get('password') as string
if (!email || !password) {
return { success: false, message: 'Заполните email и пароль' }
}
// Имитация верификации пользователя (в продакшене: db.user.findUnique + bcrypt.compare)
await new Promise((resolve) => setTimeout(resolve, 350))
if (email === '[email protected]' && password === 'SecretAdmin123!') {
await createSession('usr_admin_1', email, 'admin')
redirect('/admin')
}
if (email === '[email protected]' && password === 'SecretUser123!') {
await createSession('usr_client_2', email, 'customer')
redirect('/dashboard')
}
return { success: false, message: 'Неверный адрес электронной почты или пароль' }
}
/**
* Серверное действие: Завершение сессии (Logout).
*/
export async function logoutAction(): Promise<void> {
await deleteSession()
redirect('/login')
}
/**
* Пример защищенного административного экшена: Изменение настроек системы.
*/
export async function updateSystemConfigAction(configKey: string, configValue: string) {
// ЭШЕЛОНИРОВАННАЯ ЗАЩИТА: обязательный вызов DAL внутри экшена!
const session = await requireRole(['admin', 'superadmin'])
console.log(`[Audit Log] Настройки изменены администратором ${session.email}: ${configKey}=${configValue}`)
return {
success: true,
message: 'Параметры системы успешно обновлены',
}
}jsonwebtoken в Middleware#// ОШИБКА: сломает сборку Next.js в Edge Runtime
import jwt from 'jsonwebtoken' // Module not found: Can't resolve 'crypto'
// ПРАВИЛЬНО: использование легковесной jose на базе Web Crypto API
import { jwtVerify } from 'jose'Если вы написали логику проверки ролей только в middleware.ts, любой злоумышленник может выполнить прямой POST-запрос к функции updateSystemConfigAction минуя страницу админки. Обязательно вызывайте await verifySession() или await requireRole() внутри каждого чувствительного Server Action.
В Next.js вызов redirect('/url') работает через выброс специального внутреннего исключения NEXT_REDIRECT. Если обернуть redirect в наивный try { ... } catch (e) { ... }, перенаправление будет перехвачено и подавлено:
// ОШИБКА: подавляет редирект Next.js!
try {
await deleteSession()
redirect('/login')
} catch (e) {
return { message: 'Ошибка выхода' } // Редирект НЕ сработает!
}
// ПРАВИЛЬНО: выносить redirect за пределы блока try/catch
try {
await deleteSession()
} catch (e) {
return { message: 'Ошибка удаления куки' }
}
redirect('/login')