Usamos cookies para mejorar tu experiencia en el sitio
CodeWorlds

Podstawy uwierzytelniania w aplikacjach Next.js

Uwierzytelnianie (ang. authentication) to fundamentalny aspekt nowoczesnych aplikacji webowych. Pozwala zweryfikować tożsamość użytkowników, co jest niezbędne do personalizacji doświadczeń, ochrony danych i udostępniania funkcji tylko uprawnionym osobom. W tym module poznamy podstawy implementacji uwierzytelniania w aplikacjach Next.js, które łączy zalety renderowania po stronie serwera (SSR) i klienta.

Czym jest uwierzytelnianie i dlaczego jest ważne?

Uwierzytelnianie to proces weryfikacji tożsamości użytkownika. Jest to kluczowy element zabezpieczeń aplikacji internetowych, ponieważ:

  1. Chroni dane użytkowników - zapewnia, że osobiste informacje są dostępne tylko dla ich właścicieli
  2. Umożliwia personalizację - pozwala na dostosowanie doświadczenia do konkretnego użytkownika
  3. Kontroluje dostęp - określa, do jakich zasobów i funkcji użytkownik ma dostęp
  4. Zapewnia odpowiedzialność - umożliwia śledzenie, kto i kiedy wykonywał określone działania

Uwierzytelnianie vs. Autoryzacja

Ważne jest rozróżnienie dwóch powiązanych, ale oddzielnych koncepcji:

  • Uwierzytelnianie (Authentication) - weryfikacja tożsamości użytkownika ("Czy jesteś tym, za kogo się podajesz?")
  • Autoryzacja (Authorization) - określanie, co uwierzytelniony użytkownik może robić ("Czy masz prawo to zrobić?")

W wielu przypadkach oba te procesy są ze sobą powiązane, ale pełnią różne funkcje w architekturze bezpieczeństwa.

Specyfika uwierzytelniania w Next.js

Next.js oferuje hybrydowy model renderowania, co ma znaczący wpływ na implementację uwierzytelniania:

  1. Renderowanie po stronie serwera (SSR) - umożliwia weryfikację uwierzytelnienia przed wysłaniem strony do klienta
  2. Statyczna generacja stron (SSG) - wymaga dodatkowej weryfikacji klienta dla chronionych treści
  3. Komponent App Router - zapewnia nowe, dedykowane mechanizmy do obsługi uwierzytelniania

Podstawowe modele uwierzytelniania

W aplikacjach Next.js można zastosować kilka głównych modeli uwierzytelniania:

1. Uwierzytelnianie oparte na sesji

Tradycyjne podejście polegające na utrzymywaniu stanu sesji na serwerze:

1// Przykład API route do logowania z sesją
2import { NextApiRequest, NextApiResponse } from 'next';
3import { getServerSession } from 'next-auth/next';
4import { authOptions } from '../auth/[...nextauth]';
5
6export default async function handler(req: NextApiRequest, res: NextApiResponse) {
7  const session = await getServerSession(req, res, authOptions);
8  
9  if (!session) {
10    return res.status(401).json({ message: 'Unauthorized' });
11  }
12  
13  // Użytkownik uwierzytelniony, można kontynuować
14  return res.status(200).json({ user: session.user });
15}

2. Uwierzytelnianie oparte na tokenach (JWT)

Popularne podejście wykorzystujące tokeny JWT (JSON Web Tokens):

1// Przykład API route do weryfikacji tokenu JWT
2import { NextApiRequest, NextApiResponse } from 'next';
3import jwt from 'jsonwebtoken';
4
5export default function handler(req: NextApiRequest, res: NextApiResponse) {
6  const { authorization } = req.headers;
7  
8  if (!authorization || !authorization.startsWith('Bearer ')) {
9    return res.status(401).json({ message: 'Unauthorized' });
10  }
11  
12  const token = authorization.replace('Bearer ', '');
13  
14  try {
15    const decoded = jwt.verify(token, process.env.JWT_SECRET!);
16    // Token prawidłowy, można kontynuować
17    return res.status(200).json({ user: decoded });
18  } catch (error) {
19    return res.status(401).json({ message: 'Invalid token' });
20  }
21}

3. Uwierzytelnianie z zewnętrznymi dostawcami (OAuth)

Wykorzystanie serwisów zewnętrznych jak Google, Facebook czy GitHub:

1// Przykład konfiguracji NextAuth.js z uwierzytelnianiem Google
2import NextAuth from 'next-auth';
3import GoogleProvider from 'next-auth/providers/google';
4
5export default NextAuth({
6  providers: [
7    GoogleProvider({
8      clientId: process.env.GOOGLE_CLIENT_ID!,
9      clientSecret: process.env.GOOGLE_CLIENT_SECRET!
10    })
11  ],
12  // Dodatkowa konfiguracja...
13});

