Utilizziamo i cookie per migliorare la tua esperienza sul sito
CodeWorlds

Context Provider dla autentykacji

W poprzednich modułach poznaliśmy podstawy uwierzytelniania, podłączanie zewnętrznych dostawców, zarządzanie sesjami, system ról i uprawnień oraz middleware do ochrony tras. Teraz zajmiemy się tworzeniem Context Providera dla autentykacji, który ułatwi dostęp do danych i funkcji uwierzytelniania w całej aplikacji React.

Context API w React pozwala na przekazywanie danych przez drzewo komponentów bez konieczności przekazywania props na każdym poziomie. Jest to idealne rozwiązanie dla funkcjonalności, która jest potrzebna w wielu miejscach aplikacji - dokładnie tak jak autentykacja.

Dlaczego warto stworzyć własny Context Provider dla autentykacji?

NextAuth.js dostarcza już

SessionProvider
, który udostępnia podstawowe informacje o sesji. Jednak często warto rozszerzyć tę funkcjonalność o dodatkowe możliwości:

  1. Centralizacja logiki uwierzytelniania - wszystkie funkcje związane z uwierzytelnianiem w jednym miejscu
  2. Dodatkowe metody - np. sprawdzanie uprawnień, obsługa rejestracji, resetowania hasła
  3. Stan ładowania - łatwiejsze zarządzanie stanem ładowania sesji
  4. Obsługa błędów - spójne zarządzanie błędami uwierzytelniania
  5. Kontrakt typowania - jasno zdefiniowany interfejs dla TypeScript

Implementacja AuthContext i AuthProvider

Zacznijmy od zdefiniowania podstawowego kontekstu uwierzytelniania:

1// lib/authContext.tsx
2import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
3import { Session } from 'next-auth';
4import { useSession, signIn, signOut } from 'next-auth/react';
5
6// Definiowanie typu dla kontekstu uwierzytelniania
7type AuthContextType = {
8  session: Session | null;
9  status: 'loading' | 'authenticated' | 'unauthenticated';
10  isLoading: boolean;
11  isAuthenticated: boolean;
12  user: Session['user'] | null;
13  hasRole: (role: string | string[]) => boolean;
14  hasPermission: (permission: string | string[]) => boolean;
15  signIn: (provider?: string, options?: any) => Promise<any>;
16  signOut: (options?: any) => Promise<any>;
17  error: string | null;
18};
19
20// Tworzenie kontekstu z wartością domyślną (null)
21const AuthContext = createContext<AuthContextType | null>(null);
22
23// Hook pozwalający na łatwy dostęp do kontekstu
24export function useAuth() {
25  const context = useContext(AuthContext);
26  
27  if (!context) {
28    throw new Error('useAuth must be used within an AuthProvider');
29  }
30  
31  return context;
32}
33
34// Komponent dostawcy (provider)
35interface AuthProviderProps {
36  children: ReactNode;
37}
38
39export function AuthProvider({ children }: AuthProviderProps) {
40  // Używamy useSession z NextAuth.js do pobierania danych sesji
41  const { data: session, status } = useSession();
42  const [error, setError] = useState<string | null>(null);
43  
44  // Sprawdź, czy użytkownik ma określoną rolę
45  const hasRole = (role: string | string[]): boolean => {
46    if (!session?.user?.role) return false;
47    
48    if (Array.isArray(role)) {
49      return role.includes(session.user.role as string);
50    }
51    
52    return session.user.role === role;
53  };
54  
55  // Sprawdź, czy użytkownik ma określone uprawnienie
56  const hasPermission = (permission: string | string[]): boolean => {
57    if (!session?.user?.permissions) return false;
58    
59    const userPermissions = session.user.permissions as string[];
60    
61    if (Array.isArray(permission)) {
62      return permission.some(p => userPermissions.includes(p));
63    }
64    
65    return userPermissions.includes(permission as string);
66  };
67  
68  // Prosłupa funkcja opakowująca signIn z obsługą błędów
69  const handleSignIn = async (provider?: string, options?: any) => {
70    try {
71      setError(null);
72      return await signIn(provider, options);
73    } catch (err) {
74      console.error('Login error:', err);
75      setError('Wystąpił problem podczas logowania. Spróbuj ponownie.');
76      throw err;
77    }
78  };
79  
80  // Prosta funkcja opakowująca signOut
81  const handleSignOut = async (options?: any) => {
82    try {
83      return await signOut(options);
84    } catch (err) {
85      console.error('Logout error:', err);
86      setError('Wystąpił problem podczas wylogowywania.');
87      throw err;
88    }
89  };
90  
91  // Wartość kontekstu, która będzie udostępniana
92  const value: AuthContextType = {
93    session,
94    status,
95    isLoading: status === 'loading',
96    isAuthenticated: status === 'authenticated',
97    user: session?.user || null,
98    hasRole,
99    hasPermission,
100    signIn: handleSignIn,
101    signOut: handleSignOut,
102    error,
103  };
104  
105  return (
106    <AuthContext.Provider value={value}>
107      {children}
108    </AuthContext.Provider>
109  );
110}

Integracja AuthProvider z aplikacją

Autoryzacja jest funkcjonalnością całej aplikacji, więc najlepiej dodać AuthProvider na wysokim poziomie w drzewie komponentów, najlepiej w głównym layoucie aplikacji, opakowując nim

SessionProvider
z NextAuth.js:

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

Tworzenie PasswordResetProvider jako przykład wyspecjalizowanego providera

Często potrzebujemy również wyspecjalizowanych kontekstów dla konkretnych funkcjonalności uwierzytelniania, jak na przykład resetowanie hasła. Oto przykład takiego providera:

