Jak zaimplementować uwierzytelnianie tokenem w Next.js przy użyciu tokenów JWT

Autentykacja za pomocą tokenów to popularna metoda zabezpieczania aplikacji internetowych oraz mobilnych przed dostępem osób niepowołanych. W środowisku Next.js możesz skorzystać z gotowych mechanizmów uwierzytelniania oferowanych przez bibliotekę Next-auth.

Możesz również wybrać opcję utworzenia własnego, spersonalizowanego systemu autentykacji opartego na tokenach, wykorzystując do tego celu tokeny JSON Web Tokens (JWT). Takie rozwiązanie zapewnia większą kontrolę nad logiką uwierzytelniania i umożliwia dostosowanie systemu do konkretnych wymagań twojego projektu.

Konfiguracja projektu Next.js

Na początek, zainstaluj Next.js, wykonując poniższe polecenie w terminalu.

 npx create-next-app@latest next-auth-jwt --experimental-app 

W tym poradniku wykorzystamy projekt Next.js w wersji 13, który używa katalogu aplikacji.

Następnie, zainstaluj niezbędne zależności w swoim projekcie za pomocą npm, menedżera pakietów Node.js.

 npm install jose universal-cookie 

Jose to moduł JavaScript, który dostarcza narzędzia do pracy z tokenami JSON Web Tokens, natomiast universal-cookie to biblioteka ułatwiająca operacje na plikach cookie przeglądarki, zarówno po stronie klienta, jak i serwera.

Stworzenie interfejsu użytkownika formularza logowania

Przejdź do folderu src/app, utwórz nowy katalog o nazwie login. Wewnątrz tego folderu dodaj nowy plik o nazwie page.js i umieść w nim poniższy kod.

 "use client";
import { useRouter } from "next/navigation";

export default function LoginPage() {
  return (
    <form onSubmit={handleSubmit}>
      <label>
        Nazwa użytkownika:
        <input type="text" name="username" />
      </label>
      <label>
        Hasło:
        <input type="password" name="password" />
      </label>
      <button type="submit">Zaloguj</button>
    </form>
  );
}

Powyższy kod generuje komponent strony logowania, który w przeglądarce wyświetla prosty formularz z polami na nazwę użytkownika i hasło.

Dyrektywa „use client” w kodzie definiuje granicę między kodem wykonywanym po stronie serwera a kodem działającym po stronie klienta w obrębie katalogu aplikacji.

W tym przypadku informuje ona, że kod na stronie logowania, a szczególnie funkcja handleSubmit, będzie uruchamiany tylko po stronie klienta. W innym przypadku Next.js zwróci błąd.

Teraz przejdziemy do definicji funkcji handleSubmit. W obrębie komponentu funkcyjnego dodaj poniższy fragment kodu.

 const router = useRouter();

const handleSubmit = async (event) => {
    event.preventDefault();
    const formData = new FormData(event.target);
    const username = formData.get("username");
    const password = formData.get("password");
    const res = await fetch("/api/login", {
      method: "POST",
      body: JSON.stringify({ username, password }),
    });
    const { success } = await res.json();
    if (success) {
      router.push("/protected");
      router.refresh();
    } else {
      alert("Logowanie nieudane");
    }
 };

Ta funkcja, odpowiedzialna za zarządzanie logiką uwierzytelniania, pobiera dane logowania użytkownika z formularza. Następnie wysyła żądanie POST do punktu końcowego API, przesyłając te dane w celu weryfikacji.

W przypadku, gdy dane uwierzytelniające są poprawne, co oznacza udane logowanie, API zwraca informację o sukcesie. Funkcja obsługi używa routera Next.js do przekierowania użytkownika do określonego adresu URL, w tym przypadku do ścieżki chronionej.

Definicja punktu końcowego API logowania

W folderze src/app utwórz nowy katalog o nazwie api. W jego wnętrzu dodaj nowy folder o nazwie login, a następnie w nim utwórz plik route.js i wklej do niego poniższy kod.

 import { SignJWT } from "jose";
