Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

Middleware do ochrony tras

W poprzednich modułach poznaliśmy podstawy uwierzytelniania, podłączanie zewnętrznych dostawców, zarządzanie sesjami i implementację systemu ról i uprawnień. Teraz skupimy się na middleware - potężnym mechanizmie Next.js, który pozwala na wykonanie kodu przed dotarciem do żądania docelowego, co jest idealnym miejscem do implementacji ochrony tras.

Middleware w Next.js działa na poziomie serwera, ale jest wykonywane przed renderowaniem strony lub obsługą żądania API. Daje to możliwość centralnego zarządzania dostępem, przekierowywania, modyfikacji nagłówków i wielu innych operacji.

Podstawy middleware w Next.js

Zacznijmy od zrozumienia, jak działa middleware w Next.js:

  1. Lokalizacja - middleware jest definiowane w pliku
    middleware.ts
    (lub
    middleware.js
    ) w głównym katalogu projektu Next.js.
  2. Wykonanie - middleware uruchamia się przed zrenderowaniem strony lub obsługą żądania API.
  3. Dopasowanie - możesz określić, dla których ścieżek middleware powinno być wykonywane.
  4. Działanie - middleware może przekierować użytkownika, zmodyfikować żądanie/odpowiedź lub po prostu przejść do następnego kroku w łańcuchu przetwarzania.

Prosty przykład middleware:

1// middleware.ts
2import { NextResponse } from 'next/server';
3import type { NextRequest } from 'next/server';
4
5export function middleware(request: NextRequest) {
6  console.log('Middleware uruchomione dla:', request.nextUrl.pathname);
7  
8  // Kontynuuj normalne przetwarzanie żądania
9  return NextResponse.next();
10}
11
12// Zastosuj middleware tylko do określonych ścieżek
13export const config = {
14  matcher: ['/dashboard/:path*', '/api/:path*'],
15};

Implementacja middleware do ochrony tras

Teraz przejdźmy do pełnej implementacji middleware do ochrony tras w aplikacji Next.js.

1. Podstawowe middleware uwierzytelniania

Zacznijmy od prostego middleware, które sprawdza, czy użytkownik jest zalogowany, i chroni określone ścieżki:

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  // Pobierz token sesji z ciasteczka
8  const token = await getToken({ req: request });
9  
10  // Sprawdź, czy użytkownik jest zalogowany
11  const isLoggedIn = !!token;
12  
13  // Aktualna ścieżka
14  const { pathname } = request.nextUrl;
15  
16  // Ścieżki chronione - wymagające zalogowania
17  const protectedPaths = [
18    '/dashboard',
19    '/profile',
20    '/settings',
21  ];
22  
23  // Ścieżki tylko dla niezalogowanych (np. strona logowania)
24  const authPaths = ['/auth/login', '/auth/register'];
25  
26  // Sprawdź, czy aktualna ścieżka jest chroniona
27  const isProtectedPath = protectedPaths.some(path => 
28    pathname === path || pathname.startsWith(`${path}/`)
29  );
30  
31  // Sprawdź, czy aktualna ścieżka jest stroną uwierzytelniania
32  const isAuthPath = authPaths.some(path => pathname === path);
33  
34  // Jeśli użytkownik próbuje dostać się do chronionej ścieżki, ale nie jest zalogowany
35  if (isProtectedPath && !isLoggedIn) {
36    // Przekieruj do strony logowania z informacją o docelowej ścieżce
37    const url = new URL('/auth/login', request.url);
38    url.searchParams.set('callbackUrl', encodeURI(pathname));
39    return NextResponse.redirect(url);
40  }
41  
42  // Jeśli użytkownik jest zalogowany i próbuje dostać się do strony logowania/rejestracji
43  if (isAuthPath && isLoggedIn) {
44    // Przekieruj na dashboard
45    return NextResponse.redirect(new URL('/dashboard', request.url));
46  }
47  
48  // W innych przypadkach kontynuuj normalne przetwarzanie
49  return NextResponse.next();
50}
51
52// Określ, dla których ścieżek middleware powinno być uruchamiane
53export const config = {
54  matcher: [
55    /*
56     * Dopasuj wszystkie ścieżki z wyjątkiem:
57     * 1. Plików statycznych (np. /favicon.ico, /images/*, itd.)
58     * 2. API Route next-auth (/api/auth/*, które obsługuje samo uwierzytelnianie)
59     */
60    '/((?!api/auth|_next/static|_next/image|favicon.ico).*)',
61  ],
62};