1// lib/passwordResetContext.tsx
2import { createContext, useContext, useState, ReactNode } from 'react';
3
4type PasswordResetContextType = {
5  isLoading: boolean;
6  error: string | null;
7  message: string | null;
8  requestPasswordReset: (email: string) => Promise<boolean>;
9  resetPassword: (token: string, newPassword: string) => Promise<boolean>;
10};
11
12const PasswordResetContext = createContext<PasswordResetContextType | null>(null);
13
14export function usePasswordReset() {
15  const context = useContext(PasswordResetContext);
16  
17  if (!context) {
18    throw new Error('usePasswordReset must be used within a PasswordResetProvider');
19  }
20  
21  return context;
22}
23
24interface PasswordResetProviderProps {
25  children: ReactNode;
26}
27
28export function PasswordResetProvider({ children }: PasswordResetProviderProps) {
29  const [isLoading, setIsLoading] = useState(false);
30  const [error, setError] = useState<string | null>(null);
31  const [message, setMessage] = useState<string | null>(null);
32  
33  // Funkcja do wysyłania żądania resetowania hasła
34  const requestPasswordReset = async (email: string): Promise<boolean> => {
35    setIsLoading(true);
36    setError(null);
37    setMessage(null);
38    
39    try {
40      const response = await fetch('/api/auth/reset-password', {
41        method: 'POST',
42        headers: { 'Content-Type': 'application/json' },
43        body: JSON.stringify({ email }),
44      });
45      
46      const data = await response.json();
47      
48      if (!response.ok) {
49        throw new Error(data.message || 'Wystąpił problem podczas wysyłania linku resetującego');
50      }
51      
52      setMessage('Instrukcje resetowania hasła zostały wysłane na podany adres email.');
53      return true;
54    } catch (err) {
55      setError(err instanceof Error ? err.message : 'Wystąpił nieznany błąd');
56      return false;
57    } finally {
58      setIsLoading(false);
59    }
60  };
61  
62  // Funkcja do ustawiania nowego hasła
63  const resetPassword = async (token: string, newPassword: string): Promise<boolean> => {
64    setIsLoading(true);
65    setError(null);
66    setMessage(null);
67    
68    try {
69      const response = await fetch('/api/auth/reset-password/confirm', {
70        method: 'POST',
71        headers: { 'Content-Type': 'application/json' },
72        body: JSON.stringify({ token, newPassword }),
73      });
74      
75      const data = await response.json();
76      
77      if (!response.ok) {
78        throw new Error(data.message || 'Wystąpił problem podczas resetowania hasła');
79      }
80      
81      setMessage('Hasło zostało pomyślnie zmienione. Możesz się teraz zalogować.');
82      return true;
83    } catch (err) {
84      setError(err instanceof Error ? err.message : 'Wystąpił nieznany błąd');
85      return false;
86    } finally {
87      setIsLoading(false);
88    }
89  };
90  
91  const value: PasswordResetContextType = {
92    isLoading,
93    error,
94    message,
95    requestPasswordReset,
96    resetPassword,
97  };
98  
99  return (
100    <PasswordResetContext.Provider value={value}>
101      {children}
102    </PasswordResetContext.Provider>
103  );
104}

Rozszerzenie AuthProvider o dodatkowe funkcjonalności

Nasz AuthProvider możemy rozszerzyć o dodatkowe funkcjonalności, które często będą potrzebne w aplikacjach:

1. Rejestracja użytkownika

1// Rozszerzone metody w AuthContext
2type AuthContextType = {
3  // ... istniejące pola
4  registerUser: (userData: RegisterUserData) => Promise<boolean>;
5};
6
7// Dane do rejestracji
8type RegisterUserData = {
9  name: string;
10  email: string;
11  password: string;
12};
13
14// Implementacja w AuthProvider
15export function AuthProvider({ children }: AuthProviderProps) {
16  // ... istniejący kod
17  
18  const registerUser = async (userData: RegisterUserData): Promise<boolean> => {
19    try {
20      setError(null);
21      const response = await fetch('/api/auth/register', {
22        method: 'POST',
23        headers: { 'Content-Type': 'application/json' },
24        body: JSON.stringify(userData),
25      });
26      
27      const data = await response.json();
28      
29      if (!response.ok) {
30        throw new Error(data.message || 'Wystąpił problem podczas rejestracji');
31      }
32      
33      return true;
34    } catch (err) {
35      console.error('Registration error:', err);
36      setError(err instanceof Error ? err.message : 'Wystąpił nieznany błąd podczas rejestracji');
37      return false;
38    }
39  };
40  
41  const value: AuthContextType = {
42    // ... istniejące pola
43    registerUser,
44  };
45  
46  // ... istniejący kod
47}

2. Aktualizacja profilu użytkownika

1// Rozszerzone metody w AuthContext
2type AuthContextType = {
3  // ... istniejące pola
4  updateUserProfile: (profileData: UpdateProfileData) => Promise<boolean>;
5};
6
7// Dane do aktualizacji profilu
8type UpdateProfileData = {
9  name?: string;
10  email?: string;
11  currentPassword?: string;
12  newPassword?: string;
13  image?: string;
14};
15
16// Implementacja w AuthProvider
17export function AuthProvider({ children }: AuthProviderProps) {
18  // ... istniejący kod
19  
20  const updateUserProfile = async (profileData: UpdateProfileData): Promise<boolean> => {
21    try {
22      setError(null);
23      const response = await fetch('/api/user/profile', {
24        method: 'PUT',
25        headers: { 'Content-Type': 'application/json' },
26        body: JSON.stringify(profileData),
27      });
28      
29      const data = await response.json();
30      
31      if (!response.ok) {
32        throw new Error(data.message || 'Wystąpił problem podczas aktualizacji profilu');
33      }
34      
35      // Wymuś odświeżenie sesji, aby pobrać zaktualizowane dane
36      await refreshSession();
37      
38      return true;
39    } catch (err) {
40      console.error('Profile update error:', err);
41      setError(err instanceof Error ? err.message : 'Wystąpił nieznany błąd podczas aktualizacji profilu');
42      return false;
43    }
44  };
45  
46  // Funkcja odświeżająca sesję
47  const refreshSession = async () => {
48    // Obecnie w Next.js 13+ z App Router możemy użyć:
49    const event = new Event('visibilitychange');
50    document.dispatchEvent(event);
51    
52    // Alternatywnie, możemy sami zaimplementować odświeżanie sesji
53    // np. poprzez re-render providera
54  };
55  
56  const value: AuthContextType = {
57    // ... istniejące pola
58    updateUserProfile,
59  };
60  
61  // ... istniejący kod
62}