Implementacja uwierzytelniania w Next.js App Router

W nowszych wersjach Next.js (13+) z App Router, podejście do uwierzytelniania zostało ulepszone. Poniżej przedstawiamy podstawowe kroki:

1. Konfiguracja dostawcy uwierzytelniania

Najpierw musimy skonfigurować dostawcę uwierzytelniania. W tym przykładzie użyjemy Auth.js (dawniej NextAuth.js):

1// app/api/auth/[...nextauth]/route.ts
2import NextAuth from 'next-auth';
3import CredentialsProvider from 'next-auth/providers/credentials';
4import { compare } from 'bcrypt';
5import { prisma } from '@/lib/prisma';
6
7export const authOptions = {
8  providers: [
9    CredentialsProvider({
10      name: 'Credentials',
11      credentials: {
12        email: { label: "Email", type: "email" },
13        password: { label: "Password", type: "password" }
14      },
15      async authorize(credentials) {
16        if (!credentials?.email || !credentials?.password) {
17          return null;
18        }
19        
20        const user = await prisma.user.findUnique({
21          where: {
22            email: credentials.email
23          }
24        });
25        
26        if (!user) {
27          return null;
28        }
29        
30        const isPasswordValid = await compare(
31          credentials.password,
32          user.password
33        );
34        
35        if (!isPasswordValid) {
36          return null;
37        }
38        
39        return {
40          id: user.id,
41          email: user.email,
42          name: user.name
43        };
44      }
45    })
46  ],
47  pages: {
48    signIn: '/login',
49    signOut: '/logout',
50    error: '/login',
51  },
52  session: {
53    strategy: 'jwt'
54  }
55};
56
57const handler = NextAuth(authOptions);
58export { handler as GET, handler as POST };

2. Tworzenie komponentów do logowania i rejestracji

Następnie potrzebujemy interfejsu użytkownika do logowania:

1// app/login/page.tsx
2'use client';
3
4import { useState } from 'react';
5import { signIn } from 'next-auth/react';
6import { useRouter } from 'next/navigation';
7
8export default function LoginPage() {
9  const router = useRouter();
10  const [email, setEmail] = useState('');
11  const [password, setPassword] = useState('');
12  const [error, setError] = useState('');
13  const [loading, setLoading] = useState(false);
14  
15  async function handleSubmit(e: React.FormEvent) {
16    e.preventDefault();
17    setLoading(true);
18    setError('');
19    
20    try {
21      const result = await signIn('credentials', {
22        redirect: false,
23        email,
24        password
25      });
26      
27      if (result?.error) {
28        setError('Nieprawidłowy email lub hasło');
29      } else {
30        router.push('/dashboard');
31        router.refresh();
32      }
33    } catch (error) {
34      setError('Wystąpił błąd podczas logowania');
35    } finally {
36      setLoading(false);
37    }
38  }
39  
40  return (
41    <div className="max-w-md mx-auto mt-10 p-6 bg-white rounded-lg shadow-md">
42      <h1 className="text-2xl font-bold mb-6">Logowanie</h1>
43      
44      {error && (
45        <div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
46          {error}
47        </div>
48      )}
49      
50      <form onSubmit={handleSubmit} className="space-y-4">
51        <div>
52          <label htmlFor="email" className="block text-sm font-medium mb-1">
53            Email
54          </label>
55          <input
56            id="email"
57            type="email"
58            value={email}
59            onChange={(e) => setEmail(e.target.value)}
60            required
61            className="w-full px-3 py-2 border border-gray-300 rounded"
62          />
63        </div>
64        
65        <div>
66          <label htmlFor="password" className="block text-sm font-medium mb-1">
67            Hasło
68          </label>
69          <input
70            id="password"
71            type="password"
72            value={password}
73            onChange={(e) => setPassword(e.target.value)}
74            required
75            className="w-full px-3 py-2 border border-gray-300 rounded"
76          />
77        </div>
78        
79        <button
80          type="submit"
81          disabled={loading}
82          className="w-full bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600 disabled:opacity-50"
83        >
84          {loading ? 'Logowanie...' : 'Zaloguj się'}
85        </button>
86      </form>
87    </div>
88  );
89}

Oraz stronę do rejestracji:

