Использование аутентификации Firebase с новейшими функциями Next.js

Использование аутентификации Firebase с новейшими функциями Next.js

5 апреля 2024 г.

Введение в next-firebase-auth-edge

Вероятно, вы нашли эту статью, когда искали способы добавить проверку подлинности Firebase в существующее или новое приложение Next.js. Вы стремитесь принять разумное, беспристрастное и ориентированное на будущее решение, которое максимизирует шансы на успех вашего приложения. Как создатель next-firebase-auth-edge, я должен признать, что высказывать совершенно беспристрастное мнение — не моя сильная сторона, но, по крайней мере, я попытаюсь обосновать подход, который я использовал при проектировании библиотеки. Надеемся, что к концу этого руководства вы сможете найти этот подход простым и жизнеспособным в долгосрочной перспективе.

Как это началось

Я избавлю вас от длинных представлений. Скажу только, что идея библиотеки возникла из ситуации, возможно похожей на вашу. Это было время, когда Next.js выпустил канареечную версию App Router. Я работал над приложением, которое в значительной степени полагалось на перезапись и внутренние перенаправления. Для этого мы использовали собственное приложение Node.js express для серверного рендеринга Next.js.

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

В тот раз мы использовали next-firebase-auth. Нам очень понравилась библиотека, но она распространяла нашу логику аутентификации через next.config.js , pages/_app.tsx , pages/api/login.ts , pages/api/logout.ts, которые достаточно скоро будут считаться устаревшими. Кроме того, библиотека не была совместима с промежуточным программным обеспечением, что не позволяло нам перезаписывать URL-адреса или перенаправлять пользователей в зависимости от их контекста.

Итак, я начал поиск, но, к своему удивлению, не нашел библиотеки, поддерживающей аутентификацию Firebase в промежуточном программном обеспечении. – Почему это могло быть? Это невозможно! Как инженер-программист с более чем 11-летним коммерческим опытом работы с Node.js и React, я готовился решить эту загадку.

Итак, я начал. И ответ стал очевиден. Промежуточное ПО работает внутри Edge Runtime. В API веб-криптографии. ://edge-runtime.vercel.app/">Edge Runtime. Я был обречен. Я чувствовал себя беспомощным. Впервые мне придется подождать, чтобы начать играть с новыми и модными API? – Нет. Горшок, за которым наблюдают, никогда не закипает. Я быстро перестал рыдать и начал реконструировать next-firebase-auth< /a>, firebase-admin и несколько других библиотек аутентификации JWT, адаптируя их к Edge Runtime. Я воспользовался возможностью решить все проблемы, с которыми столкнулся при использовании предыдущих библиотек аутентификации, стремясь создать самую легкую, простую в настройке и ориентированную на будущее библиотеку аутентификации.

Примерно две недели спустя появилась версия 0.0.1 next-firebase-auth-edge родился. Это было убедительное доказательство концепции, но вы не захотите использовать версию 0.0.1. Поверьте мне.

Как дела

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

My other self

В этом руководстве я буду использовать версию 1.4.1 next-firebase-auth-edge. для создания аутентифицированного приложения Next.js с нуля. Мы подробно рассмотрим каждый шаг, начиная с создания нового проекта Firebase и приложения Next.js, а затем интеграцию с next-firebase-auth-edge и firebase/auth. библиотеки. В конце этого руководства мы развернем приложение в Vercel, чтобы убедиться, что все работает как локально, так и в рабочей среде.

Настройка Firebase

Предполагается, что вы еще не настроили аутентификацию Firebase. Если нет, смело переходите к следующей части.

Давайте перейдем к консоли Firebase и создадим проект

После создания проекта давайте включим аутентификацию Firebase. Откройте консоль и перейдите к Build > Аутентификация > Метод входа и включите метод Электронная почта и пароль. Именно этот метод мы собираемся поддерживать в нашем приложении

Enabling Email/Password sign-in method

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

Retrieve Web API Key

Скопируйте ключ API и сохраните его. Теперь давайте откроем следующую вкладку — Облачные сообщения и запишем Идентификатор отправителя. Он понадобится нам позже.

Retrieve Sender ID