3. Obsługa uwierzytelniania dwuskładnikowego (2FA)

1// Rozszerzone metody w AuthContext
2type AuthContextType = {
3  // ... istniejące pola
4  enable2FA: () => Promise<{ secret: string; qrCodeUrl: string }>;
5  verify2FA: (token: string) => Promise<boolean>;
6  disable2FA: (token: string) => Promise<boolean>;
7  is2FAEnabled: boolean;
8};
9
10// Implementacja w AuthProvider
11export function AuthProvider({ children }: AuthProviderProps) {
12  // ... istniejący kod
13  const [is2FAEnabled, setIs2FAEnabled] = useState(false);
14  
15  // Aktualizuj stan 2FA na podstawie sesji
16  useEffect(() => {
17    if (session?.user?.twoFactorEnabled) {
18      setIs2FAEnabled(true);
19    } else {
20      setIs2FAEnabled(false);
21    }
22  }, [session]);
23  
24  // Włącz 2FA
25  const enable2FA = async () => {
26    try {
27      setError(null);
28      const response = await fetch('/api/auth/2fa/enable', {
29        method: 'POST',
30      });
31      
32      if (!response.ok) {
33        const data = await response.json();
34        throw new Error(data.message || 'Wystąpił problem podczas włączania 2FA');
35      }
36      
37      const { secret, qrCodeUrl } = await response.json();
38      return { secret, qrCodeUrl };
39    } catch (err) {
40      console.error('Enable 2FA error:', err);
41      setError(err instanceof Error ? err.message : 'Wystąpił nieznany błąd');
42      throw err;
43    }
44  };
45  
46  // Weryfikuj token 2FA
47  const verify2FA = async (token: string) => {
48    try {
49      setError(null);
50      const response = await fetch('/api/auth/2fa/verify', {
51        method: 'POST',
52        headers: { 'Content-Type': 'application/json' },
53        body: JSON.stringify({ token }),
54      });
55      
56      if (!response.ok) {
57        const data = await response.json();
58        throw new Error(data.message || 'Nieprawidłowy kod weryfikacyjny');
59      }
60      
61      // Odśwież sesję, aby zaktualizować stan 2FA
62      await refreshSession();
63      setIs2FAEnabled(true);
64      
65      return true;
66    } catch (err) {
67      console.error('Verify 2FA error:', err);
68      setError(err instanceof Error ? err.message : 'Wystąpił nieznany błąd');
69      return false;
70    }
71  };
72  
73  // Wyłącz 2FA
74  const disable2FA = async (token: string) => {
75    try {
76      setError(null);
77      const response = await fetch('/api/auth/2fa/disable', {
78        method: 'POST',
79        headers: { 'Content-Type': 'application/json' },
80        body: JSON.stringify({ token }),
81      });
82      
83      if (!response.ok) {
84        const data = await response.json();
85        throw new Error(data.message || 'Nieprawidłowy kod weryfikacyjny');
86      }
87      
88      // Odśwież sesję, aby zaktualizować stan 2FA
89      await refreshSession();
90      setIs2FAEnabled(false);
91      
92      return true;
93    } catch (err) {
94      console.error('Disable 2FA error:', err);
95      setError(err instanceof Error ? err.message : 'Wystąpił nieznany błąd');
96      return false;
97    }
98  };
99  
100  const value: AuthContextType = {
101    // ... istniejące pola
102    enable2FA,
103    verify2FA,
104    disable2FA,
105    is2FAEnabled,
106  };
107  
108  // ... istniejący kod
109}

Użycie AuthContext w komponentach

Teraz możemy użyć naszego AuthContext w komponentach aplikacji:

1. Komponent przycisku logowania/wylogowania

1// components/AuthButton.tsx
2'use client';
3
4import { useAuth } from '@/lib/authContext';
5
6export function AuthButton() {
7  const { isAuthenticated, isLoading, signIn, signOut, user } = useAuth();
8  
9  if (isLoading) {
10    return <button className="btn btn-ghost animate-pulse">Ładowanie...</button>;
11  }
12  
13  if (isAuthenticated) {
14    return (
15      <button 
16        onClick={() => signOut({ callbackUrl: '/' })}
17        className="btn btn-outline"
18      >
19        Wyloguj {user?.name}
20      </button>
21    );
22  }
23  
24  return (
25    <button 
26      onClick={() => signIn()}
27      className="btn btn-primary"
28    >
29      Zaloguj się
30    </button>
31  );
32}

2. Komponent strony chronionej