1// app/register/page.tsx
2'use client';
3
4import { useState } from 'react';
5import { useRouter } from 'next/navigation';
6
7export default function RegisterPage() {
8  const router = useRouter();
9  const [name, setName] = useState('');
10  const [email, setEmail] = useState('');
11  const [password, setPassword] = useState('');
12  const [confirmPassword, setConfirmPassword] = useState('');
13  const [error, setError] = useState('');
14  const [loading, setLoading] = useState(false);
15  
16  async function handleSubmit(e: React.FormEvent) {
17    e.preventDefault();
18    setLoading(true);
19    setError('');
20    
21    // Walidacja formularza
22    if (password !== confirmPassword) {
23      setError('Hasła nie są identyczne');
24      setLoading(false);
25      return;
26    }
27    
28    try {
29      const response = await fetch('/api/register', {
30        method: 'POST',
31        headers: { 'Content-Type': 'application/json' },
32        body: JSON.stringify({ name, email, password })
33      });
34      
35      const data = await response.json();
36      
37      if (!response.ok) {
38        throw new Error(data.message || 'Wystąpił błąd podczas rejestracji');
39      }
40      
41      // Rejestracja pomyślna, przekieruj do strony logowania
42      router.push('/login?registered=true');
43    } catch (error) {
44      setError(error instanceof Error ? error.message : 'Wystąpił błąd');
45    } finally {
46      setLoading(false);
47    }
48  }
49  
50  return (
51    <div className="max-w-md mx-auto mt-10 p-6 bg-white rounded-lg shadow-md">
52      <h1 className="text-2xl font-bold mb-6">Rejestracja</h1>
53      
54      {error && (
55        <div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
56          {error}
57        </div>
58      )}
59      
60      <form onSubmit={handleSubmit} className="space-y-4">
61        <div>
62          <label htmlFor="name" className="block text-sm font-medium mb-1">
63            Imię i nazwisko
64          </label>
65          <input
66            id="name"
67            type="text"
68            value={name}
69            onChange={(e) => setName(e.target.value)}
70            required
71            className="w-full px-3 py-2 border border-gray-300 rounded"
72          />
73        </div>
74        
75        <div>
76          <label htmlFor="email" className="block text-sm font-medium mb-1">
77            Email
78          </label>
79          <input
80            id="email"
81            type="email"
82            value={email}
83            onChange={(e) => setEmail(e.target.value)}
84            required
85            className="w-full px-3 py-2 border border-gray-300 rounded"
86          />
87        </div>
88        
89        <div>
90          <label htmlFor="password" className="block text-sm font-medium mb-1">
91            Hasło
92          </label>
93          <input
94            id="password"
95            type="password"
96            value={password}
97            onChange={(e) => setPassword(e.target.value)}
98            required
99            minLength={8}
100            className="w-full px-3 py-2 border border-gray-300 rounded"
101          />
102        </div>
103        
104        <div>
105          <label htmlFor="confirmPassword" className="block text-sm font-medium mb-1">
106            Potwierdź hasło
107          </label>
108          <input
109            id="confirmPassword"
110            type="password"
111            value={confirmPassword}
112            onChange={(e) => setConfirmPassword(e.target.value)}
113            required
114            className="w-full px-3 py-2 border border-gray-300 rounded"
115          />
116        </div>
117        
118        <button
119          type="submit"
120          disabled={loading}
121          className="w-full bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600 disabled:opacity-50"
122        >
123          {loading ? 'Rejestracja...' : 'Zarejestruj się'}
124        </button>
125      </form>
126    </div>
127  );
128}

3. Implementacja API endpointu do rejestracji

Aby umożliwić rejestrację, potrzebujemy endpointu API:

1// app/api/register/route.ts
2import { NextResponse } from 'next/server';
3import { hash } from 'bcrypt';
4import { prisma } from '@/lib/prisma';
5import { z } from 'zod';
6
7// Schemat walidacji
8const UserSchema = z.object({
9  name: z.string().min(2, 'Imię musi mieć co najmniej 2 znaki'),
10  email: z.string().email('Nieprawidłowy adres email'),
11  password: z.string().min(8, 'Hasło musi mieć co najmniej 8 znaków')
12});
13
14export async function POST(request: Request) {
15  try {
16    const body = await request.json();
17    
18    // Walidacja danych
19    const result = UserSchema.safeParse(body);
20    
21    if (!result.success) {
22      return NextResponse.json(
23        { message: result.error.errors[0].message },
24        { status: 400 }
25      );
26    }
27    
28    const { name, email, password } = result.data;
29    
30    // Sprawdź, czy użytkownik już istnieje
31    const existingUser = await prisma.user.findUnique({
32      where: { email }
33    });
34    
35    if (existingUser) {
36      return NextResponse.json(
37        { message: 'Użytkownik o podanym adresie email już istnieje' },
38        { status: 409 }
39      );
40    }
41    
42    // Haszowanie hasła
43    const hashedPassword = await hash(password, 10);
44    
45    // Utworzenie nowego użytkownika
46    const user = await prisma.user.create({
47      data: {
48        name,
49        email,
50        password: hashedPassword
51      }
52    });
53    
54    // Zwróć odpowiedź bez hasła
55    const { password: _, ...userWithoutPassword } = user;
56    
57    return NextResponse.json(
58      { message: 'Rejestracja zakończona pomyślnie', user: userWithoutPassword },
59      { status: 201 }
60    );
61  } catch (error) {
62    console.error('Registration error:', error);
63    return NextResponse.json(
64      { message: 'Wystąpił błąd serwera' },
65      { status: 500 }
66    );
67  }
68}