2. Zaawansowane middleware z kontrolą ról i uprawnień

Rozbudujmy nasze middleware, aby uwzględniało również role i uprawnienia użytkowników:

1// middleware.ts
2import { NextResponse } from 'next/server';
3import type { NextRequest } from 'next/server';
4import { getToken } from 'next-auth/jwt';
5
6// Struktura opisująca wymagania dostępu dla ścieżki
7interface PathConfig {
8  requireAuth: boolean;        // Czy wymagane jest zalogowanie
9  roles?: string[];            // Dozwolone role (jeśli puste, wszystkie role mają dostęp)
10  permissions?: string[];      // Wymagane uprawnienia
11}
12
13// Konfiguracja dostępu do ścieżek
14const pathAccessConfig: Record<string, PathConfig> = {
15  // Ścieżki administracyjne
16  '/admin': { 
17    requireAuth: true, 
18    roles: ['admin'],
19  },
20  
21  // Ścieżki moderatorów
22  '/moderator': { 
23    requireAuth: true, 
24    roles: ['admin', 'moderator'],
25  },
26  
27  // Ścieżki dashboard - wymagają zalogowania
28  '/dashboard': { 
29    requireAuth: true,
30  },
31  
32  // Strona zarządzania użytkownikami - wymaga uprawnienia user:manage
33  '/admin/users': { 
34    requireAuth: true, 
35    roles: ['admin'],
36    permissions: ['user:manage'],
37  },
38  
39  // Ścieżki publiczne - dostępne dla wszystkich
40  '/': { 
41    requireAuth: false,
42  },
43  '/about': { 
44    requireAuth: false,
45  },
46  '/contact': { 
47    requireAuth: false,
48  },
49  
50  // Ścieżki uwierzytelniania - dostępne tylko dla niezalogowanych
51  '/auth/login': { 
52    requireAuth: false,
53  },
54  '/auth/register': { 
55    requireAuth: false,
56  },
57};
58
59// Funkcja pomocnicza do sprawdzania, czy ścieżka pasuje do konfiguracji
60function matchPathConfig(pathname: string): [string, PathConfig] | null {
61  // Sprawdź dokładne dopasowanie
62  if (pathAccessConfig[pathname]) {
63    return [pathname, pathAccessConfig[pathname]];
64  }
65  
66  // Sprawdź dopasowanie prefiksu
67  for (const configPath in pathAccessConfig) {
68    // Ignoruj dokładne dopasowania, ponieważ już je sprawdziliśmy
69    if (configPath === pathname) continue;
70    
71    // Sprawdź, czy aktualna ścieżka zaczyna się od konfigurowanej ścieżki + /
72    if (pathname.startsWith(`${configPath}/`)) {
73      return [configPath, pathAccessConfig[configPath]];
74    }
75  }
76  
77  // Brak dopasowania
78  return null;
79}
80
81export async function middleware(request: NextRequest) {
82  // Pobierz token sesji z ciasteczka
83  const token = await getToken({ req: request });
84  
85  // Aktualna ścieżka
86  const { pathname } = request.nextUrl;
87  
88  // Znajdź konfigurację dostępu dla aktualnej ścieżki
89  const pathMatch = matchPathConfig(pathname);
90  
91  // Jeśli nie znaleziono konfiguracji dla tej ścieżki, kontynuuj normalne przetwarzanie
92  if (!pathMatch) {
93    return NextResponse.next();
94  }
95  
96  const [configPath, config] = pathMatch;
97  
98  // Strony uwierzytelniania - przekieruj do dashboard, jeśli użytkownik jest już zalogowany
99  if (
100    !config.requireAuth && 
101    (pathname === '/auth/login' || pathname === '/auth/register') && 
102    token
103  ) {
104    return NextResponse.redirect(new URL('/dashboard', request.url));
105  }
106  
107  // Jeśli ścieżka wymaga uwierzytelnienia, a użytkownik nie jest zalogowany
108  if (config.requireAuth && !token) {
109    // Przekieruj do strony logowania z informacją o docelowej ścieżce
110    const url = new URL('/auth/login', request.url);
111    url.searchParams.set('callbackUrl', pathname);
112    return NextResponse.redirect(url);
113  }
114  
115  // Jeśli ścieżka wymaga określonych ról, sprawdź uprawnienia użytkownika
116  if (token && config.roles && config.roles.length > 0) {
117    const userRole = token.role as string;
118    
119    if (!config.roles.includes(userRole)) {
120      // Użytkownik nie ma wymaganej roli - przekieruj do strony braku uprawnień
121      return NextResponse.redirect(new URL('/auth/unauthorized', request.url));
122    }
123  }
124  
125  // Jeśli ścieżka wymaga określonych uprawnień, sprawdź uprawnienia użytkownika
126  if (token && config.permissions && config.permissions.length > 0) {
127    const userPermissions = token.permissions as string[] || [];
128    
129    // Sprawdź, czy użytkownik ma wszystkie wymagane uprawnienia
130    const hasAllPermissions = config.permissions.every(permission =>
131      userPermissions.includes(permission)
132    );
133    
134    if (!hasAllPermissions) {
135      // Użytkownik nie ma wymaganych uprawnień - przekieruj do strony braku uprawnień
136      return NextResponse.redirect(new URL('/auth/unauthorized', request.url));
137    }
138  }
139  
140  // Wszystkie warunki spełnione - kontynuuj
141  return NextResponse.next();
142}
143
144// Dopasowanie dla wszystkich tras z wyjątkiem plików statycznych i API uwierzytelniania
145export const config = {
146  matcher: [
147    '/((?!api/auth|_next/static|_next/image|images|favicon.ico).*)',
148  ],
149};