1// app/dashboard/page.tsx
2'use client';
3
4import { useAuth } from '@/lib/authContext';
5import { useEffect } from 'react';
6import { useRouter } from 'next/navigation';
7
8export default function DashboardPage() {
9  const { isAuthenticated, isLoading, user, hasRole } = useAuth();
10  const router = useRouter();
11  
12  // Przekieruj do strony logowania, jeśli użytkownik nie jest zalogowany
13  useEffect(() => {
14    if (!isLoading && !isAuthenticated) {
15      router.push('/auth/login?callbackUrl=/dashboard');
16    }
17  }, [isLoading, isAuthenticated, router]);
18  
19  if (isLoading) {
20    return <div className="flex justify-center p-8">Ładowanie...</div>;
21  }
22  
23  if (!isAuthenticated) {
24    return null; // Unikaj migotania przed przekierowaniem
25  }
26  
27  return (
28    <div className="container mx-auto p-6">
29      <h1 className="text-2xl font-bold mb-6">Witaj, {user?.name}!</h1>
30      
31      <div className="bg-white shadow rounded-lg p-6">
32        <h2 className="text-xl font-semibold mb-4">Dashboard</h2>
33        
34        {/* Zawartość dostępna dla wszystkich zalogowanych użytkowników */}
35        <div className="mb-6">
36          <h3 className="text-lg font-medium mb-2">Twój profil</h3>
37          <p>Email: {user?.email}</p>
38          <p>Rola: {user?.role}</p>
39        </div>
40        
41        {/* Zawartość widoczna tylko dla administratorów */}
42        {hasRole('admin') && (
43          <div className="p-4 bg-purple-50 rounded-lg">
44            <h3 className="text-lg font-medium mb-2 text-purple-800">Panel administratora</h3>
45            <p className="text-purple-700">Ta sekcja jest widoczna tylko dla administratorów.</p>
46            <button className="mt-2 px-4 py-2 bg-purple-600 text-white rounded">Zarządzaj użytkownikami</button>
47          </div>
48        )}
49        
50        {/* Zawartość widoczna tylko dla określonych ról */}
51        {hasRole(['admin', 'editor']) && (
52          <div className="mt-4 p-4 bg-blue-50 rounded-lg">
53            <h3 className="text-lg font-medium mb-2 text-blue-800">Zarządzanie treścią</h3>
54            <p className="text-blue-700">Ta sekcja jest dostępna dla administratorów i edytorów.</p>
55          </div>
56        )}
57      </div>
58    </div>
59  );
60}

3. Formularz rejestracji z walidacją

1// app/auth/register/page.tsx
2'use client';
3
4import { useAuth } from '@/lib/authContext';
5import { useState, FormEvent } from 'react';
6import { useRouter } from 'next/navigation';
7import Link from 'next/link';
8
9export default function RegisterPage() {
10  const { registerUser, error: authError, isAuthenticated } = useAuth();
11  const router = useRouter();
12  
13  const [name, setName] = useState('');
14  const [email, setEmail] = useState('');
15  const [password, setPassword] = useState('');
16  const [confirmPassword, setConfirmPassword] = useState('');
17  const [isLoading, setIsLoading] = useState(false);
18  const [formError, setFormError] = useState<string | null>(null);
19  const [successMessage, setSuccessMessage] = useState<string | null>(null);
20  
21  // Jeśli użytkownik jest już zalogowany, przekieruj do dashboardu
22  if (isAuthenticated) {
23    router.push('/dashboard');
24    return null;
25  }
26  
27  const handleSubmit = async (e: FormEvent) => {
28    e.preventDefault();
29    setFormError(null);
30    setSuccessMessage(null);
31    
32    // Walidacja formularza
33    if (password !== confirmPassword) {
34      setFormError('Hasła nie są identyczne');
35      return;
36    }
37    
38    if (password.length < 8) {
39      setFormError('Hasło musi mieć co najmniej 8 znaków');
40      return;
41    }
42    
43    setIsLoading(true);
44    
45    try {
46      const success = await registerUser({ name, email, password });
47      
48      if (success) {
49        setSuccessMessage('Rejestracja zakończona pomyślnie! Możesz się teraz zalogować.');
50        // Opcjonalnie: przekieruj do strony logowania po krótkim opóźnieniu
51        setTimeout(() => {
52          router.push('/auth/login');
53        }, 2000);
54      }
55    } catch (err) {
56      console.error('Registration error:', err);
57      // Błąd jest już obsługiwany przez AuthProvider
58    } finally {
59      setIsLoading(false);
60    }
61  };
62  
63  return (
64    <div className="max-w-md mx-auto mt-10 p-6 bg-white rounded-lg shadow-md">
65      <h1 className="text-2xl font-bold mb-6 text-center">Rejestracja</h1>
66      
67      {/* Wyświetl błędy */}
68      {(formError || authError) && (
69        <div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md">
70          {formError || authError}
71        </div>
72      )}
73      
74      {/* Wyświetl komunikat o sukcesie */}
75      {successMessage && (
76        <div className="mb-4 p-3 bg-green-100 text-green-700 rounded-md">
77          {successMessage}
78        </div>
79      )}
80      
81      <form onSubmit={handleSubmit} className="space-y-4">
82        <div>
83          <label htmlFor="name" className="block text-sm font-medium mb-1">
84            Imię i nazwisko
85          </label>
86          <input
87            id="name"
88            type="text"
89            value={name}
90            onChange={(e) => setName(e.target.value)}
91            className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
92            required
93          />
94        </div>
95        
96        <div>
97          <label htmlFor="email" className="block text-sm font-medium mb-1">
98            Email
99          </label>
100          <input
101            id="email"
102            type="email"
103            value={email}
104            onChange={(e) => setEmail(e.target.value)}
105            className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
106            required
107          />
108        </div>
109        
110        <div>
111          <label htmlFor="password" className="block text-sm font-medium mb-1">
112            Hasło
113          </label>
114          <input
115            id="password"
116            type="password"
117            value={password}
118            onChange={(e) => setPassword(e.target.value)}
119            className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
120            required
121            minLength={8}
122          />
123        </div>
124        
125        <div>
126          <label htmlFor="confirmPassword" className="block text-sm font-medium mb-1">
127            Potwierdź hasło
128          </label>
129          <input
130            id="confirmPassword"
131            type="password"
132            value={confirmPassword}
133            onChange={(e) => setConfirmPassword(e.target.value)}
134            className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
135            required
136          />
137        </div>
138        
139        <button
140          type="submit"
141          disabled={isLoading}
142          className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50"
143        >
144          {isLoading ? 'Przetwarzanie...' : 'Zarejestruj się'}
145        </button>
146      </form>
147      
148      <div className="mt-6 text-center">
149        <p className="text-sm text-gray-600">
150          Masz już konto?{' '}
151          <Link href="/auth/login" className="text-blue-600 hover:underline">
152            Zaloguj się
153          </Link>
154        </p>
155      </div>
156    </div>
157  );
158}

4. Formularz resetowania hasła