4. Zabezpieczenie stron chronionych

Aby zabezpieczyć strony, które powinny być dostępne tylko dla zalogowanych użytkowników, możemy użyć middleware:

1// middleware.ts
2import { NextResponse } from 'next/server';
3import type { NextRequest } from 'next/server';
4import { getToken } from 'next-auth/jwt';
5
6export async function middleware(request: NextRequest) {
7  const token = await getToken({ req: request });
8  
9  // Sprawdź, czy użytkownik jest zalogowany
10  if (!token) {
11    const url = new URL('/login', request.url);
12    url.searchParams.set('callbackUrl', request.nextUrl.pathname);
13    return NextResponse.redirect(url);
14  }
15  
16  return NextResponse.next();
17}
18
19// Określ ścieżki, które mają być chronione
20export const config = {
21  matcher: ['/dashboard/:path*', '/profile/:path*', '/settings/:path*']
22};

5. Wyświetlanie danych użytkownika i zarządzanie sesją

Aby wykorzystać informacje o zalogowanym użytkowniku w komponencie, możemy użyć hooka

useSession
:

1// components/ProfileSection.tsx
2'use client';
3
4import { useSession } from 'next-auth/react';
5import { signOut } from 'next-auth/react';
6
7export default function ProfileSection() {
8  const { data: session, status } = useSession();
9  
10  if (status === 'loading') {
11    return <div>Ładowanie...</div>;
12  }
13  
14  if (status === 'unauthenticated' || !session) {
15    return <div>Nie jesteś zalogowany</div>;
16  }
17  
18  return (
19    <div className="p-4 bg-white rounded shadow">
20      <h2 className="text-xl font-bold mb-4">Twój profil</h2>
21      <p>Witaj, {session.user?.name || 'użytkowniku'}!</p>
22      <p>Email: {session.user?.email}</p>
23      
24      <button
25        onClick={() => signOut({ callbackUrl: '/' })}
26        className="mt-4 px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
27      >
28        Wyloguj się
29      </button>
30    </div>
31  );
32}

6. Inicjalizacja SessionProvider dla całej aplikacji

Aby uwierzytelnianie działało poprawnie, musimy dodać

SessionProvider
na poziomie layoutu:

1// app/providers.tsx
2'use client';
3
4import { SessionProvider } from 'next-auth/react';
5import { ReactNode } from 'react';
6
7export function Providers({ children }: { children: ReactNode }) {
8  return <SessionProvider>{children}</SessionProvider>;
9}
10
11// app/layout.tsx
12import { Providers } from './providers';
13
14export default function RootLayout({
15  children,
16}: {
17  children: React.ReactNode;
18}) {
19  return (
20    <html lang="en">
21      <body>
22        <Providers>{children}</Providers>
23      </body>
24    </html>
25  );
26}

Mechanizmy zabezpieczeń

Implementując uwierzytelnianie, należy pamiętać o kilku kluczowych mechanizmach zabezpieczeń:

1. Bezpieczne przechowywanie haseł

Nigdy nie przechowuj haseł w formie czystego tekstu! Zawsze używaj funkcji haszujących, takich jak

bcrypt
:

1import { hash, compare } from 'bcrypt';
2
3// Haszowanie hasła podczas rejestracji
4const hashedPassword = await hash(password, 10);
5
6// Weryfikacja hasła podczas logowania
7const isValid = await compare(password, hashedPassword);

2. Ochrona przed atakami CSRF

Cross-Site Request Forgery (CSRF) to rodzaj ataku, który wykorzystuje uwierzytelnioną sesję użytkownika do wykonania nieautoryzowanych działań. Next.js i NextAuth.js domyślnie oferują pewne zabezpieczenia, ale warto zapewnić dodatkową ochronę:

