Как реализовать аутентификацию в Next.js

Понимание аутентификации критически важно для защиты данных вашего приложения. На этой странице объясняется, какие функции React и Next.js использовать для реализации аутентификации.

Перед началом работы полезно разбить процесс на три концепции:

  1. Аутентификация: Проверяет, является ли пользователь тем, за кого себя выдает. Требует от пользователя подтверждения личности с помощью чего-то, что у него есть, например, имени пользователя и пароля.
  2. Управление сеансом: Отслеживает состояние аутентификации пользователя между запросами.
  3. Авторизация: Определяет, к каким маршрутам и данным пользователь может получить доступ.

На этой диаграмме показан процесс аутентификации с использованием функций React и Next.js:

Диаграмма, показывающая процесс аутентификации с функциями React и Next.js

Примеры на этой странице демонстрируют базовую аутентификацию по имени пользователя и паролю в учебных целях. Хотя вы можете реализовать собственное решение для аутентификации, для повышения безопасности и упрощения мы рекомендуем использовать библиотеку аутентификации. Они предлагают встроенные решения для аутентификации, управления сеансами и авторизации, а также дополнительные функции, такие как социальные входы, многофакторная аутентификация и управление доступом на основе ролей. Список можно найти в разделе Библиотеки аутентификации.

Аутентификация

Функциональность регистрации и входа

Вы можете использовать элемент <form> с Server Actions в React и useActionState для сбора учетных данных пользователя, проверки полей формы и вызова API или базы данных вашего провайдера аутентификации.

Поскольку Server Actions всегда выполняются на сервере, они обеспечивают безопасную среду для обработки логики аутентификации.

Вот шаги для реализации функциональности регистрации/входа:

1. Сбор учетных данных пользователя

Для сбора учетных данных создайте форму, которая вызывает Server Action при отправке. Например, форма регистрации, принимающая имя пользователя, email и пароль:

import { signup } from '@/app/actions/auth'

export function SignupForm() {
  return (
    <form action={signup}>
      <div>
        <label htmlFor="name">Имя</label>
        <input id="name" name="name" placeholder="Имя" />
      </div>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" placeholder="Email" />
      </div>
      <div>
        <label htmlFor="password">Пароль</label>
        <input id="password" name="password" type="password" />
      </div>
      <button type="submit">Зарегистрироваться</button>
    </form>
  )
}
import { signup } from '@/app/actions/auth'

export function SignupForm() {
  return (
    <form action={signup}>
      <div>
        <label htmlFor="name">Имя</label>
        <input id="name" name="name" placeholder="Имя" />
      </div>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" placeholder="Email" />
      </div>
      <div>
        <label htmlFor="password">Пароль</label>
        <input id="password" name="password" type="password" />
      </div>
      <button type="submit">Зарегистрироваться</button>
    </form>
  )
}
export async function signup(formData: FormData) {}
export async function signup(formData) {}

2. Проверка полей формы на сервере

Используйте Server Action для проверки полей формы на сервере. Если ваш провайдер аутентификации не предоставляет проверку формы, вы можете использовать библиотеку для валидации схем, такую как Zod или Yup.

Используя Zod в качестве примера, вы можете определить схему формы с соответствующими сообщениями об ошибках:

import { z } from 'zod'

export const SignupFormSchema = z.object({
  name: z
    .string()
    .min(2, { message: 'Имя должно содержать не менее 2 символов.' })
    .trim(),
  email: z.string().email({ message: 'Пожалуйста, введите корректный email.' }).trim(),
  password: z
    .string()
    .min(8, { message: 'Должен содержать не менее 8 символов' })
    .regex(/[a-zA-Z]/, { message: 'Должен содержать хотя бы одну букву.' })
    .regex(/[0-9]/, { message: 'Должен содержать хотя бы одну цифру.' })
    .regex(/[^a-zA-Z0-9]/, {
      message: 'Должен содержать хотя бы один специальный символ.',
    })
    .trim(),
})

export type FormState =
  | {
      errors?: {
        name?: string[]
        email?: string[]
        password?: string[]
      }
      message?: string
    }
  | undefined
import { z } from 'zod'

export const SignupFormSchema = z.object({
  name: z
    .string()
    .min(2, { message: 'Имя должно содержать не менее 2 символов.' })
    .trim(),
  email: z.string().email({ message: 'Пожалуйста, введите корректный email.' }).trim(),
  password: z
    .string()
    .min(8, { message: 'Должен содержать не менее 8 символов' })
    .regex(/[a-zA-Z]/, { message: 'Должен содержать хотя бы одну букву.' })
    .regex(/[0-9]/, { message: 'Должен содержать хотя бы одну цифру.' })
    .regex(/[^a-zA-Z0-9]/, {
      message: 'Должен содержать хотя бы один специальный символ.',
    })
    .trim(),
})