3. Middleware dla uwierzytelniania API

Jak widać, opisane middleware działa dla stron, przekierowując użytkownika w przypadku braku uprawnień. W przypadku API routes powinniśmy zwrócić odpowiednie kody błędów, a nie przekierowywać użytkownika. Oto implementacja middleware specjalnie dla API:

1// middleware.ts
2import { NextResponse } from 'next/server';
3import type { NextRequest } from 'next/server';
4import { getToken } from 'next-auth/jwt';
5
6// Konfiguracja dostępu do tras API
7const apiAccessConfig: Record<string, {
8  roles?: string[];
9  permissions?: string[];
10}> = {
11  // API administratora
12  '/api/admin': { 
13    roles: ['admin'],
14  },
15  
16  // API moderatora
17  '/api/moderator': { 
18    roles: ['admin', 'moderator'],
19  },
20  
21  // API zarządzania użytkownikami
22  '/api/users': { 
23    permissions: ['user:read'],
24  },
25  '/api/users/create': { 
26    permissions: ['user:create'],
27  },
28  '/api/users/update': { 
29    permissions: ['user:update'],
30  },
31  '/api/users/delete': { 
32    permissions: ['user:delete'],
33  },
34};
35
36// Funkcja pomocnicza do sprawdzania, czy ścieżka API pasuje do konfiguracji
37function matchApiConfig(pathname: string) {
38  // Sprawdź dokładne dopasowanie
39  if (apiAccessConfig[pathname]) {
40    return [pathname, apiAccessConfig[pathname]];
41  }
42  
43  // Sprawdź dopasowanie prefiksu
44  for (const configPath in apiAccessConfig) {
45    if (pathname.startsWith(`${configPath}/`)) {
46      return [configPath, apiAccessConfig[configPath]];
47    }
48  }
49  
50  return null;
51}
52
53export async function middleware(request: NextRequest) {
54  // Pobierz token sesji
55  const token = await getToken({ req: request });
56  
57  // Aktualna ścieżka
58  const { pathname } = request.nextUrl;
59  
60  // Sprawdź, czy to żądanie API
61  const isApiRequest = pathname.startsWith('/api/');
62  
63  // Jeśli to nie jest API, przejdź do następnego middleware
64  if (!isApiRequest) {
65    return NextResponse.next();
66  }
67  
68  // Pomiń routes uwierzytelniania
69  if (pathname.startsWith('/api/auth/')) {
70    return NextResponse.next();
71  }
72  
73  // Jeśli to API i użytkownik nie jest zalogowany
74  if (!token) {
75    return NextResponse.json(
76      { error: 'Unauthorized - Authentication required' },
77      { status: 401 }
78    );
79  }
80  
81  // Znajdź konfigurację dostępu dla tego endpointu API
82  const apiMatch = matchApiConfig(pathname);
83  
84  // Jeśli brak konfiguracji, zakładamy, że wystarczy być zalogowanym
85  if (!apiMatch) {
86    return NextResponse.next();
87  }
88  
89  const [configPath, config] = apiMatch;
90  
91  // Sprawdź rolę użytkownika
92  if (config.roles && config.roles.length > 0) {
93    const userRole = token.role as string;
94    
95    if (!config.roles.includes(userRole)) {
96      return NextResponse.json(
97        { error: 'Forbidden - Insufficient role permissions' },
98        { status: 403 }
99      );
100    }
101  }
102  
103  // Sprawdź uprawnienia użytkownika
104  if (config.permissions && config.permissions.length > 0) {
105    const userPermissions = token.permissions as string[] || [];
106    
107    const hasAllPermissions = config.permissions.every(permission =>
108      userPermissions.includes(permission)
109    );
110    
111    if (!hasAllPermissions) {
112      return NextResponse.json(
113        { error: 'Forbidden - Insufficient permissions' },
114        { status: 403 }
115      );
116    }
117  }
118  
119  // Wszystkie warunki spełnione
120  return NextResponse.next();
121}
122
123// Dopasowanie dla wszystkich tras API
124export const config = {
125  matcher: ['/api/:path*'],
126};