import { NextResponse } from "next/server";
import { getJwtSecretKey } from "@/libs/auth";

export async function POST(request) {
  const body = await request.json();
  if (body.username === "admin" && body.password === "admin") {
    const token = await new SignJWT({
      username: body.username,
    })
      .setProtectedHeader({ alg: "HS256" })
      .setIssuedAt()
      .setExpirationTime("30s")
      .sign(getJwtSecretKey());
    const response = NextResponse.json(
      { success: true },
      { status: 200, headers: { "content-type": "application/json" } }
    );
    response.cookies.set({
      name: "token",
      value: token,
      path: "https://www.makeuseof.com/",
    });
    return response;
  }
  return NextResponse.json({ success: false });
}

Głównym zadaniem tego API jest weryfikacja danych logowania przekazywanych w żądaniach POST, wykorzystując do tego celu przykładowe dane.

Po udanej weryfikacji generuje zaszyfrowany token JWT powiązany z danymi uwierzytelnionego użytkownika. Na koniec wysyła do klienta pomyślną odpowiedź, umieszczając token w ciasteczkach (cookies) odpowiedzi. W przeciwnym wypadku, zwraca odpowiedź o błędzie.

Implementacja logiki weryfikacji tokena

Pierwszym krokiem w autentykacji opartej na tokenach jest wygenerowanie tokena po pomyślnym procesie logowania. Kolejnym krokiem jest implementacja logiki weryfikacji tokena.

Do weryfikacji tokenów JWT przesyłanych w kolejnych żądaniach HTTP wykorzystamy funkcję jwtVerify udostępnianą przez moduł Jose.

W katalogu src utwórz nowy plik libs/auth.js i dodaj do niego poniższy kod.

 import { jwtVerify } from "jose";

export function getJwtSecretKey() {
  const secret = process.env.NEXT_PUBLIC_JWT_SECRET_KEY;
  if (!secret) {
    throw new Error("Klucz tajny JWT nie został zdefiniowany");
  }
  return new TextEncoder().encode(secret);
}

export async function verifyJwtToken(token) {
  try {
    const { payload } = await jwtVerify(token, getJwtSecretKey());
    return payload;
  } catch (error) {
    return null;
  }
}

Klucz tajny jest używany do podpisywania i weryfikacji tokenów. Porównując zdekodowany podpis tokena z oczekiwanym podpisem, serwer może skutecznie zweryfikować, czy przekazany token jest ważny i tym samym autoryzować żądania użytkowników.

Utwórz plik .env w głównym katalogu i zdefiniuj w nim unikalny klucz tajny w następujący sposób:

 NEXT_PUBLIC_JWT_SECRET_KEY=twój_sekretny_klucz 

Utworzenie chronionej trasy

Teraz należy utworzyć trasę, która będzie dostępna tylko dla uwierzytelnionych użytkowników. W tym celu, utwórz nowy plik chroniony/page.js w katalogu src/app. Wewnątrz tego pliku umieść poniższy kod.

 export default function ProtectedPage() {
    return <h1>Strona dostępna tylko dla zalogowanych użytkowników</h1>;
  }

Utworzenie haka do zarządzania stanem uwierzytelniania

W katalogu src utwórz nowy folder o nazwie hooks. Wewnątrz tego folderu utwórz nowy folder o nazwie useAuth, a w nim umieść plik index.js i dodaj do niego poniższy kod.

 "use client" ;
import React from "react";
import Cookies from "universal-cookie";
import { verifyJwtToken } from "@/libs/auth";

export function useAuth() {
  const [auth, setAuth] = React.useState(null);

  const getVerifiedtoken = async () => {
    const cookies = new Cookies();
    const token = cookies.get("token") ?? null;
    const verifiedToken = await verifyJwtToken(token);
    setAuth(verifiedToken);
  };
  React.useEffect(() => {
    getVerifiedtoken();
  }, []);
  return auth;
}