Чтобы избежать ненужных вызовов API или базы данных вашего провайдера аутентификации, вы можете выполнить return в Server Action, если какие-либо поля формы не соответствуют определенной схеме.

import { SignupFormSchema, FormState } from '@/app/lib/definitions'

export async function signup(state: FormState, formData: FormData) {
  // Проверка полей формы
  const validatedFields = SignupFormSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    password: formData.get('password'),
  })

  // Если какие-либо поля формы недействительны, завершаем выполнение
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    }
  }

  // Вызываем провайдера или базу данных для создания пользователя...
}
import { SignupFormSchema } from '@/app/lib/definitions'

export async function signup(state, formData) {
  // Проверка полей формы
  const validatedFields = SignupFormSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    password: formData.get('password'),
  })

  // Если какие-либо поля формы недействительны, завершаем выполнение
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    }
  }

  // Вызываем провайдера или базу данных для создания пользователя...
}

Вернувшись в ваш <SignupForm />, вы можете использовать хук useActionState из React для отображения ошибок валидации во время отправки формы:

'use client'

import { signup } from '@/app/actions/auth'
import { useActionState } from 'react'

export default function SignupForm() {
  const [state, action, pending] = useActionState(signup, undefined)

  return (
    <form action={action}>
      <div>
        <label htmlFor="name">Имя</label>
        <input id="name" name="name" placeholder="Имя" />
      </div>
      {state?.errors?.name && <p>{state.errors.name}</p>}

      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" placeholder="Email" />
      </div>
      {state?.errors?.email && <p>{state.errors.email}</p>}

      <div>
        <label htmlFor="password">Пароль</label>
        <input id="password" name="password" type="password" />
      </div>
      {state?.errors?.password && (
        <div>
          <p>Пароль должен:</p>
          <ul>
            {state.errors.password.map((error) => (
              <li key={error}>- {error}</li>
            ))}
          </ul>
        </div>
      )}
      <button disabled={pending} type="submit">
        Зарегистрироваться
      </button>
    </form>
  )
}
'use client'

import { signup } from '@/app/actions/auth'
import { useActionState } from 'react'

export default function SignupForm() {
  const [state, action, pending] = useActionState(signup, undefined)

  return (
    <form action={action}>
      <div>
        <label htmlFor="name">Имя</label>
        <input id="name" name="name" placeholder="Имя" />
      </div>
      {state?.errors?.name && <p>{state.errors.name}</p>}

      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" placeholder="Email" />
      </div>
      {state?.errors?.email && <p>{state.errors.email}</p>}

      <div>
        <label htmlFor="password">Пароль</label>
        <input id="password" name="password" type="password" />
      </div>
      {state?.errors?.password && (
        <div>
          <p>Пароль должен:</p>
          <ul>
            {state.errors.password.map((error) => (
              <li key={error}>- {error}</li>
            ))}
          </ul>
        </div>
      )}
      <button disabled={pending} type="submit">
        Зарегистрироваться
      </button>
    </form>
  )
}

Полезно знать:

  • В React 19 useFormStatus включает дополнительные ключи в возвращаемом объекте, такие как data, method и action. Если вы не используете React 19, доступен только ключ pending.
  • Перед изменением данных вы всегда должны убедиться, что пользователь также авторизован для выполнения действия. См. Аутентификация и авторизация.

3. Создание пользователя или проверка учетных данных

После валидации полей формы вы можете создать новую учетную запись или проверить существование пользователя, обратившись к API вашего провайдера аутентификации или базе данных.

Продолжая предыдущий пример:

export async function signup(state: FormState, formData: FormData) {
  // 1. Валидация полей формы
  // ...

  // 2. Подготовка данных для вставки в базу данных
  const { name, email, password } = validatedFields.data
  // Например, хеширование пароля перед сохранением
  const hashedPassword = await bcrypt.hash(password, 10)

  // 3. Вставка пользователя в базу данных или вызов API библиотеки аутентификации
  const data = await db
    .insert(users)
    .values({
      name,
      email,
      password: hashedPassword,
    })
    .returning({ id: users.id })

  const user = data[0]

  if (!user) {
    return {
      message: 'Произошла ошибка при создании вашей учетной записи.',
    }
  }

  // TODO:
  // 4. Создание сессии пользователя
  // 5. Перенаправление пользователя
}
export async function signup(state, formData) {
  // 1. Валидация полей формы
  // ...

  // 2. Подготовка данных для вставки в базу данных
  const { name, email, password } = validatedFields.data
  // Например, хеширование пароля перед сохранением
  const hashedPassword = await bcrypt.hash(password, 10)

  // 3. Вставка пользователя в базу данных или вызов API библиотеки
  const data = await db
    .insert(users)
    .values({
      name,
      email,
      password: hashedPassword,
    })
    .returning({ id: users.id })

  const user = data[0]

  if (!user) {
    return {
      message: 'Произошла ошибка при создании вашей учетной записи.',
    }
  }

  // TODO:
  // 4. Создание сессии пользователя
  // 5. Перенаправление пользователя
}