1// Dodanie tokenu CSRF do formularza
2export default function LoginForm() {
3  const csrfToken = await getCsrfToken();
4  
5  return (
6    <form method="post" action="/api/auth/callback/credentials">
7      <input name="csrfToken" type="hidden" defaultValue={csrfToken} />
8      {/* Reszta formularza */}
9    </form>
10  );
11}

3. Implementacja HTTPS

Używanie szyfrowanego połączenia HTTPS jest niezbędne do ochrony danych uwierzytelniających i tokenów sesji. W środowisku produkcyjnym upewnij się, że Twoja aplikacja jest dostępna tylko przez HTTPS.

4. Wygasanie tokenów JWT

Jeśli używasz tokenów JWT, ustaw im odpowiedni czas wygaśnięcia, aby ograniczyć ryzyko w przypadku kradzieży tokenu:

1// Konfiguracja wygasania tokenów JWT w NextAuth.js
2export const authOptions = {
3  // ... inne opcje
4  session: {
5    strategy: 'jwt',
6    maxAge: 30 * 24 * 60 * 60, // 30 dni
7  },
8  jwt: {
9    secret: process.env.JWT_SECRET,
10    maxAge: 30 * 24 * 60 * 60, // 30 dni
11  }
12};

Typowe wyzwania i rozwiązania

Problem: Wymuszanie uwierzytelnienia na stronach statycznych (SSG)

Wyzwaniem może być zabezpieczenie stron, które są generowane statycznie podczas budowania:

Rozwiązanie: Dodaj dodatkową warstwę weryfikacji po stronie klienta:

1'use client';
2
3import { useSession } from 'next-auth/react';
4import { useRouter } from 'next/navigation';
5import { useEffect } from 'react';
6
7export default function ProtectedPage() {
8  const { status } = useSession();
9  const router = useRouter();
10  
11  useEffect(() => {
12    if (status === 'unauthenticated') {
13      router.push('/login');
14    }
15  }, [status, router]);
16  
17  if (status === 'loading') {
18    return <div>Ładowanie...</div>;
19  }
20  
21  if (status === 'unauthenticated') {
22    return null; // Zamiast renderować zawartość, przekieruj do strony logowania
23  }
24  
25  // Właściwa zawartość strony chronionej
26  return (
27    <div>
28      <h1>Strona chroniona</h1>
29      {/* Reszta zawartości */}
30    </div>
31  );
32}

Problem: Różne role użytkowników

Czasami potrzebujemy różnych poziomów dostępu dla użytkowników:

Rozwiązanie: Rozszerz token/sesję o informacje o rolach i zaimplementuj sprawdzanie ról:

1// Rozszerzenie typów NextAuth
2declare module 'next-auth' {
3  interface Session {
4    user: {
5      id: string;
6      name?: string | null;
7      email?: string | null;
8      image?: string | null;
9      role: string;
10    };
11  }
12  
13  interface User {
14    id: string;
15    name?: string | null;
16    email?: string | null;
17    image?: string | null;
18    role: string;
19  }
20}
21
22// Używanie informacji o rolach
23function canAccessAdmin(session: Session | null) {
24  return session?.user?.role === 'admin';
25}
26
27// W komponencie
28const { data: session } = useSession();
29const isAdmin = session?.user?.role === 'admin';
30
31if (!isAdmin) {
32  return <div>Brak dostępu. Ta sekcja jest tylko dla administratorów.</div>;
33}

Podsumowanie

Uwierzytelnianie w Next.js może być implementowane na wiele sposobów, ale najczęściej używane podejścia obejmują:

  1. NextAuth.js / Auth.js - kompleksowe rozwiązanie oferujące integrację z wieloma dostawcami uwierzytelniania
  2. Własne rozwiązanie JWT - dla niestandardowych wymagań
  3. Zewnętrzne serwisy - np. Firebase Auth, Auth0, Clerk

Wybór odpowiedniego rozwiązania zależy od specyficznych wymagań Twojej aplikacji, takich jak:

  • Potrzeba integracji z zewnętrznymi dostawcami (Google, Facebook, itp.)
  • Wymagania dotyczące własności danych
  • Złożoność modelu uprawnień i ról
  • Skalowalność

W kolejnych modułach przyjrzymy się bardziej zaawansowanym aspektom uwierzytelniania, w tym szczegółowej implementacji NextAuth.js, zarządzaniu sesjami i tokenami, ochronie ścieżek za pomocą middleware, oraz zagadnieniom bezpieczeństwa.

Ir a CodeWorlds