JWT, httpOnly cookies, серверные middleware для защиты маршрутов, useAuth composable и refresh token flow
Авторизация в Nuxt 3 — это не просто хранение токена в localStorage. SSR требует синхронизации состояния между сервером и клиентом, а безопасность требует httpOnly cookies вместо доступного из JS хранилища.
Два основных подхода и их компромиссы.
Cookie-based сессии: сервер создаёт сессию, отправляет cookie с session_id. При каждом запросе сервер читает cookie и загружает данные сессии из хранилища. Плюсы: простота инвалидации (удали сессию на сервере), нет проблем с SSR. Минусы: требует хранилища сессий (Redis), не масштабируется без sticky sessions или централизованного хранилища.
JWT токены: сервер выдаёт подписанный токен, клиент хранит его и отправляет в каждом запросе. Сервер верифицирует подпись без обращения к БД. Плюсы: stateless, масштабируется. Минусы: трудная инвалидация (нужен blacklist), нужна стратегия refresh токенов.
Для Nuxt оптимальна гибридная схема: JWT токен в httpOnly cookie. Cookie отправляется автоматически с каждым запросом, недоступен из JavaScript (защита от XSS), а на сервере Nuxt может читать его для SSR-запросов.
nuxt-auth-utils — минималистичный модуль для управления сессией в Nuxt через зашифрованные cookies:
npm install nuxt-auth-utilsДобавьте в nuxt.config.ts:
export default defineNuxtConfig({
modules: ['nuxt-auth-utils'],
runtimeConfig: {
session: {
password: process.env.NUXT_SESSION_PASSWORD // Минимум 32 символа
}
}
})Обязательно задайте NUXT_SESSION_PASSWORD в .env — это ключ шифрования cookie:
NUXT_SESSION_PASSWORD=my-super-secret-key-at-least-32-chars-longВесь процесс авторизации — на сервере. Клиент отправляет учётные данные, сервер проверяет и создаёт сессию:
// server/api/auth/login.post.ts
import { z } from 'zod'
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(8)
})
export default defineEventHandler(async (event) => {
// Читаем и валидируем тело запроса
const body = await readValidatedBody(event, loginSchema.parse)
// Проверяем учётные данные в БД (используйте bcrypt для сравнения паролей)
const user = await db.users.findByEmail(body.email)
if (!user || !await verifyPassword(body.password, user.passwordHash)) {
throw createError({
statusCode: 401,
statusMessage: 'Неверный email или пароль'
})
}
if (!user.isActive) {
throw createError({
statusCode: 403,
statusMessage: 'Аккаунт заблокирован'
})
}
// Создаём сессию через nuxt-auth-utils
await setUserSession(event, {
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role
},
loggedInAt: new Date().toISOString()
})
return { success: true, user: { id: user.id, name: user.name } }
})// server/api/auth/logout.post.ts
export default defineEventHandler(async (event) => {
// clearUserSession удаляет cookie и инвалидирует сессию
await clearUserSession(event)
return { success: true }
})useUserSession() — основной composable для работы с сессией. Автоматически синхронизируется с сервером:
<!-- components/UserMenu.vue -->
<script setup lang="ts">
const { user, loggedIn, fetch: refreshSession } = useUserSession()
async function handleLogout() {
// Вызываем эндпоинт логаута
await $fetch('/api/auth/logout', { method: 'POST' })
// Обновляем состояние сессии на клиенте
await refreshSession()
// Редирект на главную
await navigateTo('/')
}
</script>
<template>
<div v-if="loggedIn">
<span>{{ user?.name }}</span>
<button @click="handleLogout">Выйти</button>
</div>
<NuxtLink v-else to="/login">Войти</NuxtLink>
</template>Route middleware — правильное место для проверки авторизации. Middleware выполняется до рендеринга страницы как на сервере, так и на клиенте:
// middleware/auth.ts — применяется вручную через definePageMeta
export default defineNuxtRouteMiddleware((to) => {
const { loggedIn } = useUserSession()
if (!loggedIn.value) {
// Сохраняем целевую страницу для редиректа после логина
return navigateTo({
path: '/login',
query: { redirect: to.fullPath }
})
}
})Применение к конкретным страницам:
<!-- pages/dashboard.vue -->
<script setup lang="ts">
// Применяем middleware auth к этой странице
definePageMeta({
middleware: ['auth']
})
</script>Если большинство страниц требуют авторизации, удобнее использовать глобальный middleware с белым списком открытых страниц:
// middleware/auth.global.ts
const publicRoutes = ['/', '/login', '/register', '/about', '/blog']
export default defineNuxtRouteMiddleware((to) => {
const { loggedIn } = useUserSession()
// Проверяем что маршрут не публичный
const isPublic = publicRoutes.some(route =>
to.path === route || to.path.startsWith('/blog/')
)
if (!isPublic && !loggedIn.value) {
return navigateTo(`/login?redirect=${encodeURIComponent(to.fullPath)}`)
}
})API эндпоинты тоже нужно защищать. requireUserSession бросает 401 если сессия отсутствует:
// server/api/profile.get.ts
export default defineEventHandler(async (event) => {
// Требует активной сессии — иначе 401
const { user } = await requireUserSession(event)
// user типизирован из вашей сессии
const profile = await db.users.findById(user.id)
return profile
})Для сложных проверок прав:
// server/api/admin/users.get.ts
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event)
if (user.role !== 'admin') {
throw createError({
statusCode: 403,
statusMessage: 'Недостаточно прав'
})
}
return await db.users.findAll()
})Для сложных приложений удобно хранить данные пользователя в Pinia store, синхронизируя с сессией:
// stores/auth.ts
export const useAuthStore = defineStore('auth', () => {
const { user: sessionUser, loggedIn } = useUserSession()
// Дополнительные вычисленные данные
const isAdmin = computed(() => sessionUser.value?.role === 'admin')
const displayName = computed(() =>
sessionUser.value?.name || sessionUser.value?.email || 'Гость'
)
async function login(email: string, password: string) {
const result = await $fetch('/api/auth/login', {
method: 'POST',
body: { email, password }
})
// useUserSession автоматически обновится при следующей проверке
await navigateTo('/dashboard')
return result
}
async function logout() {
await $fetch('/api/auth/logout', { method: 'POST' })
await navigateTo('/')
}
return { loggedIn, isAdmin, displayName, login, logout }
})nuxt-auth-utils поддерживает OAuth "из коробки":
// server/api/auth/github.get.ts
export default defineOAuthGitHubEventHandler({
config: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
scope: ['user:email']
},
async onSuccess(event, { user, tokens }) {
// user — данные от GitHub API
// Ищем или создаём пользователя в нашей БД
let dbUser = await db.users.findByGithubId(user.id)
if (!dbUser) {
dbUser = await db.users.create({
githubId: user.id,
email: user.email,
name: user.name,
avatarUrl: user.avatar_url
})
}
// Устанавливаем сессию
await setUserSession(event, {
user: {
id: dbUser.id,
email: dbUser.email,
name: dbUser.name,
role: dbUser.role
}
})
return sendRedirect(event, '/dashboard')
},
async onError(event, error) {
console.error('GitHub OAuth error:', error)
return sendRedirect(event, '/login?error=oauth_failed')
}
})Добавьте кнопку на страницу логина:
<template>
<a href="/api/auth/github" class="oauth-btn">
Войти через GitHub
</a>
</template>Роли хранятся в сессии и проверяются в middleware и server routes:
// middleware/admin.ts
export default defineNuxtRouteMiddleware(() => {
const { user, loggedIn } = useUserSession()
if (!loggedIn.value) {
return navigateTo('/login')
}
if (user.value?.role !== 'admin') {
throw createError({
statusCode: 403,
statusMessage: 'Доступ запрещён'
})
}
})Для гранулярного контроля — composable с проверкой прав:
// composables/usePermissions.ts
export function usePermissions() {
const { user } = useUserSession()
const can = (permission: string): boolean => {
const role = user.value?.role
const permissions: Record<string, string[]> = {
admin: ['read', 'write', 'delete', 'manage_users'],
moderator: ['read', 'write'],
user: ['read']
}
return permissions[role ?? 'user']?.includes(permission) ?? false
}
return { can }
}Ошибка 1: хранить токен в localStorage. Он доступен любому JS-коду — XSS атака полностью компрометирует авторизацию. Используйте httpOnly cookies.
Ошибка 2: не проверять авторизацию на сервере. Middleware на клиенте — это UX, не безопасность. Клиент может его обойти. Всегда проверяйте в server routes.
Ошибка 3: race condition при инициализации. Если несколько запросов идут до того как сессия загружена, возникают конкурентные проблемы. Используйте await при загрузке сессии в плагинах.
Ошибка 4: hydration mismatch. Если сервер рендерит "авторизованный" контент, а клиент видит "неавторизованный" (или наоборот) — возникает hydration error. Убедитесь что useUserSession() одинаково работает на обеих сторонах.
Ошибка 5: отсутствие CSRF защиты. nuxt-auth-utils использует SameSite=Lax для cookies — это базовая защита. Для критических операций добавьте CSRF токен.
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.