После успешного создания учетной записи или проверки учетных данных вы можете создать сессию для управления состоянием аутентификации пользователя. В зависимости от вашей стратегии управления сессиями, они могут храниться в cookies или базе данных, или в обоих местах. Перейдите к разделу Управление сессиями, чтобы узнать больше.

Советы:

  • Приведенный выше пример подробный, так как разбивает шаги аутентификации для обучающих целей. Это показывает, что реализация собственного безопасного решения может быстро усложниться. Рассмотрите использование Библиотеки аутентификации для упрощения процесса.
  • Для улучшения пользовательского опыта вы можете проверять дубликаты email или имен пользователей раньше в процессе регистрации. Например, когда пользователь вводит имя или поле ввода теряет фокус. Это поможет предотвратить ненужные отправки форм и даст пользователю немедленную обратную связь. Вы можете управлять частотой таких проверок с помощью библиотек, таких как use-debounce.

Управление сессиями

Управление сессиями гарантирует сохранение аутентифицированного состояния пользователя между запросами. Оно включает создание, хранение, обновление и удаление сессий или токенов.

Существует два типа сессий:

  1. Stateless (без состояния): Данные сессии (или токен) хранятся в cookies браузера. Cookie отправляется с каждым запросом, позволяя проверять сессию на сервере. Этот метод проще, но может быть менее безопасным, если реализован неправильно.
  2. Database (база данных): Данные сессии хранятся в базе данных, а браузер пользователя получает только зашифрованный ID сессии. Этот метод безопаснее, но сложнее и требует больше ресурсов сервера.

Полезно знать: Хотя вы можете использовать любой метод или оба, мы рекомендуем использовать библиотеки управления сессиями, такие как iron-session или Jose.

Stateless сессии

Для создания и управления stateless сессиями необходимо выполнить несколько шагов:

  1. Сгенерировать секретный ключ, который будет использоваться для подписи сессии, и сохранить его как переменную окружения.
  2. Написать логику для шифрования/дешифрования данных сессии с помощью библиотеки управления сессиями.
  3. Управлять cookies с помощью API Next.js cookies.

Кроме того, рассмотрите возможность добавления функциональности для обновления сессии, когда пользователь возвращается в приложение, и удаления сессии при выходе пользователя.

Полезно знать: Проверьте, включает ли ваша библиотека аутентификации управление сессиями.

1. Генерация секретного ключа

Есть несколько способов сгенерировать секретный ключ для подписи сессии. Например, вы можете использовать команду openssl в терминале:

terminal
openssl rand -base64 32

Эта команда генерирует случайную строку из 32 символов, которую можно использовать как секретный ключ и сохранить в файле переменных окружения:

.env
SESSION_SECRET=your_secret_key

Затем вы можете ссылаться на этот ключ в вашей логике управления сессиями:

app/lib/session.js
const secretKey = process.env.SESSION_SECRET

2. Шифрование и дешифрование сессий

Далее вы можете использовать предпочитаемую библиотеку управления сессиями для шифрования и дешифрования сессий. Продолжая предыдущий пример, мы будем использовать Jose (совместимый с Edge Runtime) и пакет React server-only, чтобы гарантировать, что логика управления сессиями выполняется только на сервере.

import 'server-only'
import { SignJWT, jwtVerify } from 'jose'
import { SessionPayload } from '@/app/lib/definitions'

const secretKey = process.env.SESSION_SECRET
const encodedKey = new TextEncoder().encode(secretKey)

export async function encrypt(payload: SessionPayload) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')
    .sign(encodedKey)
}

export async function decrypt(session: string | undefined = '') {
  try {
    const { payload } = await jwtVerify(session, encodedKey, {
      algorithms: ['HS256'],
    })
    return payload
  } catch (error) {
    console.log('Не удалось проверить сессию')
  }
}
import 'server-only'
import { SignJWT, jwtVerify } from 'jose'

const secretKey = process.env.SESSION_SECRET
const encodedKey = new TextEncoder().encode(secretKey)

export async function encrypt(payload) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')
    .sign(encodedKey)
}

export async function decrypt(session) {
  try {
    const { payload } = await jwtVerify(session, encodedKey, {
      algorithms: ['HS256'],
    })
    return payload
  } catch (error) {
    console.log('Не удалось проверить сессию')
  }
}