4. Łączenie middleware dla stron i API

Możemy połączyć oba podejścia w jednym middleware:

1// middleware.ts
2import { NextResponse } from 'next/server';
3import type { NextRequest } from 'next/server';
4import { getToken } from 'next-auth/jwt';
5
6// Konfiguracja dostępu do stron
7const pageAccessConfig: Record<string, {
8  requireAuth: boolean;
9  roles?: string[];
10  permissions?: string[];
11}> = {
12  '/admin': { requireAuth: true, roles: ['admin'] },
13  '/moderator': { requireAuth: true, roles: ['admin', 'moderator'] },
14  '/dashboard': { requireAuth: true },
15  '/profile': { requireAuth: true },
16  '/settings': { requireAuth: true },
17  '/': { requireAuth: false },
18  '/about': { requireAuth: false },
19  '/auth/login': { requireAuth: false },
20  '/auth/register': { requireAuth: false },
21};
22
23// Konfiguracja dostępu do API
24const apiAccessConfig: Record<string, {
25  roles?: string[];
26  permissions?: string[];
27}> = {
28  '/api/admin': { roles: ['admin'] },
29  '/api/moderator': { roles: ['admin', 'moderator'] },
30  '/api/users': { permissions: ['user:read'] },
31  '/api/users/create': { permissions: ['user:create'] },
32};
33
34// Funkcje pomocnicze do dopasowywania ścieżek (pominięte dla zwięzłości)
35//
36export async function middleware(request: NextRequest) {
37  // Pobierz token sesji
38  const token = await getToken({ req: request });
39  const { pathname } = request.nextUrl;
40  
41  // Sprawdź, czy to żądanie API
42  const isApiRequest = pathname.startsWith('/api/');
43  
44  // Pomiń routes uwierzytelniania
45  if (pathname.startsWith('/api/auth/')) {
46    return NextResponse.next();
47  }
48  
49  // Obsługa żądań API
50  if (isApiRequest) {
51    // Jeśli to API i użytkownik nie jest zalogowany (dla chronionych endpointów)
52    if (!token && !pathname.startsWith('/api/public/')) {
53      return NextResponse.json(
54        { error: 'Unauthorized - Authentication required' },
55        { status: 401 }
56      );
57    }
58    
59    // Dopasuj konfigurację API
60    const apiMatch = matchApiConfig(pathname);
61    if (apiMatch && token) {
62      const [configPath, config] = apiMatch;
63      
64      // Sprawdź role i uprawnienia
65      if (!hasRequiredRole(token, config.roles) || 
66          !hasRequiredPermissions(token, config.permissions)) {
67        return NextResponse.json(
68          { error: 'Forbidden - Insufficient permissions' },
69          { status: 403 }
70        );
71      }
72    }
73  } 
74  // Obsługa stron
75  else {
76    // Dopasuj konfigurację strony
77    const pageMatch = matchPageConfig(pathname);
78    if (pageMatch) {
79      const [configPath, config] = pageMatch;
80      
81      // Strony uwierzytelniania - przekieruj, jeśli użytkownik jest zalogowany
82      if (
83        !config.requireAuth && 
84        (pathname === '/auth/login' || pathname === '/auth/register') && 
85        token
86      ) {
87        return NextResponse.redirect(new URL('/dashboard', request.url));
88      }
89      
90      // Jeśli strona wymaga uwierzytelnienia, a użytkownik nie jest zalogowany
91      if (config.requireAuth && !token) {
92        const url = new URL('/auth/login', request.url);
93        url.searchParams.set('callbackUrl', pathname);
94        return NextResponse.redirect(url);
95      }
96      
97      // Jeśli użytkownik jest zalogowany, sprawdź role i uprawnienia
98      if (token && config.requireAuth) {
99        if (!hasRequiredRole(token, config.roles) || 
100            !hasRequiredPermissions(token, config.permissions)) {
101          return NextResponse.redirect(new URL('/auth/unauthorized', request.url));
102        }
103      }
104    }
105  }
106  
107  // Wszystkie warunki spełnione
108  return NextResponse.next();
109}
110
111// Funkcje pomocnicze do sprawdzania ról i uprawnień
112function hasRequiredRole(token: any, requiredRoles?: string[]): boolean {
113  if (!requiredRoles || requiredRoles.length === 0) return true;
114  
115  const userRole = token.role as string;
116  return requiredRoles.includes(userRole);
117}
118
119function hasRequiredPermissions(token: any, requiredPermissions?: string[]): boolean {
120  if (!requiredPermissions || requiredPermissions.length === 0) return true;
121  
122  const userPermissions = token.permissions as string[] || [];
123  return requiredPermissions.every(permission => userPermissions.includes(permission));
124}
125
126// Funkcje dopasowujące konfigurację ścieżek (implementacja pominięta dla zwięzłości)
127function matchPageConfig(pathname: string) { /* ... */ }
128function matchApiConfig(pathname: string) { /* ... */ }
129
130export const config = {
131  matcher: [
132    '/((?!_next/static|_next/image|images|favicon.ico).*)',
133  ],
134};

