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.
Uwierzytelnianie to proces weryfikacji tożsamości użytkownika. Jest to kluczowy element zabezpieczeń aplikacji internetowych, ponieważ:
Ważne jest rozróżnienie dwóch powiązanych, ale oddzielnych koncepcji:
W wielu przypadkach oba te procesy są ze sobą powiązane, ale pełnią różne funkcje w architekturze bezpieczeństwa.
Next.js oferuje hybrydowy model renderowania, co ma znaczący wpływ na implementację uwierzytelniania:
W aplikacjach Next.js można zastosować kilka głównych modeli uwierzytelniania:
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}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}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});W nowszych wersjach Next.js (13+) z App Router, podejście do uwierzytelniania zostało ulepszone. Poniżej przedstawiamy podstawowe kroki:
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 };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}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}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};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}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}Implementując uwierzytelnianie, należy pamiętać o kilku kluczowych mechanizmach zabezpieczeń:
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);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}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.
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};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}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}Uwierzytelnianie w Next.js może być implementowane na wiele sposobów, ale najczęściej używane podejścia obejmują:
Wybór odpowiedniego rozwiązania zależy od specyficznych wymagań Twojej aplikacji, takich jak:
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.