Советы:

  • Полезная нагрузка должна содержать минимальные, уникальные данные пользователя, которые будут использоваться в последующих запросах, такие как ID пользователя, роль и т.д. Она не должна содержать личную информацию, такую как номер телефона, email, данные кредитной карты и т.д., или чувствительные данные, такие как пароли.

3. Установка cookies (рекомендуемые опции)

Для хранения сессии в cookie используйте API Next.js cookies. Cookie должен устанавливаться на сервере и включать рекомендуемые опции:

  • HttpOnly: Предотвращает доступ к cookie из JavaScript на стороне клиента.
  • Secure: Использует https для отправки cookie.
  • SameSite: Указывает, может ли cookie отправляться с межсайтовыми запросами.
  • Max-Age или Expires: Удаляет cookie после определенного периода.
  • Path: Определяет URL-путь для cookie.

Пожалуйста, обратитесь к MDN для получения дополнительной информации о каждой из этих опций.

import 'server-only'
import { cookies } from 'next/headers'

export async function createSession(userId: string) {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
  const session = await encrypt({ userId, expiresAt })
  const cookieStore = await cookies()

  cookieStore.set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: 'lax',
    path: '/',
  })
}
import 'server-only'
import { cookies } from 'next/headers'

export async function createSession(userId) {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
  const session = await encrypt({ userId, expiresAt })
  const cookieStore = await cookies()

  cookieStore.set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: 'lax',
    path: '/',
  })
}

Вернувшись в ваше Server Action, вы можете вызвать функцию createSession() и использовать API redirect() для перенаправления пользователя на соответствующую страницу:

import { createSession } from '@/app/lib/session'

export async function signup(state: FormState, formData: FormData) {
  // Предыдущие шаги:
  // 1. Валидация полей формы
  // 2. Подготовка данных для вставки в базу данных
  // 3. Вставка пользователя в базу данных или вызов API библиотеки

  // Текущие шаги:
  // 4. Создание сессии пользователя
  await createSession(user.id)
  // 5. Перенаправление пользователя
  redirect('/profile')
}
import { createSession } from '@/app/lib/session'

export async function signup(state, formData) {
  // Предыдущие шаги:
  // 1. Валидация полей формы
  // 2. Подготовка данных для вставки в базу данных
  // 3. Вставка пользователя в базу данных или вызов API библиотеки

  // Текущие шаги:
  // 4. Создание сессии пользователя
  await createSession(user.id)
  // 5. Перенаправление пользователя
  redirect('/profile')
}

Советы:

  • Cookies должны устанавливаться на сервере, чтобы предотвратить подделку на стороне клиента.
  • 🎥 Видео: Узнайте больше о stateless сессиях и аутентификации в Next.js → YouTube (11 минут).

Обновление сессий

Вы также можете продлить время истечения сессии. Это полезно для сохранения входа пользователя после повторного доступа к приложению. Например:

import 'server-only'
import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/session'

export async function updateSession() {
  const session = (await cookies()).get('session')?.value
  const payload = await decrypt(session)

  if (!session || !payload) {
    return null
  }

  const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)

  const cookieStore = await cookies()
  cookieStore.set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expires,
    sameSite: 'lax',
    path: '/',
  })
}
import 'server-only'
import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/session'

export async function updateSession() {
  const session = (await cookies()).get('session')?.value
  const payload = await decrypt(session)

  if (!session || !payload) {
    return null
  }

  const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)(
    await cookies()
  ).set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expires,
    sameSite: 'lax',
    path: '/',
  })
}

Совет: Проверьте, поддерживает ли ваша библиотека аутентификации refresh tokens, которые можно использовать для продления сессии пользователя.

Удаление сессии

Чтобы удалить сессию, вы можете удалить cookie:

import 'server-only'
import { cookies } from 'next/headers'

export async function deleteSession() {
  const cookieStore = await cookies()
  cookieStore.delete('session')
}
import 'server-only'
import { cookies } from 'next/headers'

export async function deleteSession() {
  const cookieStore = await cookies()
  cookieStore.delete('session')
}

Затем вы можете повторно использовать функцию deleteSession() в вашем приложении, например, при выходе из системы:

import { cookies } from 'next/headers'
import { deleteSession } from '@/app/lib/session'

export async function logout() {
  await deleteSession()
  redirect('/login')
}
import { cookies } from 'next/headers'
import { deleteSession } from '@/app/lib/session'

export async function logout() {
  await deleteSession()
  redirect('/login')
}

Сессии в базе данных

Чтобы создать и управлять сессиями в базе данных, вам нужно выполнить следующие шаги:

  1. Создать таблицу в вашей базе данных для хранения данных сессии (или проверить, поддерживает ли это ваша библиотека аутентификации).
  2. Реализовать функциональность для добавления, обновления и удаления сессий.
  3. Зашифровать идентификатор сессии перед сохранением в браузере пользователя и обеспечить синхронизацию между базой данных и cookie (это опционально, но рекомендуется для оптимистичных проверок аутентификации в Middleware).