Middleware dla obsługi konkretnych wzorów dostępu

Często potrzebujemy middleware, które obsługuje konkretne przypadki użycia. Przyjrzyjmy się niektórym przykładom:

1. Middleware do kontroli limitu żądań (Rate Limiting)

1// middleware.ts
2import { NextResponse } from 'next/server';
3import type { NextRequest } from 'next/server';
4
5// Prosta implementacja limitu żądań w pamięci
6// W produkcji należy użyć Redis lub podobnego rozwiązania
7const REQUESTS_PER_MINUTE = 60;
8const WINDOW_SIZE_MS = 60 * 1000; // 1 minuta
9
10interface RateLimitState {
11  requests: number;
12  timestamp: number;
13}
14
15const ipRequestCounts = new Map<string, RateLimitState>();
16
17export function middleware(request: NextRequest) {
18  // Pobierz adres IP użytkownika
19  const ip = request.ip || 'unknown';
20  
21  // Sprawdź, czy to jest API
22  if (!request.nextUrl.pathname.startsWith('/api/')) {
23    return NextResponse.next();
24  }
25  
26  // Pobierz aktualny stan limitów dla tego IP
27  const now = Date.now();
28  const state = ipRequestCounts.get(ip) || { requests: 0, timestamp: now };
29  
30  // Jeśli upłynęło okno czasowe, zresetuj licznik
31  if (now - state.timestamp > WINDOW_SIZE_MS) {
32    state.requests = 0;
33    state.timestamp = now;
34  }
35  
36  // Zwiększ licznik żądań
37  state.requests++;
38  ipRequestCounts.set(ip, state);
39  
40  // Jeśli przekroczono limit, zwróć błąd 429 (Too Many Requests)
41  if (state.requests > REQUESTS_PER_MINUTE) {
42    const response = NextResponse.json(
43      { error: 'Rate limit exceeded. Please try again later.' },
44      { status: 429 }
45    );
46    
47    // Ustaw nagłówki informujące o limitach
48    response.headers.set('X-RateLimit-Limit', REQUESTS_PER_MINUTE.toString());
49    response.headers.set('X-RateLimit-Remaining', '0');
50    response.headers.set('Retry-After', 
51      Math.ceil((state.timestamp + WINDOW_SIZE_MS - now) / 1000).toString()
52    );
53    
54    return response;
55  }
56  
57  // W granicach limitu - kontynuuj
58  const response = NextResponse.next();
59  
60  // Ustaw nagłówki informujące o limitach
61  response.headers.set('X-RateLimit-Limit', REQUESTS_PER_MINUTE.toString());
62  response.headers.set(
63    'X-RateLimit-Remaining', 
64    Math.max(0, REQUESTS_PER_MINUTE - state.requests).toString()
65  );
66  
67  return response;
68}
69
70export const config = {
71  matcher: ['/api/:path*'],
72};

2. Middleware dla geolokalizacji (Country-based access)