1// app/auth/reset-password/page.tsx
2'use client';
3
4import { useState, FormEvent } from 'react';
5import { usePasswordReset } from '@/lib/passwordResetContext';
6import Link from 'next/link';
7
8export default function PasswordResetPage() {
9  const [email, setEmail] = useState('');
10  const { requestPasswordReset, isLoading, error, message } = usePasswordReset();
11  
12  const handleSubmit = async (e: FormEvent) => {
13    e.preventDefault();
14    await requestPasswordReset(email);
15  };
16  
17  return (
18    <div className="max-w-md mx-auto mt-10 p-6 bg-white rounded-lg shadow-md">
19      <h1 className="text-2xl font-bold mb-6 text-center">Resetowanie hasła</h1>
20      
21      {error && (
22        <div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md">
23          {error}
24        </div>
25      )}
26      
27      {message && (
28        <div className="mb-4 p-3 bg-green-100 text-green-700 rounded-md">
29          {message}
30        </div>
31      )}
32      
33      <form onSubmit={handleSubmit} className="space-y-4">
34        <div>
35          <label htmlFor="email" className="block text-sm font-medium mb-1">
36            Email
37          </label>
38          <input
39            id="email"
40            type="email"
41            value={email}
42            onChange={(e) => setEmail(e.target.value)}
43            className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
44            required
45            disabled={isLoading || !!message} // Zablokuj po pomyślnym wysłaniu
46          />
47        </div>
48        
49        <button
50          type="submit"
51          disabled={isLoading || !!message}
52          className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50"
53        >
54          {isLoading ? 'Wysyłanie...' : 'Resetuj hasło'}
55        </button>
56      </form>
57      
58      <div className="mt-6 text-center">
59        <p className="text-sm text-gray-600">
60          <Link href="/auth/login" className="text-blue-600 hover:underline">
61            Powrót do strony logowania
62          </Link>
63        </p>
64      </div>
65    </div>
66  );
67}

Tworzenie komponentów wysokiego poziomu (HOC) do ochrony stron

Możemy również stworzyć komponenty wysokiego poziomu (Higher-Order Components, HOC), które będą chronić dostęp do stron na podstawie stanu uwierzytelniania:

1// components/withAuth.tsx
2'use client';
3
4import { useAuth } from '@/lib/authContext';
5import { useRouter } from 'next/navigation';
6import { useEffect, ComponentType } from 'react';
7
8// HOC do wymagania uwierzytelnienia
9export function withAuth<P extends object>(Component: ComponentType<P>) {
10  return function AuthProtected(props: P) {
11    const { isAuthenticated, isLoading } = useAuth();
12    const router = useRouter();
13    
14    useEffect(() => {
15      if (!isLoading && !isAuthenticated) {
16        router.push(`/auth/login?callbackUrl=${encodeURIComponent(window.location.pathname)}`);
17      }
18    }, [isLoading, isAuthenticated, router]);
19    
20    if (isLoading) {
21      return <div className="flex justify-center p-8">Ładowanie...</div>;
22    }
23    
24    if (!isAuthenticated) {
25      return null; // Unikaj migotania przed przekierowaniem
26    }
27    
28    return <Component {...props} />;
29  };
30}
31
32// HOC do wymagania określonej roli
33export function withRole<P extends object>(Component: ComponentType<P>, requiredRole: string | string[]) {
34  return function RoleProtected(props: P) {
35    const { isAuthenticated, isLoading, hasRole, user } = useAuth();
36    const router = useRouter();
37    
38    useEffect(() => {
39      if (!isLoading) {
40        if (!isAuthenticated) {
41          router.push(`/auth/login?callbackUrl=${encodeURIComponent(window.location.pathname)}`);
42        } else if (!hasRole(requiredRole)) {
43          router.push('/auth/unauthorized');
44        }
45      }
46    }, [isLoading, isAuthenticated, hasRole, router]);
47    
48    if (isLoading) {
49      return <div className="flex justify-center p-8">Ładowanie...</div>;
50    }
51    
52    if (!isAuthenticated || !hasRole(requiredRole)) {
53      return null; // Unikaj migotania przed przekierowaniem
54    }
55    
56    return <Component {...props} />;
57  };
58}
59
60// HOC do wymagania określonego uprawnienia
61export function withPermission<P extends object>(Component: ComponentType<P>, requiredPermission: string | string[]) {
62  return function PermissionProtected(props: P) {
63    const { isAuthenticated, isLoading, hasPermission } = useAuth();
64    const router = useRouter();
65    
66    useEffect(() => {
67      if (!isLoading) {
68        if (!isAuthenticated) {
69          router.push(`/auth/login?callbackUrl=${encodeURIComponent(window.location.pathname)}`);
70        } else if (!hasPermission(requiredPermission)) {
71          router.push('/auth/unauthorized');
72        }
73      }
74    }, [isLoading, isAuthenticated, hasPermission, router]);
75    
76    if (isLoading) {
77      return <div className="flex justify-center p-8">Ładowanie...</div>;
78    }
79    
80    if (!isAuthenticated || !hasPermission(requiredPermission)) {
81      return null; // Unikaj migotania przed przekierowaniem
82    }
83    
84    return <Component {...props} />;
85  };
86}

Przykład użycia HOC:

1// app/admin/settings/page.tsx
2'use client';
3
4import { withRole } from '@/components/withAuth';
5
6function AdminSettingsPage() {
7  return (
8    <div className="container mx-auto p-6">
9      <h1 className="text-2xl font-bold mb-6">Ustawienia administratora</h1>
10      {/* Zawartość strony */}
11    </div>
12  );
13}
14
15// Eksportujemy komponent chroniony przez HOC
16export default withRole(AdminSettingsPage, 'admin');
17
18// app/content/edit/page.tsx
19'use client';
20
21import { withPermission } from '@/components/withAuth';
22
23function EditContentPage() {
24  return (
25    <div className="container mx-auto p-6">
26      <h1 className="text-2xl font-bold mb-6">Edycja zawartości</h1>
27      {/* Zawartość strony */}
28    </div>
29  );
30}
31
32// Eksportujemy komponent chroniony przez HOC
33export default withPermission(EditContentPage, 'content:edit');