Например:

import cookies from 'next/headers'
import { db } from '@/app/lib/db'
import { encrypt } from '@/app/lib/session'

export async function createSession(id: number) {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)

  // 1. Создать сессию в базе данных
  const data = await db
    .insert(sessions)
    .values({
      userId: id,
      expiresAt,
    })
    // Вернуть идентификатор сессии
    .returning({ id: sessions.id })

  const sessionId = data[0].id

  // 2. Зашифровать идентификатор сессии
  const session = await encrypt({ sessionId, expiresAt })

  // 3. Сохранить сессию в cookie для оптимистичных проверок аутентификации
  const cookieStore = await cookies()
  cookieStore.set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: 'lax',
    path: '/',
  })
}
import cookies from 'next/headers'
import { db } from '@/app/lib/db'
import { encrypt } from '@/app/lib/session'

export async function createSession(id) {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)

  // 1. Создать сессию в базе данных
  const data = await db
    .insert(sessions)
    .values({
      userId: id,
      expiresAt,
    })
    // Вернуть идентификатор сессии
    .returning({ id: sessions.id })

  const sessionId = data[0].id

  // 2. Зашифровать идентификатор сессии
  const session = await encrypt({ sessionId, expiresAt })

  // 3. Сохранить сессию в cookie для оптимистичных проверок аутентификации
  const cookieStore = await cookies()
  cookieStore.set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: 'lax',
    path: '/',
  })
}

Советы:

  • Для более быстрого доступа вы можете рассмотреть возможность добавления кэширования на сервере на время жизни сессии. Вы также можете хранить данные сессии в основной базе данных и объединять запросы данных, чтобы уменьшить количество запросов.
  • Вы можете использовать сессии в базе данных для более сложных случаев, таких как отслеживание времени последнего входа пользователя, количества активных устройств или предоставление пользователям возможности выхода со всех устройств.

После реализации управления сессиями вам нужно добавить логику авторизации для контроля доступа пользователей к функциям вашего приложения. Перейдите к разделу Авторизация, чтобы узнать больше.

Авторизация

После аутентификации пользователя и создания сессии вы можете реализовать авторизацию для контроля доступа пользователей к функциям вашего приложения.

Существует два основных типа проверок авторизации:

  1. Оптимистичные: Проверяют, авторизован ли пользователь для доступа к маршруту или выполнения действия, используя данные сессии, хранящиеся в cookie. Эти проверки полезны для быстрых операций, таких как отображение/скрытие элементов интерфейса или перенаправление пользователей на основе разрешений или ролей.
  2. Безопасные: Проверяют, авторизован ли пользователь для доступа к маршруту или выполнения действия, используя данные сессии, хранящиеся в базе данных. Эти проверки более безопасны и используются для операций, требующих доступа к конфиденциальным данным или действиям.

Для обоих случаев мы рекомендуем:

Оптимистичные проверки с Middleware (Опционально)

В некоторых случаях вы можете использовать Middleware и перенаправлять пользователей на основе разрешений:

  • Для выполнения оптимистичных проверок. Поскольку Middleware выполняется для каждого маршрута, это хороший способ централизовать логику перенаправления и предварительно фильтровать неавторизованных пользователей.
  • Для защиты статических маршрутов, которые используют общие данные между пользователями (например, контент за платным доступом).

Однако, поскольку Middleware выполняется для каждого маршрута, включая предварительно загруженные маршруты, важно читать сессию только из cookie (оптимистичные проверки) и избегать проверок в базе данных, чтобы предотвратить проблемы с производительностью.

Например:

import { NextRequest, NextResponse } from 'next/server'
import { decrypt } from '@/app/lib/session'
import { cookies } from 'next/headers'

// 1. Указать защищенные и публичные маршруты
const protectedRoutes = ['/dashboard']
const publicRoutes = ['/login', '/signup', '/']

export default async function middleware(req: NextRequest) {
  // 2. Проверить, является ли текущий маршрут защищенным или публичным
  const path = req.nextUrl.pathname
  const isProtectedRoute = protectedRoutes.includes(path)
  const isPublicRoute = publicRoutes.includes(path)

  // 3. Расшифровать сессию из cookie
  const cookie = (await cookies()).get('session')?.value
  const session = await decrypt(cookie)

  // 4. Перенаправить на /login, если пользователь не аутентифицирован
  if (isProtectedRoute && !session?.userId) {
    return NextResponse.redirect(new URL('/login', req.nextUrl))
  }

  // 5. Перенаправить на /dashboard, если пользователь аутентифицирован
  if (
    isPublicRoute &&
    session?.userId &&
    !req.nextUrl.pathname.startsWith('/dashboard')
  ) {
    return NextResponse.redirect(new URL('/dashboard', req.nextUrl))
  }

  return NextResponse.next()
}