1// middleware.ts
2import { NextResponse } from 'next/server';
3import type { NextRequest } from 'next/server';
4
5// Lista dozwolonych kodów krajów
6const ALLOWED_COUNTRIES = ['US', 'CA', 'GB', 'DE', 'FR', 'PL'];
7
8// Lista stron objętych restrykcjami geograficznymi
9const GEO_RESTRICTED_PATHS = [
10  '/premium-content',
11  '/members-only',
12];
13
14export function middleware(request: NextRequest) {
15  // Sprawdź, czy ścieżka podlega restrykcjom geograficznym
16  const isGeoRestrictedPath = GEO_RESTRICTED_PATHS.some(path => 
17    request.nextUrl.pathname === path || 
18    request.nextUrl.pathname.startsWith(`${path}/`)
19  );
20  
21  if (isGeoRestrictedPath) {
22    // Pobierz informacje o kraju użytkownika z nagłówków (dostępne na platformie Vercel)
23    const country = request.geo?.country || 'Unknown';
24    
25    // Jeśli kraj użytkownika nie jest na liście dozwolonych
26    if (!ALLOWED_COUNTRIES.includes(country)) {
27      return NextResponse.redirect(new URL('/geo-restricted', request.url));
28    }
29  }
30  
31  return NextResponse.next();
32}
33
34export const config = {
35  matcher: [
36    '/premium-content/:path*',
37    '/members-only/:path*',
38  ],
39};

3. Middleware dla modyfikacji nagłówków bezpieczeństwa

1// middleware.ts
2import { NextResponse } from 'next/server';
3import type { NextRequest } from 'next/server';
4
5export function middleware(request: NextRequest) {
6  // Pobierz oryginalną odpowiedź
7  const response = NextResponse.next();
8  
9  // Dodaj nagłówki bezpieczeństwa
10  
11  // Chroń przed atakami XSS
12  response.headers.set('X-XSS-Protection', '1; mode=block');
13  
14  // Zapobiegaj "sniffing" MIME typów
15  response.headers.set('X-Content-Type-Options', 'nosniff');
16  
17  // Kontroluj, jak strona może być osadzona w iframe
18  response.headers.set('X-Frame-Options', 'SAMEORIGIN');
19  
20  // Content Security Policy - bardziej zaawansowana ochrona przed XSS
21  response.headers.set(
22    'Content-Security-Policy',
23    "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://api.example.com;"
24  );
25  
26  // HTTP Strict Transport Security - wymusza użycie HTTPS
27  response.headers.set(
28    'Strict-Transport-Security',
29    'max-age=31536000; includeSubDomains; preload'
30  );
31  
32  // Permissions Policy - ogłoś, które funkcje przeglądarki są dozwolone
33  response.headers.set(
34    'Permissions-Policy',
35    'camera=(), microphone=(), geolocation=(self), interest-cohort=()'
36  );
37  
38  return response;
39}
40
41export const config = {
42  matcher: [
43    '/((?!_next/static|_next/image|images|favicon.ico).*)',
44  ],
45};

4. Middleware dla A/B testowania

1// middleware.ts
2import { NextResponse } from 'next/server';
3import type { NextRequest } from 'next/server';
4
5// Konfiguracja eksperymentów A/B
6const AB_TESTS = {
7  'new-landing-page': {
8    variants: ['control', 'variant-a', 'variant-b'],
9    weights: [0.34, 0.33, 0.33], // Podział ruchu
10    paths: ['/'],  // Ścieżki objęte testem
11    cookieName: 'ab-new-landing',
12  },
13  'checkout-flow': {
14    variants: ['original', 'simplified'],
15    weights: [0.5, 0.5],
16    paths: ['/checkout', '/cart'],
17    cookieName: 'ab-checkout',
18  },
19};
20
21export function middleware(request: NextRequest) {
22  const url = request.nextUrl;
23  const response = NextResponse.next();
24  
25  // Iteruj przez zdefiniowane testy
26  for (const [testName, test] of Object.entries(AB_TESTS)) {
27    // Sprawdź, czy aktualna ścieżka jest objęta testem
28    const isTestPath = test.paths.some(path => 
29      url.pathname === path || url.pathname.startsWith(`${path}/`)
30    );
31    
32    if (isTestPath) {
33      // Sprawdź, czy użytkownik ma już przypisany wariant testu
34      const existingVariant = request.cookies.get(test.cookieName)?.value;
35      let variant = existingVariant;
36      
37      // Jeśli użytkownik nie ma przypisanego wariantu, przydziel nowy
38      if (!variant) {
39        variant = assignVariant(test.variants, test.weights);
40        
41        // Ustaw ciasteczko z przypisanym wariantem
42        response.cookies.set(test.cookieName, variant, {
43          maxAge: 60 * 60 * 24 * 30, // 30 dni
44          path: '/',
45        });
46      }
47      
48      // Dodaj nagłówek z informacją o wariancie testu
49      response.headers.set(`X-AB-Test-${testName}`, variant);
50      
51      // Możesz również ustawić rewrite do innej strony dla różnych wariantów
52      if (testName === 'new-landing-page' && url.pathname === '/') {
53        if (variant === 'variant-a') {
54          // Przekierowanie wewnętrzne (niewidoczne dla użytkownika)
55          return NextResponse.rewrite(new URL('/landing-a', request.url));
56        } else if (variant === 'variant-b') {
57          return NextResponse.rewrite(new URL('/landing-b', request.url));
58        }
59      }
60    }
61  }
62  
63  return response;
64}
65
66// Funkcja przydzielająca wariant na podstawie wag
67function assignVariant(variants: string[], weights: number[]): string {
68  const randomNum = Math.random();
69  let cumulativeWeight = 0;
70  
71  for (let i = 0; i < variants.length; i++) {
72    cumulativeWeight += weights[i];
73    if (randomNum < cumulativeWeight) {
74      return variants[i];
75    }
76  }
77  
78  // Fallback na pierwszy wariant
79  return variants[0];
80}
81
82export const config = {
83  matcher: [
84    '/', '/checkout', '/cart',
85    '/landing-a', '/landing-b',
86  ],
87};