Integracja z systemem zarządzania stanem aplikacji

Nasz AuthContext może być również zintegrowany z bardziej złożonym systemem zarządzania stanem aplikacji, takim jak Redux, Zustand czy Jotai. Na przykład, integracja z Zustand mogłaby wyglądać następująco:

1// lib/authStore.ts
2import { create } from 'zustand';
3import { Session } from 'next-auth';
4
5type AuthState = {
6  session: Session | null;
7  status: 'loading' | 'authenticated' | 'unauthenticated';
8  error: string | null;
9  user: Session['user'] | null;
10  setSession: (session: Session | null) => void;
11  setStatus: (status: 'loading' | 'authenticated' | 'unauthenticated') => void;
12  setError: (error: string | null) => void;
13  hasRole: (role: string | string[]) => boolean;
14  hasPermission: (permission: string | string[]) => boolean;
15  reset: () => void;
16};
17
18export const useAuthStore = create<AuthState>((set, get) => ({
19  session: null,
20  status: 'loading',
21  error: null,
22  user: null,
23  
24  setSession: (session) => set({ 
25    session, 
26    user: session?.user || null,
27    status: session ? 'authenticated' : 'unauthenticated'
28  }),
29  
30  setStatus: (status) => set({ status }),
31  
32  setError: (error) => set({ error }),
33  
34  hasRole: (role) => {
35    const { user } = get();
36    if (!user?.role) return false;
37    
38    if (Array.isArray(role)) {
39      return role.includes(user.role as string);
40    }
41    
42    return user.role === role;
43  },
44  
45  hasPermission: (permission) => {
46    const { user } = get();
47    if (!user?.permissions) return false;
48    
49    const userPermissions = user.permissions as string[];
50    
51    if (Array.isArray(permission)) {
52      return permission.some(p => userPermissions.includes(p));
53    }
54    
55    return userPermissions.includes(permission as string);
56  },
57  
58  reset: () => set({
59    session: null,
60    status: 'unauthenticated',
61    error: null,
62    user: null
63  }),
64}));
65
66// Zmodyfikowany AuthProvider używający Zustand
67export function AuthProvider({ children }: { children: ReactNode }) {
68  const { data: session, status } = useSession();
69  const { setSession, setStatus, setError } = useAuthStore();
70  
71  // Aktualizuj store przy zmianie sesji
72  useEffect(() => {
73    setSession(session);
74    setStatus(status);
75  }, [session, status, setSession, setStatus]);
76  
77  return <>{children}</>;
78}

Testowanie AuthContext

Testowanie Auth Context jest kluczowe dla zapewnienia niezawodności systemu uwierzytelniania. Poniżej przedstawiono przykład testów z wykorzystaniem React Testing Library i Jest:

1// __tests__/authContext.test.tsx
2import { render, screen, waitFor, fireEvent } from '@testing-library/react';
3import { AuthProvider, useAuth } from '@/lib/authContext';
4import { SessionProvider } from 'next-auth/react';
5
6// Mock next-auth
7jest.mock('next-auth/react', () => ({
8  useSession: jest.fn(),
9  signIn: jest.fn(),
10  signOut: jest.fn(),
11}));
12
13// Komponent testowy używający useAuth
14function TestComponent() {
15  const { isAuthenticated, user, hasRole, hasPermission } = useAuth();
16  
17  return (
18    <div>
19      <div data-testid="auth-status">
20        {isAuthenticated ? 'Authenticated' : 'Not authenticated'}
21      </div>
22      {user && <div data-testid="user-name">{user.name}</div>}
23      <div data-testid="admin-role">
24        {hasRole('admin') ? 'Has admin role' : 'No admin role'}
25      </div>
26      <div data-testid="edit-permission">
27        {hasPermission('content:edit') ? 'Can edit content' : 'Cannot edit content'}
28      </div>
29    </div>
30  );
31}
32
33// Wrapper dla testów
34function renderWithAuth(ui: React.ReactNode, sessionData: any = null) {
35  const useSessionMock = require('next-auth/react').useSession;
36  
37  // Ustaw mock dla useSession
38  useSessionMock.mockReturnValue(sessionData);
39  
40  return render(
41    <SessionProvider>
42      <AuthProvider>{ui}</AuthProvider>
43    </SessionProvider>
44  );
45}
46
47describe('AuthContext', () => {
48  it('should show unauthenticated state when no session exists', () => {
49    renderWithAuth(<TestComponent />, {
50      data: null,
51      status: 'unauthenticated'
52    });
53    
54    expect(screen.getByTestId('auth-status')).toHaveTextContent('Not authenticated');
55    expect(screen.getByTestId('admin-role')).toHaveTextContent('No admin role');
56    expect(screen.getByTestId('edit-permission')).toHaveTextContent('Cannot edit content');
57  });
58  
59  it('should show authenticated state with user data when session exists', () => {
60    renderWithAuth(<TestComponent />, {
61      data: {
62        user: {
63          name: 'Test User',
64          email: 'test@example.com',
65          role: 'user',
66          permissions: ['content:read']
67        }
68      },
69      status: 'authenticated'
70    });
71    
72    expect(screen.getByTestId('auth-status')).toHaveTextContent('Authenticated');
73    expect(screen.getByTestId('user-name')).toHaveTextContent('Test User');
74    expect(screen.getByTestId('admin-role')).toHaveTextContent('No admin role');
75    expect(screen.getByTestId('edit-permission')).toHaveTextContent('Cannot edit content');
76  });
77  
78  it('should correctly check for admin role', () => {
79    renderWithAuth(<TestComponent />, {
80      data: {
81        user: {
82          name: 'Admin User',
83          email: 'admin@example.com',
84          role: 'admin',
85          permissions: ['content:read', 'content:edit', 'user:manage']
86        }
87      },
88      status: 'authenticated'
89    });
90    
91    expect(screen.getByTestId('auth-status')).toHaveTextContent('Authenticated');
92    expect(screen.getByTestId('admin-role')).toHaveTextContent('Has admin role');
93    expect(screen.getByTestId('edit-permission')).toHaveTextContent('Can edit content');
94  });
95  
96  it('should correctly check for permissions', () => {
97    renderWithAuth(<TestComponent />, {
98      data: {
99        user: {
100          name: 'Editor User',
101          email: 'editor@example.com',
102          role: 'editor',
103          permissions: ['content:read', 'content:edit']
104        }
105      },
106      status: 'authenticated'
107    });
108    
109    expect(screen.getByTestId('auth-status')).toHaveTextContent('Authenticated');
110    expect(screen.getByTestId('admin-role')).toHaveTextContent('No admin role');
111    expect(screen.getByTestId('edit-permission')).toHaveTextContent('Can edit content');
112  });
113});

Zaawansowane przypadki użycia AuthContext

1. Wykrywanie nieaktywności i automatyczne wylogowywanie

1// lib/useIdleTimer.ts
2import { useState, useEffect, useCallback } from 'react';
3
4type UseIdleTimerProps = {
5  timeout: number; // Czas w milisekundach
6  onIdle: () => void;
7  onActive?: () => void;
8  events?: string[];
9};
10
11export function useIdleTimer({
12  timeout,
13  onIdle,
14  onActive,
15  events = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart']
16}: UseIdleTimerProps) {
17  const [isIdle, setIsIdle] = useState(false);
18  const [lastActive, setLastActive] = useState(Date.now());
19  const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | null>(null);
20  
21  // Resetuj timer nieaktywności
22  const resetTimer = useCallback(() => {
23    if (isIdle) {
24      setIsIdle(false);
25      onActive?.();
26    }
27    
28    setLastActive(Date.now());
29    
30    if (timeoutId) {
31      clearTimeout(timeoutId);
32    }
33    
34    const id = setTimeout(() => {
35      setIsIdle(true);
36      onIdle();
37    }, timeout);
38    
39    setTimeoutId(id);
40  }, [isIdle, onActive, onIdle, timeout, timeoutId]);
41  
42  // Inicjalizacja timera przy montowaniu
43  useEffect(() => {
44    resetTimer();
45    
46    // Dodaj nasłuchiwanie zdarzeń
47    events.forEach(event => {
48      window.addEventListener(event, resetTimer);
49    });
50    
51    // Wyczyść przy odmontowaniu
52    return () => {
53      if (timeoutId) {
54        clearTimeout(timeoutId);
55      }
56      
57      events.forEach(event => {
58        window.removeEventListener(event, resetTimer);
59      });
60    };
61  }, [events, resetTimer, timeoutId]);
62  
63  return {
64    isIdle,
65    lastActive,
66    resetTimer
67  };
68}
69
70// Rozszerzenie AuthProvider o obsługę automatycznego wylogowywania
71export function AuthProvider({ children }: AuthProviderProps) {
72  // ... istniejący kod
73  
74  // Czas bezczynności: 30 minut
75  const idleTimeout = 30 * 60 * 1000;
76  const [showIdleWarning, setShowIdleWarning] = useState(false);
77  
78  // Obsługa wylogowania po czasie nieaktywności
79  const handleIdle = () => {
80    // Pokaż ostrzeżenie o zbliżającym się wylogowaniu
81    setShowIdleWarning(true);
82    
83    // Daj użytkownikowi 1 minutę na reakcję, zanim zostanie wylogowany
84    setTimeout(() => {
85      if (showIdleWarning) {
86        handleSignOut({ callbackUrl: '/' });
87      }
88    }, 60 * 1000);
89  };
90  
91  // Obsługa powrotu do aktywności
92  const handleActive = () => {
93    setShowIdleWarning(false);
94  };
95  
96  // Użyj hooka idle timer
97  useIdleTimer({
98    timeout: idleTimeout,
99    onIdle: handleIdle,
100    onActive: handleActive
101  });
102  
103  // ... istniejący kod
104  
105  // Dodaj modal ostrzeżenia o nieaktywności
106  return (
107    <AuthContext.Provider value={value}>
108      {children}
109      
110      {/* Modal ostrzeżenia o nieaktywności */}
111      {showIdleWarning && (
112        <div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50">
113          <div className="bg-white p-6 rounded-lg shadow-lg max-w-md">
114            <h2 className="text-xl font-bold mb-4">Wykryto bezczynność</h2>
115            <p className="mb-4">Zostaniesz automatycznie wylogowany za 1 minutę z powodu braku aktywności.</p>
116            <div className="flex justify-end space-x-4">
117              <button 
118                onClick={() => handleSignOut({ callbackUrl: '/' })}
119                className="px-4 py-2 bg-gray-300 rounded hover:bg-gray-400"
120              >
121                Wyloguj teraz
122              </button>
123              <button 
124                onClick={handleActive}
125                className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
126              >
127                Pozostań zalogowany
128              </button>
129            </div>
130          </div>
131        </div>
132      )}
133    </AuthContext.Provider>
134  );
135}

2. Wdrażanie mechanizmu delegacji dostępu (token swapping)