И последнее, но не менее важное: нам необходимо сгенерировать учетные данные сервисной учетной записи. Это позволит вашему приложению получить полный доступ к вашим сервисам Firebase. Перейдите в Настройки проекта > Учетные записи служб и нажмите Создать новый закрытый ключ. Будет загружен файл .json с учетными данными сервисной учетной записи. Сохраните этот файл в известном месте.

Вот и все! Мы готовы интегрировать приложение Next.js с аутентификацией Firebase

Создание приложения Next.js с нуля

В этом руководстве предполагается, что у вас установлены Node.js и npm. Команды, используемые в этом руководстве, были проверены на соответствие последней версии LTS Node.js v20. Вы можете проверить версию узла, запустив node -v в терминале. Вы также можете использовать такие инструменты, как NVM, чтобы быстро переключаться между версиями Node.js.

Настройка приложения Next.js с помощью CLI

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

npx create-next-app@latest

Для простоты давайте использовать конфигурацию по умолчанию. Это означает, что мы будем использовать TypeScript и tailwind

.
 What is your project named?  my-app
 Would you like to use TypeScript?  Yes
 Would you like to use ESLint?  Yes
 Would you like to use Tailwind CSS?  Yes
 Would you like to use `src/` directory?  No
 Would you like to use App Router? (recommended)  Yes
 Would you like to customize the default import alias (@/*)?  No

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

cd my-app

npm install

Чтобы убедиться, что все работает как положено, давайте запустим сервер разработки Next.js с помощью команды npm run dev. Когда вы откроете http://localhost:3000 , вы должны увидеть страницу приветствия Next.js, похожую на эту:

Next.js welcome page

Подготовка переменных среды

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

Откройте свой любимый редактор кода и перейдите в папку проекта

Давайте создадим файл .env.local в корневом каталоге проекта и заполним его следующими переменными среды:

FIREBASE_ADMIN_CLIENT_EMAIL=...
FIREBASE_ADMIN_PRIVATE_KEY=...

AUTH_COOKIE_NAME=AuthToken
AUTH_COOKIE_SIGNATURE_KEY_CURRENT=secret1
AUTH_COOKIE_SIGNATURE_KEY_PREVIOUS=secret2

USE_SECURE_COOKIES=false

NEXT_PUBLIC_FIREBASE_PROJECT_ID=...
NEXT_PUBLIC_FIREBASE_API_KEY=AIza...
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=....firebaseapp.com
NEXT_PUBLIC_FIREBASE_DATABASE_URL=....firebaseio.com
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=...

Обратите внимание, что переменные с префиксом NEXT_PUBLIC_ будут доступны в пакете на стороне клиента. Они понадобятся нам для настройки SDK Firebase Auth Client

NEXT_PUBLIC_FIREBASE_PROJECT_ID , FIREBASE_ADMIN_CLIENT_EMAIL и FIREBASE_ADMIN_PRIVATE_KEY можно получить из файла .json, загруженного после создания учетных данных сервисной учетной записи.

AUTH_COOKIE_NAME — это имя файла cookie, используемого для хранения учетных данных пользователя.

AUTH_COOKIE_SIGNATURE_KEY_CURRENT и AUTH_COOKIE_SIGNATURE_KEY_PREVIOUS — это секреты, с помощью которых мы будем подписывать учетные данные

NEXT_PUBLIC_FIREBASE_API_KEY — это Ключ веб-API, полученный с общей страницы Настройки проекта

NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN — это идентификатор вашего проекта.firebaseapp.com

NEXT_PUBLIC_FIREBASE_DATABASE_URL — это идентификатор вашего проекта.firebaseio.com

NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID можно получить в Настройки проекта > Страница Облачные сообщения

USE_SECURE_COOKIES не будет использоваться для локальной разработки, но пригодится, когда мы развернем наше приложение в Vercel

Интеграция с аутентификацией Firebase

Установка next-firebase-auth-edge и первоначальная настройка

Добавьте библиотеку в зависимости проекта, запустив npm install next-firebase-auth-edge@^1.4.1

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

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

export const serverConfig = {
  cookieName: process.env.AUTH_COOKIE_NAME!,
  cookieSignatureKeys: [process.env.AUTH_COOKIE_SIGNATURE_KEY_CURRENT!, process.env.AUTH_COOKIE_SIGNATURE_KEY_PREVIOUS!],
  cookieSerializeOptions: {
    path: "/",
    httpOnly: true,
    secure: process.env.USE_SECURE_COOKIES === "true",
    sameSite: "lax" as const,
    maxAge: 12 * 60 * 60 * 24,
  },
  serviceAccount: {
    projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID!,
    clientEmail: process.env.FIREBASE_ADMIN_CLIENT_EMAIL!,
    privateKey: process.env.FIREBASE_ADMIN_PRIVATE_KEY?.replace(/n/g, "n")!,
  }
};

export const clientConfig = {
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY!,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  databaseURL: process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID
};

Добавление промежуточного программного обеспечения

Создайте файл middleware.ts в корне проекта и вставьте следующее

import { NextRequest } from "next/server";
import { authMiddleware } from "next-firebase-auth-edge";
import { clientConfig, serverConfig } from "./config";

export async function middleware(request: NextRequest) {
  return authMiddleware(request, {
    loginPath: "/api/login",
    logoutPath: "/api/logout",
    apiKey: clientConfig.apiKey,
    cookieName: serverConfig.cookieName,
    cookieSignatureKeys: serverConfig.cookieSignatureKeys,
    cookieSerializeOptions: serverConfig.cookieSerializeOptions,
    serviceAccount: serverConfig.serviceAccount,
  });
}

export const config = {
  matcher: [
    "/",
    "/((?!_next|api|.*.).*)",
    "/api/login",
    "/api/logout",
  ],
};

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

loginPath даст указание authMiddleware предоставить конечную точку GET /api/login. Когда эта конечная точка вызывается с заголовком Authorization: Bearer ${idToken*, она отвечает только HTTP(S)-заголовком Set-Cookie, содержащим подписанный настраиваемые и обновляемые токены

*idToken извлекается с помощью функции getIdToken, доступной в SDK клиента Firebase. Подробнее об этом позже.

Аналогичным образом, logoutPath указывает промежуточному программному обеспечению предоставить GET /api/logout, но для этого не требуются дополнительные заголовки. При вызове он удаляет файлы cookie аутентификации из браузера.

apiKey — это ключ веб-API. Промежуточное программное обеспечение использует его для обновления пользовательского токена и сброса файлов cookie аутентификации после истечения срока действия учетных данных.

cookieName — это имя файла cookie, установленного и удаленного конечными точками /api/login и /api/logout

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

cookieSerializeOptions — это параметры, передаваемые в cookie при создании заголовка Set-Cookie. Дополнительную информацию см. в файле cookie README

.

serviceAccount разрешает библиотеке использовать ваши сервисы Firebase.

Сопоставитель инструктирует сервер Next.js запускать промежуточное программное обеспечение для /api/login , /api/logout , / и любого другого пути, который не t файл или вызов API.

export const config = {
  matcher: [
    "/",
    "/((?!_next|api|.*.).*)",
    "/api/login",
    "/api/logout",
  ],
};

Вам может быть интересно, почему мы не включаем промежуточное ПО для всех /api/* вызовов. Мы могли бы, но рекомендуется обрабатывать неаутентифицированные вызовы внутри самого обработчика маршрутов API. Это немного выходит за рамки данного руководства, но если вам интересно, дайте мне знать, и я подготовлю несколько примеров!

Как видите, конфигурация минимальна и имеет четко определенную цель. Теперь давайте начнем вызывать наши конечные точки /api/login и /api/logout.

Создание безопасной домашней страницы

Чтобы упростить задачу, давайте очистим домашнюю страницу Next.js по умолчанию и заменим ее персонализированным контентом.

Откройте ./app/page.tsx и вставьте это:

import { getTokens } from "next-firebase-auth-edge";
import { cookies } from "next/headers";
import { notFound } from "next/navigation";
import { clientConfig, serverConfig } from "../config";

export default async function Home() {
  const tokens = await getTokens(cookies(), {
    apiKey: clientConfig.apiKey,
    cookieName: serverConfig.cookieName,
    cookieSignatureKeys: serverConfig.cookieSignatureKeys,
    serviceAccount: serverConfig.serviceAccount,
  });

  if (!tokens) {
    notFound();
  }

  return (
    <main className="flex min-h-screen flex-col items-center justify-center p-24">
      <h1 className="text-xl mb-4">Super secure home page</h1>
      <p>
        Only <strong>{tokens?.decodedToken.email}</strong> holds the magic key to this kingdom!
      </p>
    </main>
  );
}

Давайте разберем это по пунктам.

Функция getTokens предназначена для проверки и извлечения учетных данных пользователя из файлов cookie< /п>

const tokens = await getTokens(cookies(), {
  apiKey: clientConfig.apiKey,
  cookieName: serverConfig.cookieName,
  cookieSignatureKeys: serverConfig.cookieSignatureKeys,
  serviceAccount: serverConfig.serviceAccount,
});

Он разрешается с помощью null, если пользователь не прошел аутентификацию, или объекта, содержащего два свойства:

token — это string idToken, который можно использовать для авторизации запросов API к внешним серверным службам. Это немного выходит за рамки, но стоит отметить, что библиотека поддерживает архитектуру распределенных сервисов. токен совместим и готов к использованию со всеми официальными библиотеками Firebase на всех платформах.

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

После получения токенов мы используем функцию notFound из next/navigation, чтобы страница была доступна только авторизованным пользователям

if (!tokens) {
  notFound();
}

Наконец, мы отображаем базовый персонализированный пользовательский контент.

<main className="flex min-h-screen flex-col items-center justify-center p-24">
  <h1 className="text-xl mb-4">Super secure home page</h1>
  <p>
    Only <strong>{tokens?.decodedToken.email}</strong> holds the magic key to this kingdom!"
  </p>
</main>

Давайте запустим.

Если вы закрыли свой сервер разработки, просто запустите npm run dev .

При попытке доступа к http://localhost:3000/ вы должны увидеть сообщение 404: эта страница не найдена.

Успех! Мы сохранили наши секреты от посторонних глаз!

Установка firebase и инициализация Firebase Client SDK

Запустите npm install firebase в корневом каталоге проекта

После установки клиентского SDK создайте файл firebase.ts в корневом каталоге проекта и вставьте следующее

import { initializeApp } from 'firebase/app';
import { clientConfig } from './config';

export const app = initializeApp(clientConfig);

Это инициализирует Firebase Client SDK и предоставит объект приложения для клиентских компонентов.

Создание страницы регистрации

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

Давайте создадим новую интересную страницу под ./app/register/page.tsx

.

"use client";

import { FormEvent, useState } from "react";
import Link from "next/link";
import { getAuth, createUserWithEmailAndPassword } from "firebase/auth";
import { app } from "../../firebase";
import { useRouter } from "next/navigation";

export default function Register() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [confirmation, setConfirmation] = useState("");
  const [error, setError] = useState("");
  const router = useRouter();

  async function handleSubmit(event: FormEvent) {
    event.preventDefault();

    setError("");

    if (password !== confirmation) {
      setError("Passwords don't match");
      return;
    }

    try {
      await createUserWithEmailAndPassword(getAuth(app), email, password);
      router.push("/login");
    } catch (e) {
      setError((e as Error).message);
    }
  }

  return (
    <main className="flex min-h-screen flex-col items-center justify-center p-8">
      <div className="w-full bg-white rounded-lg shadow dark:border md:mt-0 sm:max-w-md xl:p-0 dark:bg-gray-800 dark:border-gray-700">
        <div className="p-6 space-y-4 md:space-y-6 sm:p-8">
          <h1 className="text-xl font-bold leading-tight tracking-tight text-gray-900 md:text-2xl dark:text-white">
            Pray tell, who be this gallant soul seeking entry to mine humble
            abode?
          </h1>
          <form
            onSubmit={handleSubmit}
            className="space-y-4 md:space-y-6"
            action="#"
          >
            <div>
              <label
                htmlFor="email"
                className="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
              >
                Your email
              </label>
              <input
                type="email"
                name="email"
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                id="email"
                className="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
                placeholder="name@company.com"
                required
              />
            </div>
            <div>
              <label
                htmlFor="password"
                className="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
              >
                Password
              </label>
              <input
                type="password"
                name="password"
                value={password}
                onChange={(e) => setPassword(e.target.value)}
                id="password"
                placeholder="••••••••"
                className="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
                required
              />
            </div>
            <div>
              <label
                htmlFor="confirm-password"
                className="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
              >
                Confirm password
              </label>
              <input
                type="password"
                name="confirm-password"
                value={confirmation}
                onChange={(e) => setConfirmation(e.target.value)}
                id="confirm-password"
                placeholder="••••••••"
                className="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
                required
              />
            </div>
            {error && (
              <div
                className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"
                role="alert"
              >
                <span className="block sm:inline">{error}</span>
              </div>
            )}
            <button
              type="submit"
              className="w-full text-white bg-gray-600 hover:bg-gray-700 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-primary-800"
            >
              Create an account
            </button>
            <p className="text-sm font-light text-gray-500 dark:text-gray-400">
              Already have an account?{" "}
              <Link
                href="/login"
                className="font-medium text-gray-600 hover:underline dark:text-gray-500"
              >
                Login here
              </Link>
            </p>
          </form>
        </div>
      </div>
    </main>
  );
}

Я знаю. Текста много, но потерпите.

Мы начинаем с "use client";, чтобы указать, что страница регистрации будет использовать клиентские API

  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [confirmation, setConfirmation] = useState("");
  const [error, setError] = useState("");

Затем мы определяем некоторые переменные и установщики для хранения состояния нашей формы

const router = useRouter();

async function handleSubmit(event: FormEvent) {
  event.preventDefault();

  setError("");

  if (password !== confirmation) {
    setError("Passwords don't match");
    return;
  }

  try {
    await createUserWithEmailAndPassword(getAuth(app), email, password);
    router.push("/login");
  } catch (e) {
    setError((e as Error).message);
  }
}

Здесь мы определяем логику отправки формы. Сначала мы проверяем, равны ли пароль и подтверждение, в противном случае мы обновляем состояние ошибки. Если значения действительны, мы создаем учетную запись пользователя с помощью createUserWithEmailAndPassword из firebase/auth . Если этот шаг не удался (например, электронное письмо было принято), мы сообщаем пользователю, обновляя ошибку.

Если все идет хорошо, мы перенаправляем пользователя на страницу /login. Вероятно, вы сейчас в замешательстве, и вы правы. Страница /login еще не существует. Мы просто готовимся к тому, что будет дальше.

Когда вы посещаете http://localhost:3000/register, страница должна выглядеть примерно так:

Registration page

Создание страницы входа

Теперь, когда пользователи могут зарегистрироваться, позвольте им подтвердить свою личность

Создайте страницу входа в ./app/login/page.tsx

.

"use client";

import { FormEvent, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { getAuth, signInWithEmailAndPassword } from "firebase/auth";
import { app } from "../../firebase";

export default function Login() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");
  const router = useRouter();

  async function handleSubmit(event: FormEvent) {
    event.preventDefault();
    setError("");

    try {
      const credential = await signInWithEmailAndPassword(
        getAuth(app),
        email,
        password
      );
      const idToken = await credential.user.getIdToken();

      await fetch("/api/login", {
        headers: {
          Authorization: `Bearer ${idToken}`,
        },
      });

      router.push("/");
    } catch (e) {
      setError((e as Error).message);
    }
  }

  return (
    <main className="flex min-h-screen flex-col items-center justify-center p-8">
      <div className="w-full bg-white rounded-lg shadow dark:border md:mt-0 sm:max-w-md xl:p-0 dark:bg-gray-800 dark:border-gray-700">
        <div className="p-6 space-y-4 md:space-y-6 sm:p-8">
          <h1 className="text-xl font-bold leading-tight tracking-tight text-gray-900 md:text-2xl dark:text-white">
            Speak thy secret word!
          </h1>
          <form
            onSubmit={handleSubmit}
            className="space-y-4 md:space-y-6"
            action="#"
          >
            <div>
              <label
                htmlFor="email"
                className="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
              >
                Your email
              </label>
              <input
                type="email"
                name="email"
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                id="email"
                className="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
                placeholder="name@company.com"
                required
              />
            </div>
            <div>
              <label
                htmlFor="password"
                className="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
              >
                Password
              </label>
              <input
                type="password"
                name="password"
                value={password}
                onChange={(e) => setPassword(e.target.value)}
                id="password"
                placeholder="••••••••"
                className="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
                required
              />
            </div>
            {error && (
              <div
                className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"
                role="alert"
              >
                <span className="block sm:inline">{error}</span>
              </div>
            )}
            <button
              type="submit"
              className="w-full text-white bg-gray-600 hover:bg-gray-700 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-primary-800"
            >
              Enter
            </button>
            <p className="text-sm font-light text-gray-500 dark:text-gray-400">
              Don&apos;t have an account?{" "}
              <Link
                href="/register"
                className="font-medium text-gray-600 hover:underline dark:text-gray-500"
              >
                Register here
              </Link>
            </p>
          </form>
        </div>
      </div>
    </main>
  );
}

Как видите, это очень похоже на страницу регистрации. Давайте сосредоточимся на самом важном:

async function handleSubmit(event: FormEvent) {
    event.preventDefault();
    setError("");

    try {
      const credential = await signInWithEmailAndPassword(
        getAuth(app),
        email,
        password
      );
      const idToken = await credential.user.getIdToken();

      await fetch("/api/login", {
        headers: {
          Authorization: `Bearer ${idToken}`,
        },
      });

      router.push("/");
    } catch (e) {
      setError((e as Error).message);
    }
  }

Именно здесь происходит все волшебство. Мы используем signInEmailAndPassword из firebase/auth для получения idToken пользователя.

Затем мы вызываем конечную точку /api/login, предоставляемую промежуточным программным обеспечением. Эта конечная точка обновляет файлы cookie нашего браузера с учетными данными пользователя.

Наконец, мы перенаправляем пользователя на домашнюю страницу, вызывая router.push("/");

Страница входа должна выглядеть примерно так

Давайте проверим!

Перейдите по адресу http://localhost:3000/register, введите случайный адрес электронной почты и пароль, чтобы создать учетную запись. Используйте эти учетные данные на странице http://localhost:3000/login. После того, как вы нажмете Ввод, вы будете перенаправлены на сверхзащищенную домашнюю страницу

Super secure home page

Наконец-то мы увидели нашу собственную, личную и сверхзащищенную домашнюю страницу! Но ждать! Как нам выбраться?

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

Прежде чем мы начнем, нам нужно создать клиентский компонент, который сможет выйдите из системы с помощью Firebase Client SDK.

Давайте создадим новый файл в ./app/HomePage.tsx

.
"use client";

import { useRouter } from "next/navigation";
import { getAuth, signOut } from "firebase/auth";
import { app } from "../firebase";

interface HomePageProps {
  email?: string;
}

export default function HomePage({ email }: HomePageProps) {
  const router = useRouter();

  async function handleLogout() {
    await signOut(getAuth(app));

    await fetch("/api/logout");

    router.push("/login");
  }

  return (
    <main className="flex min-h-screen flex-col items-center justify-center p-24">
      <h1 className="text-xl mb-4">Super secure home page</h1>
      <p className="mb-8">
        Only <strong>{email}</strong> holds the magic key to this kingdom!
      </p>
      <button
        onClick={handleLogout}
        className="text-white bg-gray-600 hover:bg-gray-700 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-primary-800"
      >
        Logout
      </button>
    </main>
  );
}

Как вы могли заметить, это слегка измененная версия нашего ./app/page.tsx . Нам пришлось создать отдельный клиентский компонент, поскольку getTokens работает только внутри компоненты сервера и обработчики маршрутов API, а SignOut и useRouter необходимо запускать в контексте клиента. Я знаю, это немного сложно, но на самом деле это довольно мощно. Я объясню позже.

Давайте сосредоточимся на процессе выхода из системы

const router = useRouter();

async function handleLogout() {
  await signOut(getAuth(app));

  await fetch("/api/logout");

  router.push("/login");
}

Сначала мы выходим из Firebase Client SDK. Затем мы вызываем конечную точку /api/logout, предоставляемую промежуточным программным обеспечением. В завершение мы перенаправляем пользователя на страницу /login.

Давайте обновим домашнюю страницу нашего сервера. Перейдите в ./app/page.tsx и вставьте следующее

import { getTokens } from "next-firebase-auth-edge";
import { cookies } from "next/headers";
import { notFound } from "next/navigation";
import { clientConfig, serverConfig } from "../config";
import HomePage from "./HomePage";

export default async function Home() {
  const tokens = await getTokens(cookies(), {
    apiKey: clientConfig.apiKey,
    cookieName: serverConfig.cookieName,
    cookieSignatureKeys: serverConfig.cookieSignatureKeys,
    serviceAccount: serverConfig.serviceAccount,
  });

  if (!tokens) {
    notFound();
  }

  return <HomePage email={tokens?.decodedToken.email} />;
}

Теперь наш серверный компонент Home отвечает только за получение токенов пользователя и передачу их клиентскому компоненту HomePage. На самом деле это довольно распространенный и полезный шаблон.

Давайте проверим это:

Вуаля! Теперь мы можем входить и выходить из приложения по своему желанию. Это идеально!

Или это так?

Когда неаутентифицированный пользователь пытается войти на домашнюю страницу, открыв http://localhost:3000/, мы отображаем 404: эта страница не найдена. .

Кроме того, прошедшие проверку подлинности пользователи по-прежнему смогут получить доступ к http://localhost:3000/register и http://localhost:3000/login без необходимости выхода из системы.

Мы можем добиться большего.

Кажется, нам нужно добавить логику перенаправления. Давайте определим некоторые правила:

  • Когда аутентифицированный пользователь пытается получить доступ к страницам /register и /login, мы должны перенаправить его на /
  • Когда неаутентифицированный пользователь пытается получить доступ к странице /, мы должны перенаправить его на /login

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

Давайте откроем файл middleware.ts и вставим эту обновленную версию

import { NextRequest, NextResponse } from "next/server";
import { authMiddleware, redirectToHome, redirectToLogin } from "next-firebase-auth-edge";
import { clientConfig, serverConfig } from "./config";

const PUBLIC_PATHS = ['/register', '/login'];

export async function middleware(request: NextRequest) {
  return authMiddleware(request, {
    loginPath: "/api/login",
    logoutPath: "/api/logout",
    apiKey: clientConfig.apiKey,
    cookieName: serverConfig.cookieName,
    cookieSignatureKeys: serverConfig.cookieSignatureKeys,
    cookieSerializeOptions: serverConfig.cookieSerializeOptions,
    serviceAccount: serverConfig.serviceAccount,
    handleValidToken: async ({token, decodedToken}, headers) => {
      if (PUBLIC_PATHS.includes(request.nextUrl.pathname)) {
        return redirectToHome(request);
      }

      return NextResponse.next({
        request: {
          headers
        }
      });
    },
    handleInvalidToken: async (reason) => {
      console.info('Missing or malformed credentials', {reason});

      return redirectToLogin(request, {
        path: '/login',
        publicPaths: PUBLIC_PATHS
      });
    },
    handleError: async (error) => {
      console.error('Unhandled authentication error', {error});

      return redirectToLogin(request, {
        path: '/login',
        publicPaths: PUBLIC_PATHS
      });
    }
  });
}

export const config = {
  matcher: [
    "/",
    "/((?!_next|api|.*.).*)",
    "/api/login",
    "/api/logout",
  ],
};

Вот и все. Мы реализовали все правила перенаправления. Давайте разберемся.

const PUBLIC_PATHS = ['/register', '/login'];

  handleValidToken: async ({token, decodedToken}, headers) => {
    if (PUBLIC_PATHS.includes(request.nextUrl.pathname)) {
      return redirectToHome(request);
    }

    return NextResponse.next({
      request: {
        headers
      }
    });
  },

handleValidToken вызывается, когда к запросу прилагаются действительные учетные данные пользователя, т.е. пользователь аутентифицирован. Он вызывается с объектом tokens в качестве первого и Измененные заголовки запроса в качестве второго аргумента. Это должно разрешиться с помощью NextResponse .

redirectToHome из next-firebase-auth-edge — это вспомогательная функция, которая возвращает объект, который можно упростить до NextResponse.redirect(new URL(“/“) )

Проверяя PUBLIC_PATHS.includes(request.nextUrl.pathname) , мы проверяем, пытается ли аутентифицированный пользователь получить доступ к странице /login или /register, и в этом случае перенаправьте на главную страницу.

handleInvalidToken: async (reason) => {
  console.info('Missing or malformed credentials', {reason});

  return redirectToLogin(request, {
    path: '/login',
    publicPaths: PUBLIC_PATHS
  });
},

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

Зная, что handleInvalidToken вызывается для неаутентифицированного пользователя, мы можем перейти ко второму правилу: Когда неаутентифицированный пользователь пытается получить доступ / страницу , мы должны перенаправить их на /login

Поскольку других условий не требуется, мы просто возвращаем результат redirectToLogin, который можно упростить до NextResponse.redirect(new URL(“/login”)) . Это также гарантирует, что пользователь не попадет в цикл перенаправления.

Наконец,

handleError: async (error) => {
 console.error('Unhandled authentication error', {error});

 return redirectToLogin(request, {
   path: '/login',
   publicPaths: PUBLIC_PATHS
 });
}

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

В случае ошибки мы фиксируем этот факт и безопасно перенаправляем пользователя на страницу входа

*handleError может быть вызван с ошибкой INVALID_ARGUMENT после обновления открытых ключей Google.

Это своего рода ротация ключей, и она ожидается. Дополнительную информацию см. в этом выпуске Github

Вот и все. Наконец-то.

Давайте выйдем из нашего веб-приложения и откроем http://localhost:3000/. Мы должны быть перенаправлены на страницу /login.

Давайте снова войдем в систему и попробуем ввести http://localhost:3000/login. Мы должны быть перенаправлены на страницу /.

Мы не только обеспечили удобство взаимодействия с пользователем. next-firebase-auth-edge — это библиотека нулевого размера, которая работает только на сервере приложения и не вводит дополнительный код на стороне клиента. Полученный пакет действительно минимальный. Это то, что я называю совершенством.

Наше приложение теперь полностью интегрировано с аутентификацией Firebase как в серверном, так и в клиентском компонентах. Мы готовы раскрыть весь потенциал Next.js!


Исходный код приложения можно найти в next-firebase-auth- край/examples/next-typescript-minimal

Эпилог

В этом руководстве мы рассмотрели интеграцию нового приложения Next.js с аутентификацией Firebase.

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

Если вас заинтересовала библиотека, вы можете просмотреть полноценную начальную демонстрационную страницу next-firebase-auth-edge. .

Включает интеграцию с Firestore, Действия сервера, поддержку проверки приложений и многое другое

В библиотеке есть специальная страница документации с множеством примеров

Если вам понравилась статья, я был бы признателен за участие в репозитории next-firebase-auth-edge. Ваше здоровье! 🎉

Бонус — развертывание приложения в Vercel

Это дополнительное руководство научит вас, как развернуть приложение Next.js в Vercel

Создание git-репозитория

Чтобы иметь возможность выполнить развертывание в Vercel, вам необходимо создать репозиторий для вашего нового приложения.

Перейдите на https://github.com/ и создайте новый репозиторий.

create-next-app уже инициировал для нас локальный репозиторий git, поэтому вам просто нужно перейти в корневую папку вашего проекта и запустить:

git add --all
git commit -m "first commit"
git branch -M main
git remote add origin git@github.com:path-to-your-new-github-repository.git
git push -u origin main

Добавление нового проекта Vercel

Перейдите на сайт https://vercel.com/ и войдите в свою учетную запись Github

После входа в систему перейдите на страницу обзора Vercel и нажмите Добавить новый > Проект

Нажмите Импорт рядом с только что созданным репозиторием Github. Пока не развертывайте.

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

Deploying to Vercel

Не забудьте установить для USE_SECURE_COOKIES значение true , поскольку Vercel использует HTTPS по умолчанию

Теперь мы готовы нажать Развернуть

.

Подождите минуту или две, и вы сможете получить доступ к своему приложению по URL-адресу, подобному этому: https://next- typescript-minimal-xi.vercel.app/

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


Если вам понравилось руководство, я был бы признателен за участие в репозитории next-firebase-auth-edge.

Вы также можете оставить мне свой отзыв в комментариях. Ваше здоровье! 🎉


Оригинал