Testowanie middleware

Testy są bardzo ważne dla sprawdzenia poprawności działania middleware, zwłaszcza że obsługuje ono kluczowe aspekty bezpieczeństwa aplikacji. Oto prosty przykład testów jednostkowych:

1// __tests__/middleware.test.ts
2import { NextRequest } from 'next/server';
3import { middleware } from '../middleware';
4import { getToken } from 'next-auth/jwt';
5
6// Mockujemy getToken z next-auth/jwt
7jest.mock('next-auth/jwt', () => ({
8  getToken: jest.fn(),
9}));
10
11describe('Auth Middleware', () => {
12  // Setup i reset przed każdym testem
13  beforeEach(() => {
14    jest.clearAllMocks();
15  });
16  
17  it('should redirect unauthenticated user from protected route to login page', async () => {
18    // Mockuj getToken zwracający null (niezalogowany użytkownik)
19    (getToken as jest.Mock).mockResolvedValueOnce(null);
20    
21    // Utwórz testowy request do chronionej ścieżki
22    const req = new NextRequest(new URL('http://localhost:3000/dashboard'));
23    
24    // Uruchom middleware
25    const response = await middleware(req);
26    
27    // Sprawdź, czy przekierowuje do strony logowania
28    expect(response).not.toBeNull();
29    expect(response.status).toBe(307); // Kod przekierowania
30    expect(response.headers.get('location')).toBe('http://localhost:3000/auth/login?callbackUrl=%2Fdashboard');
31  });
32  
33  it('should allow authenticated user to access protected route', async () => {
34    // Mockuj getToken zwracający token (zalogowany użytkownik)
35    (getToken as jest.Mock).mockResolvedValueOnce({
36      name: 'Test User',
37      email: 'test@example.com',
38      role: 'user',
39      permissions: ['content:read']
40    });
41    
42    // Utwórz testowy request do chronionej ścieżki
43    const req = new NextRequest(new URL('http://localhost:3000/dashboard'));
44    
45    // Uruchom middleware
46    const response = await middleware(req);
47    
48    // Sprawdź, czy zezwala na dostęp
49    expect(response).not.toBeNull();
50    expect(response.status).toBe(200);
51  });
52  
53  it('should redirect authenticated user from login page to dashboard', async () => {
54    // Mockuj getToken zwracający token (zalogowany użytkownik)
55    (getToken as jest.Mock).mockResolvedValueOnce({
56      name: 'Test User',
57      email: 'test@example.com',
58    });
59    
60    // Utwórz testowy request do strony logowania
61    const req = new NextRequest(new URL('http://localhost:3000/auth/login'));
62    
63    // Uruchom middleware
64    const response = await middleware(req);
65    
66    // Sprawdź, czy przekierowuje do dashboardu
67    expect(response).not.toBeNull();
68    expect(response.status).toBe(307);
69    expect(response.headers.get('location')).toBe('http://localhost:3000/dashboard');
70  });
71  
72  it('should redirect user without admin role from admin page', async () => {
73    // Mockuj getToken zwracający token z rolą user
74    (getToken as jest.Mock).mockResolvedValueOnce({
75      name: 'Test User',
76      email: 'test@example.com',
77      role: 'user',
78      permissions: ['content:read']
79    });
80    
81    // Utwórz testowy request do strony administratora
82    const req = new NextRequest(new URL('http://localhost:3000/admin/dashboard'));
83    
84    // Uruchom middleware
85    const response = await middleware(req);
86    
87    // Sprawdź, czy przekierowuje do strony braku uprawnień
88    expect(response).not.toBeNull();
89    expect(response.status).toBe(307);
90    expect(response.headers.get('location')).toBe('http://localhost:3000/auth/unauthorized');
91  });
92});