// Маршруты, на которых Middleware не должен выполняться
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
}
import { NextResponse } from 'next/server'
import { decrypt } from '@/app/lib/session'
import { cookies } from 'next/headers'

// 1. Указать защищенные и публичные маршруты
const protectedRoutes = ['/dashboard']
const publicRoutes = ['/login', '/signup', '/']

export default async function middleware(req) {
  // 2. Проверить, является ли текущий маршрут защищенным или публичным
  const path = req.nextUrl.pathname
  const isProtectedRoute = protectedRoutes.includes(path)
  const isPublicRoute = publicRoutes.includes(path)

  // 3. Расшифровать сессию из cookie
  const cookie = (await cookies()).get('session')?.value
  const session = await decrypt(cookie)

  // 5. Перенаправить на /login, если пользователь не аутентифицирован
  if (isProtectedRoute && !session?.userId) {
    return NextResponse.redirect(new URL('/login', req.nextUrl))
  }

  // 6. Перенаправить на /dashboard, если пользователь аутентифицирован
  if (
    isPublicRoute &&
    session?.userId &&
    !req.nextUrl.pathname.startsWith('/dashboard')
  ) {
    return NextResponse.redirect(new URL('/dashboard', req.nextUrl))
  }

  return NextResponse.next()
}

// Маршруты, на которых Middleware не должен выполняться
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
}

Хотя Middleware может быть полезен для начальных проверок, он не должен быть единственной линией защиты ваших данных. Большинство проверок безопасности должно выполняться как можно ближе к источнику данных, см. Слой доступа к данным (DAL) для получения дополнительной информации.

Советы:

  • В Middleware вы также можете читать cookie с помощью req.cookies.get('session').value.
  • Middleware использует Edge Runtime, проверьте, совместимы ли ваша библиотека аутентификации и библиотека управления сессиями.
  • Вы можете использовать свойство matcher в Middleware, чтобы указать, на каких маршрутах должен выполняться Middleware. Однако для аутентификации рекомендуется выполнять Middleware на всех маршрутах.

Создание слоя доступа к данным (DAL)

Мы рекомендуем создать DAL для централизации запросов данных и логики авторизации.

DAL должен включать функцию, которая проверяет сессию пользователя при взаимодействии с вашим приложением. Как минимум, функция должна проверять, действительна ли сессия, а затем перенаправлять или возвращать информацию о пользователе, необходимую для выполнения дальнейших запросов.

Например, создайте отдельный файл для вашего DAL, который включает функцию verifySession(). Затем используйте API cache React для мемоизации возвращаемого значения функции во время прохода рендеринга React:

import 'server-only'

import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/session'

export const verifySession = cache(async () => {
  const cookie = (await cookies()).get('session')?.value
  const session = await decrypt(cookie)

  if (!session?.userId) {
    redirect('/login')
  }

  return { isAuth: true, userId: session.userId }
})
import 'server-only'

import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/session'

export const verifySession = cache(async () => {
  const cookie = (await cookies()).get('session')?.value
  const session = await decrypt(cookie)

  if (!session.userId) {
    redirect('/login')
  }

  return { isAuth: true, userId: session.userId }
})

Затем вы можете вызывать функцию verifySession() в ваших запросах данных, Server Actions, Route Handlers:

export const getUser = cache(async () => {
  const session = await verifySession()
  if (!session) return null

  try {
    const data = await db.query.users.findMany({
      where: eq(users.id, session.userId),
      // Явно вернуть только нужные столбцы, а не весь объект пользователя
      columns: {
        id: true,
        name: true,
        email: true,
      },
    })

    const user = data[0]

    return user
  } catch (error) {
    console.log('Failed to fetch user')
    return null
  }
})
export const getUser = cache(async () => {
  const session = await verifySession()
  if (!session) return null

  try {
    const data = await db.query.users.findMany({
      where: eq(users.id, session.userId),
      // Явно вернуть только нужные столбцы, а не весь объект пользователя
      columns: {
        id: true,
        name: true,
        email: true,
      },
    })

    const user = data[0]

    return user
  } catch (error) {
    console.log('Failed to fetch user')
    return null
  }
})

Совет:

  • DAL может использоваться для защиты данных, получаемых во время запроса. Однако для статических маршрутов, которые используют общие данные между пользователями, данные будут получаться во время сборки, а не во время запроса. Используйте Middleware для защиты статических маршрутов.
  • Для безопасных проверок вы можете проверить, действительна ли сессия, сравнив идентификатор сессии с вашей базой данных. Используйте функцию cache React, чтобы избежать ненужных дублирующих запросов к базе данных во время прохода рендеринга.
  • Вы можете объединить связанные запросы данных в JavaScript-классе, который выполняет verifySession() перед любыми методами.