Ten hak zarządza stanem uwierzytelnienia po stronie klienta. Pobiera i weryfikuje poprawność tokena JWT z plików cookie za pomocą funkcji verifyJwtToken, a następnie ustawia dane uwierzytelnionego użytkownika w stanie autoryzacji.

Umożliwia tym samym innym komponentom dostęp do danych uwierzytelnionego użytkownika i wykorzystywanie ich. Jest to szczególnie ważne w sytuacjach takich jak aktualizacja interfejsu użytkownika na podstawie stanu uwierzytelnienia, wysyłanie kolejnych żądań do API, czy renderowanie różnych treści zależnie od roli użytkownika.

W naszym przypadku użyjemy haka do renderowania różnej zawartości na stronie głównej, zależnie od stanu uwierzytelnienia użytkownika.

Alternatywnie, możesz rozważyć zarządzanie stanem przy użyciu pakietu Redux Toolkit lub wykorzystanie narzędzia do zarządzania stanem takiego jak Jotai. Takie podejście zapewnia globalny dostęp do stanu uwierzytelniania lub innego zdefiniowanego stanu.

Przejdź do pliku app/page.js, usuń dotychczasową zawartość i dodaj poniższy kod.

 "use client" ;

import { useAuth } from "@/hooks/useAuth";
import Link from "next/link";
export default function Home() {
  const auth = useAuth();
  return <>
           <h1>Publiczna strona główna</h1>
           <header>
              <nav>
                {auth ? (
                   <p>Zalogowano</p>
                ) : (
                  <Link href="https://wilku.top/login">Zaloguj</Link>
                )}
              </nav>
          </header>
  </>
}

Powyższy kod używa haka useAuth do zarządzania stanem uwierzytelnienia. Dzięki temu, warunkowo renderuje stronę główną z linkiem do logowania dla niezalogowanych użytkowników, a dla zalogowanych wyświetla informację o zalogowaniu.

Dodanie oprogramowania pośredniczącego do ochrony chronionych tras

W katalogu src utwórz nowy plik middleware.js i umieść w nim poniższy kod.

 import { NextResponse } from "next/server";
import { verifyJwtToken } from "@/libs/auth";

const AUTH_PAGES = ["https://wilku.top/login"];

const isAuthPages = (url) => AUTH_PAGES.some((page) => page.startsWith(url));

export async function middleware(request) {

  const { url, nextUrl, cookies } = request;
  const { value: token } = cookies.get("token") ?? { value: null };
  const hasVerifiedToken = token && (await verifyJwtToken(token));
  const isAuthPageRequested = isAuthPages(nextUrl.pathname);

  if (isAuthPageRequested) {
    if (!hasVerifiedToken) {
      const response = NextResponse.next();
      response.cookies.delete("token");
      return response;
    }
    const response = NextResponse.redirect(new URL(`/`, url));
    return response;
  }

  if (!hasVerifiedToken) {
    const searchParams = new URLSearchParams(nextUrl.searchParams);
    searchParams.set("next", nextUrl.pathname);
    const response = NextResponse.redirect(
      new URL(`/login?${searchParams}`, url)
    );
    response.cookies.delete("token");
    return response;
  }

  return NextResponse.next();

}
export const config = { matcher: ["https://wilku.top/login", "/protected/:path*"] };

Ten kod oprogramowania pośredniczącego działa jak strażnik. Sprawdza, czy użytkownicy próbujący uzyskać dostęp do chronionych stron są uwierzytelnieni i mają prawo do dostępu do tych tras. Nieautoryzowanych użytkowników przekierowuje na stronę logowania.

Zabezpieczanie aplikacji Next.js

Autentykacja za pomocą tokenów jest skutecznym mechanizmem zabezpieczeń, ale nie jest jedyną dostępną metodą ochrony aplikacji przed nieautoryzowanym dostępem.

Aby zwiększyć bezpieczeństwo aplikacji w dynamicznym krajobrazie cyberbezpieczeństwa, istotne jest holistyczne podejście do ochrony, które eliminuje potencjalne słabości i luki w zabezpieczeniach, zapewniając kompleksowe bezpieczeństwo.