Dobre praktyki i wzorce

1. Optymalizacja wydajności middleware

Middleware jest wykonywane przy każdym żądaniu, więc ważne jest, aby było jak najbardziej wydajne:

  • Ogranicz wywołania zewnętrznych API - Middleware powinno być szybkie. Unikaj wywołań API w middleware, jeśli to możliwe.
  • Używaj cachowania - Jeśli musisz pobierać dane, rozważ ich cachowanie.
  • Precyzyjnie definiuj matcher - Ograniczaj działanie middleware tylko do ścieżek, które go potrzebują.

2. Debugging middleware

Debugging middleware może być trudny, ponieważ działa "za kulisami":

  • Dodaj logi - Użyj
    console.log()
    do śledzenia działania middleware.
  • Dodaj nagłówki debugowania - Możesz dodać niestandardowe nagłówki do odpowiedzi, aby śledzić co się dzieje.
1// Przykład dodawania nagłówków debugowania
2const response = NextResponse.next();
3response.headers.set('X-Debug-Path', request.nextUrl.pathname);
4response.headers.set('X-Debug-Auth', token ? 'Authenticated' : 'Unauthenticated');
5return response;

3. Organizacja kodu middleware

Dla złożonych aplikacji warto rozważyć modularyzację middleware:

1// middleware/auth.ts
2import { NextRequest, NextResponse } from 'next/server';
3import { getToken } from 'next-auth/jwt';
4
5export async function authMiddleware(req: NextRequest) {
6  // Implementacja middleware uwierzytelniania
7  //
8}
9
10// middleware/security.ts
11import { NextRequest, NextResponse } from 'next/server';
12
13export function securityHeadersMiddleware(req: NextRequest) {
14  // Implementacja middleware nagłówków bezpieczeństwa
15  //
16}
17
18// middleware.ts
19import { NextRequest, NextResponse } from 'next/server';
20import { authMiddleware } from './middleware/auth';
21import { securityHeadersMiddleware } from './middleware/security';
22
23export async function middleware(req: NextRequest) {
24  // Sprawdź, czy ścieżka wymaga uwierzytelniania
25  if (req.nextUrl.pathname.startsWith('/dashboard') || 
26      req.nextUrl.pathname.startsWith('/admin')) {
27    return await authMiddleware(req);
28  }
29  
30  // Dodaj nagłówki bezpieczeństwa do wszystkich odpowiedzi
31  return securityHeadersMiddleware(req);
32}
33
34export const config = { /* ... */ };

Podsumowanie

Middleware w Next.js oferuje potężny mechanizm do implementacji ochrony tras i wielu innych funkcji na poziomie serwera. W tym module poznaliśmy:

  1. Podstawy middleware w Next.js - jak działa i jak go skonfigurować
  2. Implementację middleware uwierzytelniania - jak chronić trasy przed nieautoryzowanym dostępem
  3. Middleware z kontrolą ról i uprawnień - jak zapewniać dostęp na podstawie ról użytkowników
  4. Specjalizowane middleware - jak implementować rate limiting, geolokalizację, nagłówki bezpieczeństwa i A/B testy
  5. Testowanie middleware - jak pisać testy do middleware
  6. Dobre praktyki i wzorce - jak optymalizować i organizować kod middleware

Dobra implementacja middleware jest kluczowym elementem bezpieczeństwa i wydajności aplikacji Next.js. Dzięki technikom przedstawionym w tym module, możesz tworzyć zaawansowane, wielowarstwowe zabezpieczenia dla swoich aplikacji.

W następnym module przejdziemy do implementacji Context Provider dla uwierzytelniania, co umożliwi łatwy dostęp do danych uwierzytelniania w całej aplikacji.

Przejdź do CodeWorlds