Использование объектов передачи данных (DTO)

При получении данных рекомендуется возвращать только необходимую информацию, которая будет использоваться в приложении, а не полные объекты. Например, при запросе данных пользователя можно вернуть только его ID и имя, а не весь объект пользователя, который может содержать пароли, номера телефонов и т.д.

Однако если у вас нет контроля над структурой возвращаемых данных или вы работаете в команде и хотите избежать передачи полных объектов клиенту, можно использовать стратегии, такие как указание полей, безопасных для передачи клиенту.

import 'server-only'
import { getUser } from '@/app/lib/dal'

function canSeeUsername(viewer: User) {
  return true
}

function canSeePhoneNumber(viewer: User, team: string) {
  return viewer.isAdmin || team === viewer.team
}

export async function getProfileDTO(slug: string) {
  const data = await db.query.users.findMany({
    where: eq(users.slug, slug),
    // Возвращаем только определенные столбцы
  })
  const user = data[0]

  const currentUser = await getUser(user.id)

  // Или возвращаем только то, что нужно для конкретного запроса
  return {
    username: canSeeUsername(currentUser) ? user.username : null,
    phonenumber: canSeePhoneNumber(currentUser, user.team)
      ? user.phonenumber
      : null,
  }
}
import 'server-only'
import { getUser } from '@/app/lib/dal'

function canSeeUsername(viewer) {
  return true
}

function canSeePhoneNumber(viewer, team) {
  return viewer.isAdmin || team === viewer.team
}

export async function getProfileDTO(slug) {
  const data = await db.query.users.findMany({
    where: eq(users.slug, slug),
    // Возвращаем только определенные столбцы
  })
  const user = data[0]

  const currentUser = await getUser(user.id)

  // Или возвращаем только то, что нужно для конкретного запроса
  return {
    username: canSeeUsername(currentUser) ? user.username : null,
    phonenumber: canSeePhoneNumber(currentUser, user.team)
      ? user.phonenumber
      : null,
  }
}

Централизуя запросы данных и логику авторизации в DAL (Data Access Layer) и используя DTO, вы можете обеспечить безопасность и согласованность всех запросов данных, что упрощает поддержку, аудит и отладку по мере масштабирования приложения.

Полезно знать:

  • Существует несколько способов определения DTO: от использования toJSON() до отдельных функций, как в примере выше, или классов JS. Поскольку это паттерны JavaScript, а не функции React или Next.js, рекомендуем изучить различные подходы, чтобы выбрать оптимальный для вашего приложения.
  • Подробнее о лучших практиках безопасности читайте в статье "Безопасность в Next.js".

Серверные компоненты

Проверки авторизации в серверных компонентах полезны для ролевого доступа. Например, для условного рендеринга компонентов на основе роли пользователя:

import { verifySession } from '@/app/lib/dal'

export default function Dashboard() {
  const session = await verifySession()
  const userRole = session?.user?.role // Предполагаем, что 'role' есть в объекте сессии

  if (userRole === 'admin') {
    return <AdminDashboard />
  } else if (userRole === 'user') {
    return <UserDashboard />
  } else {
    redirect('/login')
  }
}
import { verifySession } from '@/app/lib/dal'

export default function Dashboard() {
  const session = await verifySession()
  const userRole = session.role // Предполагаем, что 'role' есть в объекте сессии

  if (userRole === 'admin') {
    return <AdminDashboard />
  } else if (userRole === 'user') {
    return <UserDashboard />
  } else {
    redirect('/login')
  }
}

В этом примере мы используем функцию verifySession() из нашего DAL для проверки ролей 'admin', 'user' и неавторизованных пользователей. Такой подход гарантирует, что каждый пользователь взаимодействует только с компонентами, соответствующими его роли.

Макеты и проверки авторизации

Из-за частичного рендеринга (Partial Rendering) будьте осторожны при проверках в макетах (Layouts), так как они не перерендериваются при навигации, что означает отсутствие проверки сессии пользователя при каждом изменении маршрута.

Вместо этого проверки следует выполнять ближе к источнику данных или компоненту, который будет условно рендериться.

Например, рассмотрим общий макет, который получает данные пользователя и отображает его изображение в навигации. Вместо проверки авторизации в макете следует получать данные пользователя (getUser()) в макете, а проверку авторизации выполнять в DAL.

Это гарантирует, что при любом вызове getUser() в приложении проверка авторизации будет выполнена, и предотвращает ситуацию, когда разработчик забывает проверить права доступа пользователя к данным.

