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.
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: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}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}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}Nasz AuthProvider możemy rozszerzyć o dodatkowe funkcjonalności, które często będą potrzebne w aplikacjach:
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}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}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}Teraz możemy użyć naszego AuthContext w komponentach aplikacji:
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}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}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}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}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');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 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});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}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}Przy implementacji AuthContext należy pamiętać o kilku kluczowych aspektach bezpieczeństwa:
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.
Waliduj dane po stronie serwera - zawsze weryfikuj uprawnienia użytkownika na serwerze, nigdy nie polegaj wyłącznie na walidacji po stronie klienta.
Używaj HTTPS - wszystkie żądania związane z uwierzytelnianiem muszą być przesyłane przez HTTPS.
Obsługuj tokeny bezpiecznie - przechowuj tokeny w httpOnly cookies, nie w localStorage czy sessionStorage.
Implementuj CSRF protection - używaj tokenów CSRF do ochrony przed atakami Cross-Site Request Forgery.
Unikaj wycieków informacji - nie ujawniaj szczegółowych informacji o błędach uwierzytelniania, które mogłyby pomóc atakującym.
Regularnie odświeżaj i rotuj tokeny - implementuj mechanizmy odświeżania i rotacji tokenów.
W tym module omówiliśmy projektowanie i implementację Context Providera dla uwierzytelniania w aplikacjach Next.js, a w szczególności:
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.