1// lib/delegatedAuth.ts
2import { useAuth } from './authContext';
3import { useState } from 'react';
4
5type DelegatedToken = {
6  token: string;
7  expiresAt: number;
8  service: string;
9};
10
11export function useDelegatedAuth() {
12  const { isAuthenticated } = useAuth();
13  const [delegatedTokens, setDelegatedTokens] = useState<Record<string, DelegatedToken>>({});
14  const [isLoading, setIsLoading] = useState(false);
15  const [error, setError] = useState<string | null>(null);
16  
17  // Pozyskaj token dla usługi zewnętrznej
18  const getDelegatedToken = async (service: string): Promise<string | null> => {
19    // Jeśli użytkownik nie jest zalogowany, nie można pozyskać tokenu
20    if (!isAuthenticated) {
21      setError('Musisz być zalogowany, aby uzyskać dostęp do usługi zewnętrznej');
22      return null;
23    }
24    
25    // Sprawdź, czy mamy ważny token w cache
26    const cachedToken = delegatedTokens[service];
27    if (cachedToken && cachedToken.expiresAt > Date.now()) {
28      return cachedToken.token;
29    }
30    
31    // Pozyskaj nowy token
32    setIsLoading(true);
33    setError(null);
34    
35    try {
36      const response = await fetch(`/api/auth/delegate/${service}`, {
37        method: 'POST',
38      });
39      
40      if (!response.ok) {
41        const data = await response.json();
42        throw new Error(data.message || 'Błąd podczas pozyskiwania tokenu dostępu');
43      }
44      
45      const { token, expiresIn } = await response.json();
46      const expiresAt = Date.now() + expiresIn * 1000;
47      
48      // Zapisz token w cache
49      const newToken = { token, expiresAt, service };
50      setDelegatedTokens(prev => ({ ...prev, [service]: newToken }));
51      
52      return token;
53    } catch (err) {
54      setError(err instanceof Error ? err.message : 'Wystąpił nieznany błąd');
55      return null;
56    } finally {
57      setIsLoading(false);
58    }
59  };
60  
61  // Wyczyść wszystkie tokeny delegowane
62  const clearDelegatedTokens = () => {
63    setDelegatedTokens({});
64  };
65  
66  return {
67    getDelegatedToken,
68    clearDelegatedTokens,
69    isLoading,
70    error,
71  };
72}
73
74// Przykład użycia w komponencie
75// components/ExternalServiceWidget.tsx
76'use client';
77
78import { useDelegatedAuth } from '@/lib/delegatedAuth';
79import { useState, useEffect } from 'react';
80
81export function ExternalServiceWidget() {
82  const { getDelegatedToken, isLoading, error } = useDelegatedAuth();
83  const [data, setData] = useState(null);
84  
85  const fetchData = async () => {
86    // Pozyskaj token dla usługi zewnętrznej
87    const token = await getDelegatedToken('external-api');
88    
89    if (!token) return;
90    
91    try {
92      // Użyj tokenu do wywołania API zewnętrznej usługi
93      const response = await fetch('https://api.external-service.com/data', {
94        headers: {
95          Authorization: `Bearer ${token}`
96        }
97      });
98      
99      const result = await response.json();
100      setData(result);
101    } catch (err) {
102      console.error('Error fetching from external service:', err);
103    }
104  };
105  
106  useEffect(() => {
107    fetchData();
108  }, []);
109  
110  return (
111    <div className="bg-white p-4 rounded shadow">
112      <h2 className="text-lg font-bold mb-2">Dane z usługi zewnętrznej</h2>
113      
114      {isLoading && <p>Ładowanie danych...</p>}
115      {error && <p className="text-red-600">{error}</p>}
116      
117      {data && (
118        <div className="mt-2">
119          {/* Wyświetl dane */}
120        </div>
121      )}
122      
123      <button 
124        onClick={fetchData} 
125        disabled={isLoading}
126        className="mt-4 px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
127      >
128        Odśwież dane
129      </button>
130    </div>
131  );
132}

Bezpieczeństwo kontekstu uwierzytelniania

Przy implementacji AuthContext należy pamiętać o kilku kluczowych aspektach bezpieczeństwa:

  1. Nie przechowuj wrażliwych danych w stanie - unikaj przechowywania haseł, pełnych tokenów JWT czy kluczy API w kontekście dostępnym po stronie klienta.

  2. Waliduj dane po stronie serwera - zawsze weryfikuj uprawnienia użytkownika na serwerze, nigdy nie polegaj wyłącznie na walidacji po stronie klienta.

  3. Używaj HTTPS - wszystkie żądania związane z uwierzytelnianiem muszą być przesyłane przez HTTPS.

  4. Obsługuj tokeny bezpiecznie - przechowuj tokeny w httpOnly cookies, nie w localStorage czy sessionStorage.

  5. Implementuj CSRF protection - używaj tokenów CSRF do ochrony przed atakami Cross-Site Request Forgery.

  6. Unikaj wycieków informacji - nie ujawniaj szczegółowych informacji o błędach uwierzytelniania, które mogłyby pomóc atakującym.

  7. Regularnie odświeżaj i rotuj tokeny - implementuj mechanizmy odświeżania i rotacji tokenów.

Podsumowanie

W tym module omówiliśmy projektowanie i implementację Context Providera dla uwierzytelniania w aplikacjach Next.js, a w szczególności:

  1. Tworzenie AuthContext i AuthProvider - centralizacja logiki uwierzytelniania w jednym miejscu
  2. Integracja z NextAuth.js - wykorzystanie istniejących mechanizmów uwierzytelniania
  3. Rozszerzanie o dodatkowe funkcjonalności - rejestracja, zarządzanie profilem, 2FA
  4. Wyspecjalizowane providery - obsługa konkretnych przypadków użycia, jak resetowanie hasła
  5. Komponenty wysokiego poziomu (HOC) - ochrona stron na podstawie ról i uprawnień
  6. Integracja z systemami zarządzania stanem - łączenie z Zustand i innymi bibliotekami
  7. Testowanie - pisanie testów dla AuthContext
  8. Zaawansowane przypadki użycia - automatyczne wylogowywanie i delegacja dostępu

Dobrze zaprojektowany Context Provider dla uwierzytelniania znacząco upraszcza zarządzanie stanem uwierzytelniania w całej aplikacji, centralizuje logikę i zapewnia spójny interfejs do korzystania z funkcji związanych z bezpieczeństwem.

W kolejnym module przyjrzymy się aspektom bezpieczeństwa aplikacji i najlepszym praktykom OWASP, które pomogą nam stworzyć bardziej bezpieczne i odporne na ataki aplikacje Next.js.

Vai a CodeWorlds