export default async function Layout({
  children,
}: {
  children: React.ReactNode;
}) {
  const user = await getUser();

  return (
    // ...
  )
}
export default async function Layout({ children }) {
  const user = await getUser();

  return (
    // ...
  )
}
export const getUser = cache(async () => {
  const session = await verifySession()
  if (!session) return null

  // Получаем ID пользователя из сессии и запрашиваем данные
})
export const getUser = cache(async () => {
  const session = await verifySession()
  if (!session) return null

  // Получаем ID пользователя из сессии и запрашиваем данные
})

Полезно знать:

  • Распространенный паттерн в SPA — возвращать null в макете или компоненте верхнего уровня, если пользователь не авторизован. Этот подход не рекомендуется, так как приложения Next.js имеют несколько точек входа, что не предотвращает доступ к вложенным сегментам маршрутов и серверным действиям (Server Actions).

Серверные действия (Server Actions)

Относитесь к серверным действиям с теми же требованиями безопасности, что и к публичным API-эндпоинтам, и проверяйте, разрешено ли пользователю выполнять мутацию.

В примере ниже мы проверяем роль пользователя перед выполнением действия:

'use server'
import { verifySession } from '@/app/lib/dal'

export async function serverAction(formData: FormData) {
  const session = await verifySession()
  const userRole = session?.user?.role

  // Прерываем выполнение, если пользователь не авторизован для действия
  if (userRole !== 'admin') {
    return null
  }

  // Продолжаем выполнение для авторизованных пользователей
}
'use server'
import { verifySession } from '@/app/lib/dal'

export async function serverAction() {
  const session = await verifySession()
  const userRole = session.user.role

  // Прерываем выполнение, если пользователь не авторизован для действия
  if (userRole !== 'admin') {
    return null
  }

  // Продолжаем выполнение для авторизованных пользователей
}

Обработчики маршрутов (Route Handlers)

Относитесь к обработчикам маршрутов с теми же требованиями безопасности, что и к публичным API-эндпоинтам, и проверяйте, разрешено ли пользователю обращаться к обработчику.

Например:

import { verifySession } from '@/app/lib/dal'

export async function GET() {
  // Аутентификация пользователя и проверка роли
  const session = await verifySession()

  // Проверяем, авторизован ли пользователь
  if (!session) {
    // Пользователь не авторизован
    return new Response(null, { status: 401 })
  }

  // Проверяем, есть ли у пользователя роль 'admin'
  if (session.user.role !== 'admin') {
    // Пользователь авторизован, но не имеет прав
    return new Response(null, { status: 403 })
  }

  // Продолжаем для авторизованных пользователей
}
import { verifySession } from '@/app/lib/dal'

export async function GET() {
  // Аутентификация пользователя и проверка роли
  const session = await verifySession()

  // Проверяем, авторизован ли пользователь
  if (!session) {
    // Пользователь не авторизован
    return new Response(null, { status: 401 })
  }

  // Проверяем, есть ли у пользователя роль 'admin'
  if (session.user.role !== 'admin') {
    // Пользователь авторизован, но не имеет прав
    return new Response(null, { status: 403 })
  }

  // Продолжаем для авторизованных пользователей
}

В примере выше показан обработчик маршрута с двухуровневой проверкой безопасности. Сначала проверяется активная сессия, затем подтверждается, что вошедший пользователь является 'администратором'.

Провайдеры контекста

Использование провайдеров контекста для авторизации работает благодаря чередованию (interleaving). Однако React context не поддерживается в серверных компонентах, что делает их применимыми только к клиентским компонентам.

Это работает, но любые дочерние серверные компоненты сначала рендерятся на сервере и не имеют доступа к данным сессии из провайдера контекста:

import { ContextProvider } from 'auth-lib'

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <ContextProvider>{children}</ContextProvider>
      </body>
    </html>
  )
}
'use client';

import { useSession } from "auth-lib";

export default function Profile() {
  const { userId } = useSession();
  const { data } = useSWR(`/api/user/${userId}`, fetcher)

  return (
    // ...
  );
}
'use client';

import { useSession } from "auth-lib";

export default function Profile() {
  const { userId } = useSession();
  const { data } = useSWR(`/api/user/${userId}`, fetcher)

  return (
    // ...
  );
}

Если данные сессии нужны в клиентских компонентах (например, для клиентского получения данных), используйте API React taintUniqueValue, чтобы предотвратить передачу конфиденциальных данных сессии клиенту.

Ресурсы

Теперь, когда вы узнали об аутентификации в Next.js, вот совместимые библиотеки и ресурсы, которые помогут реализовать безопасную аутентификацию и управление сессиями:

Библиотеки аутентификации

Библиотеки управления сессиями

Дополнительные материалы

Чтобы продолжить изучение аутентификации и безопасности, ознакомьтесь со следующими ресурсами: