Paweł Łukasiewicz: programista blogger
Paweł Łukasiewicz
2026-02-03
Paweł Łukasiewicz: programista blogger
Paweł Łukasiewicz
2026-02-03
Udostępnij Udostępnij Kontakt
Wprowadzenie

W poprzednich wpisach poznaliśmy React Query i zaawansowane wzorce pobierania danych. Ale każda prawdziwa aplikacja potrzebuje autentykacji (uwierzytelniania – weryfikacji tożsamości użytkownika) i autoryzacji (nadawania uprawnień – sprawdzania czy użytkownik ma dostęp do zasobu).

🔍 Autentykacja vs Autoryzacja - różnica:
Autentykacja Autoryzacja
Pytanie "KIM jesteś?" "CO możesz robić?"
Weryfikuje Tożsamość Uprawnienia
Przykład Login + hasło Admin może usuwać, User tylko czytać
Analogia Dowód osobisty 🪪 Karta dostępu 🔑

W skrócie: Autentykacja = "zaloguj się", Autoryzacja = "sprawdź czy możesz to zrobić"

W tym wpisie zbudujemy kompletny system autentykacji od zera! Nauczymy się jak działają JWT tokens (tokeny JSON Web Token – zaszyfrowane dane użytkownika), jak zaimplementować login i rejestrację, gdzie przechowywać tokeny (localStorage vs cookies vs httpOnly), jak chronić protected routes (chronione ścieżki – strony dostępne tylko dla zalogowanych), jak działa refresh token mechanism (mechanizm odświeżania tokenów – automatyczne przedłużanie sesji), Axios interceptors (przechwytywacze żądań – automatyczne dodawanie tokenów do requestów) i wylogowanie.

To będzie bardzo praktyczny wpis z pełnym działającym kodem! Po nim będziesz potrafił zaimplementować bezpieczną autentykację w każdej aplikacji React.

JWT Tokens – jak działają?

Czym jest JWT?

JWT (JSON Web Token) to zakodowany string zawierający informacje o użytkowniku. Token składa się z trzech części oddzielonych kropkami:

🔍 JWT - co to jest w prostych słowach?

JWT = cyfrowa przepustka 🎫

Wyobraź sobie koncert:

  • Login = kupisz bilet przy wejściu
  • JWT = dostajesz opaskę na rękę z kodem QR
  • Protected routes = różne strefy (VIP, backstage)
  • Weryfikacja = ochroniarz skanuje opaskę → puszcza lub nie

Opaska (JWT) zawiera: Twoje imię, jaką masz strefę (admin/user), kiedy wygasa

header.payload.signature

Przykład tokenu:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEyMywibmFtZSI6IkphbiIsImV4cCI6MTY5MDAwMDAwMH0.abc123xyz

Części tokenu:

1. Header (nagłówek) – algorytm szyfrowania

{
  "alg": "HS256",
  "typ": "JWT"
}

2. Payload (dane) – informacje o użytkowniku

{
  "userId": 123,
  "email": "jan@example.com",
  "role": "admin",
  "exp": 1690000000  // Czas wygaśnięcia tokenu
}

3. Signature (podpis) – weryfikacja autentyczności

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)
🔍 Dlaczego 3 części?

Header = "Jak zaszyfrowano?"

  • Algorytm (HS256, RS256)
  • Typ (JWT)

Payload = "Kto i co może?"

  • userId, email, role
  • exp (kiedy wygasa)
  • ⚠️ NIE przechowuj haseł!

Signature = "Czy nie zmodyfikowano?"

  • Podpis używający secret key
  • Tylko serwer zna secret
  • Jeśli ktoś zmieni payload → signature się nie zgadza → odrzucamy! 🛡️

Jak to działa w praktyce?

🔍 Flow JWT - krok po kroku:
  1. Użytkownik loguje się
    • POST /login { email: "jan@test.com", password: "haslo123" }
  2. Serwer weryfikuje
    • Sprawdza w bazie: czy email istnieje?
    • Sprawdza: czy hasło się zgadza? (bcrypt)
  3. Serwer tworzy JWT
    • Pakuje: { userId: 123, email: "jan@test.com", role: "admin" }
    • Podpisuje secret key
  4. Zwraca token
    • Response: { token: "eyJhbGci..." }
    • Frontend zapisuje (localStorage / cookie)
  5. Każdy request
    • GET /api/users
    • Headers: { Authorization: "Bearer eyJhbGci..." }
  6. Serwer weryfikuje token
    • Sprawdza podpis (czy nie zmodyfikowano)
    • Sprawdza exp (czy nie wygasł)
  7. Zwraca dane
    • Jeśli OK → Response: { users: [...] }
    • Jeśli błąd → 401 Unauthorized
  1. Użytkownik loguje się → wysyła email + hasło
  2. Serwer weryfikuje → sprawdza czy dane są poprawne
  3. Serwer tworzy JWT → pakuje dane użytkownika w token
  4. Zwraca token → frontend zapisuje token
  5. Każdy request → frontend wysyła token w nagłówku Authorization: Bearer <token>
  6. Serwer weryfikuje token → sprawdza podpis i czy nie wygasł
  7. Zwraca dane → jeśli token OK, użytkownik dostaje dostęp
🌟 Przykład: Dekodowanie JWT

JWT NIE jest zaszyfrowany - jest zakodowany Base64! Możesz go zdekodować:

// Token:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEyMywibmFtZSI6IkphbiJ9.abc123

// Dekoduj w konsoli:
const payload = JSON.parse(atob("eyJ1c2VySWQiOjEyMywibmFtZSI6IkphbiJ9"));
console.log(payload);
// { userId: 123, name: "Jan" }

// Albo na https://jwt.io
// Wklej token → widać payload!

// ⚠️ WAŻNE:
// - JWT NIE jest tajny! Każdy może go zdekodować!
// - NIE przechowuj haseł, kart kredytowych w JWT!
// - PODPIS chroni przed MODYFIKACJĄ, nie przed ODCZYTANIEM!

Access Token vs Refresh Token

🔍 Dlaczego dwa tokeny?

Problem z jednym tokenem:

  • ❌ Krótki (15 min) → użytkownik musi się logować co 15 min 😤
  • ❌ Długi (7 dni) → jeśli ukradną, mają dostęp przez 7 dni! 😱

Rozwiązanie - dwa tokeny:

Access Token (krótki):
- Czas życia: 15 minut
- Używany: przy każdym request
- Przechowywany: pamięć / localStorage (mniej bezpieczne OK)
- Jeśli ukradną: szkoda tylko 15 min ✅

Refresh Token (długi):
- Czas życia: 7 dni
- Używany: tylko do odświeżenia Access Token
- Przechowywany: httpOnly cookie (bardzo bezpieczne!)
- Jeśli ukradną: trudniej, bo httpOnly

Flow:

  1. Access Token wygasł (po 15 min)
  2. Frontend: używa Refresh Token → POST /refresh
  3. Serwer: zwraca NOWY Access Token
  4. Frontend: kontynuuje (użytkownik nie zauważa!)
  • Access Token – krótkotrwały (15 min), używany do każdego requesta
  • Refresh Token – długotrwały (7 dni), używany do odświeżenia Access Token

Dlaczego dwa tokeny?

  • Jeśli ktoś ukradnie Access Token, szkoda jest minimalna (wygasa za 15 min)
  • Refresh Token jest przechowywany bezpieczniej (httpOnly cookie)
  • Refresh Token pozwala użytkownikowi pozostać zalogowanym bez ciągłego logowania
Przechowywanie tokenów

Opcja 1: localStorage (NIE zalecane dla produkcji!)

⚠️ localStorage - wygodne, ale niebezpieczne!

Atak XSS (Cross-Site Scripting):

// Scenariusz ataku:
// 1. Hacker wstrzykuje złośliwy skrypt na stronę


// 4. Hacker ma Twój token!
// 5. Może wysyłać requesty jako Ty! 💀

Każdy kod JavaScript ma dostęp do localStorage!

// ❌ Podatne na ataki XSS (Cross-Site Scripting)
localStorage.setItem('accessToken', token);
const token = localStorage.getItem('accessToken');

Wady:

  • Dostępne dla JavaScript → podatne na XSS
  • Jeśli hacker wstrzyknie złośliwy kod, może ukraść token
  • Nie zalecane dla wrażliwych danych

Kiedy można użyć:

  • Aplikacje demo/nauka
  • Mniej wrażliwe dane
  • Projekty wewnętrzne

Opcja 2: httpOnly cookies (ZALECANE!)

🔍 httpOnly cookie - jak działa ochrona?

httpOnly = JavaScript NIE MA DOSTĘPU do cookie!

// Serwer ustawia cookie:
Set-Cookie: refreshToken=abc123; HttpOnly; Secure; SameSite=Strict

// W przeglądarce cookie ISTNIEJE, ale:
document.cookie  // ← NIE WIDAĆ refreshToken!

// Próba kradzieży:
const token = document.cookie;  // ← refreshToken NIEWIDOCZNY!

// Cookie jest automatycznie wysyłany przy każdym request:
fetch('/api/refresh')  // ← Cookie idzie automatycznie!
// Ale JavaScript nie może go odczytać = BEZPIECZNE! 🛡️

Dodatkowe zabezpieczenia:

  • Secure - tylko HTTPS (nie HTTP)
  • SameSite=Strict - tylko z tej samej domeny
// ✅ Serwer ustawia cookie w odpowiedzi HTTP
Set-Cookie: refreshToken=abc123; HttpOnly; Secure; SameSite=Strict
💪 Ćwiczenie 1: Przechowywanie tokenów

Zaprojektuj strategię przechowywania dla aplikacji bankowej:

  1. Access Token - gdzie przechowasz? Dlaczego?
  2. Refresh Token - gdzie przechowasz? Dlaczego?
  3. Jakie zabezpieczenia dodasz?
  4. Co zrobisz jeśli Access Token wygaśnie?
  5. Co zrobisz jeśli Refresh Token wygaśnie?

Wskazówka: Bezpieczeństwo > wygoda!

Zalety:

  • JavaScript NIE ma dostępu → ochrona przed XSS
  • Automatycznie wysyłane z każdym requestem
  • Bezpieczniejsze dla Refresh Token

Wady:

  • Wymaga konfiguracji CORS na serwerze
  • Podatne na CSRF (Cross-Site Request Forgery) → rozwiązanie: SameSite flag
🔍 CSRF - co to za atak?

CSRF (Cross-Site Request Forgery) = wymuszony request z innej strony

// Scenariusz ataku CSRF:
// 1. Jesteś zalogowany na bank.com (masz cookie!)
// 2. Odwiedzasz złośliwą stronę zlodzieje.com
// 3. Na zlodzieje.com jest ukryty kod:


// 4. Przeglądarka automatycznie wysyła cookie do bank.com!
// 5. Bank myśli że TO TY wysyłasz request! 💸
// 6. Przelew się wykonuje!

// Ochrona - SameSite=Strict:
Set-Cookie: token=abc; SameSite=Strict

// Teraz cookie NIE JEST wysyłane z innej domeny!
// Request z zlodzieje.com → cookie NIE IDZIE → BEZPIECZNE! 🛡️

Opcja 3: Hybrid (najlepsza praktyka!)

🔍 Hybrid approach - best of both worlds!

Access Token w pamięci (zmiennej):

  • ✅ Znika po refresh strony (minimalne ryzyko kradzieży)
  • ✅ Krótkotrwały (15 min) - nawet jeśli ukradną, szkoda mała
  • ❌ Użytkownik musi się "relogować" po refresh (ale automatycznie!)

Refresh Token w httpOnly cookie:

  • ✅ Bezpieczny (JavaScript nie ma dostępu)
  • ✅ Długotrwały (7 dni) - użytkownik pozostaje zalogowany
  • ✅ Automatycznie wysyłany przy /refresh

Flow po refresh strony:

  1. Access Token ZNIKNĄŁ (był w zmiennej)
  2. Refresh Token ISTNIEJE (httpOnly cookie)
  3. Przy montowaniu: POST /refresh
  4. Serwer: sprawdza Refresh Token z cookie
  5. Zwraca NOWY Access Token
  6. Użytkownik ZALOGOWANY! Nawet nie zauważył! ✨
// ✅ Access Token w memory (zmiennej)
let accessToken: string | null = null;

// ✅ Refresh Token w httpOnly cookie (ustawiane przez serwer)
Set-Cookie: refreshToken=xyz; HttpOnly; Secure; SameSite=Strict

Dlaczego najlepsze?

  • Access Token w pamięci → znika po odświeżeniu (minimalne ryzyko)
  • Refresh Token w httpOnly → bezpieczny, długotrwały
  • Najlepsza równowaga bezpieczeństwo/wygoda
Implementacja AuthContext

Stwórzmy context do zarządzania autentykacją w całej aplikacji.

🔍 AuthContext - po co?

Bez AuthContext:

  • ❌ Każdy komponent osobno sprawdza czy zalogowany
  • ❌ Login/logout w wielu miejscach (duplikacja kodu)
  • ❌ User data przechodzą przez props (prop drilling)

Z AuthContext:

  • ✅ JEDEN stan autentykacji dla całej aplikacji
  • ✅ Login/logout z każdego miejsca: useAuth()
  • ✅ user, isAuthenticated dostępne wszędzie
  • ✅ Automatyczne sprawdzanie przy starcie (checkAuth)
// contexts/AuthContext.tsx
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { apiClient } from '../api/client';

interface User {
  id: number;
  email: string;
  name: string;
  role: string;
}

interface AuthContextType {
  user: User | null;
  accessToken: string | null;
  login: (email: string, password: string) => Promise<void>;
  register: (name: string, email: string, password: string) => Promise<void>;
  logout: () => Promise<void>;
  isAuthenticated: boolean;
  isLoading: boolean;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [accessToken, setAccessToken] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  // Sprawdź czy użytkownik jest zalogowany przy montowaniu
  useEffect(() => {
    checkAuth();
  }, []);

  async function checkAuth() {
    try {
      // Spróbuj odświeżyć token (używa refresh token z httpOnly cookie)
      const response = await apiClient.post('/auth/refresh');
      const { accessToken, user } = response.data;
      
      setAccessToken(accessToken);
      setUser(user);
    } catch (error) {
      // Brak ważnego refresh token - użytkownik niezalogowany
      setAccessToken(null);
      setUser(null);
    } finally {
      setIsLoading(false);
    }
  }

  async function login(email: string, password: string) {
    const response = await apiClient.post('/auth/login', { email, password });
    const { accessToken, user } = response.data;
    
    // refreshToken jest ustawiony automatycznie w httpOnly cookie przez serwer
    setAccessToken(accessToken);
    setUser(user);
  }

  async function register(name: string, email: string, password: string) {
    const response = await apiClient.post('/auth/register', { name, email, password });
    const { accessToken, user } = response.data;
    
    setAccessToken(accessToken);
    setUser(user);
  }

  async function logout() {
    try {
      // Wyloguj na serwerze (usuń refresh token)
      await apiClient.post('/auth/logout');
    } finally {
      // Wyczyść stan lokalny (nawet jeśli request się nie powiódł)
      setAccessToken(null);
      setUser(null);
    }
  }

  const value = {
    user,
    accessToken,
    login,
    register,
    logout,
    isAuthenticated: !!user,
    isLoading,
  };

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

// Hook do używania AuthContext
export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
}
🔍 checkAuth() - dlaczego przy montowaniu?

Scenariusz: Użytkownik odświeża stronę

  1. Access Token ZNIKNĄŁ (był w zmiennej)
  2. user = null, accessToken = null
  3. useEffect wywołuje checkAuth()
  4. checkAuth: POST /refresh (wysyła Refresh Token z cookie)
  5. Serwer: sprawdza Refresh Token, zwraca nowy Access Token
  6. setAccessToken + setUser
  7. Użytkownik NADAL ZALOGOWANY! 🎉

Bez checkAuth: użytkownik byłby wylogowany po każdym refresh! 😱

Setup w App

// main.tsx
import { AuthProvider } from './contexts/AuthContext';

ReactDOM.createRoot(document.getElementById('root')!).render(
            <React.StrictMode>
            <QueryClientProvider client={queryClient}>
            <AuthProvider>
            <App />
      </AuthProvider>
    </QueryClientProvider>
  </React.StrictMode>
);
Axios Interceptors – automatyczne dodawanie tokenów

Interceptors (przechwytywacze) automatycznie modyfikują każdy request/response.

🔍 Interceptors - jak działają?

Request Interceptor = "przed wysłaniem"

// Każdy request przechodzi przez interceptor:
fetch('/api/users')
  ↓
Request Interceptor
  ↓ Dodaje: Authorization: Bearer abc123
  ↓
Wysyłka do API

Response Interceptor = "po otrzymaniu"

API zwraca 401 Unauthorized
  ↓
Response Interceptor
  ↓ Sprawdza: czy 401?
  ↓ Tak! Wywołuje /refresh
  ↓ Dostaje nowy token
  ↓ Ponawia oryginalny request
  ↓
Zwraca dane (użytkownik nie zauważył problemu!)
// api/client.ts
import axios from 'axios';

export const apiClient = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'http://localhost:5000/api',
  withCredentials: true, // Wysyłaj cookies (refresh token)
});

let accessToken: string | null = null;

// Funkcja do ustawiania tokenu (wywoływana z AuthContext)
export function setAccessToken(token: string | null) {
  accessToken = token;
}

// Request Interceptor - dodaj token do każdego requesta
apiClient.interceptors.request.use(
  (config) => {
    if (accessToken) {
      config.headers.Authorization = `Bearer ${accessToken}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

// Response Interceptor - obsługa błędów i refresh token
apiClient.interceptors.response.use(
  (response) => response, // Jeśli OK, zwróć response
  async (error) => {
    const originalRequest = error.config;

    // Jeśli 401 (Unauthorized) i to nie retry (powtórka)
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true; // Oznacz jako retry

      try {
        // Spróbuj odświeżyć token
        const response = await axios.post(
          `${apiClient.defaults.baseURL}/auth/refresh`,
          {},
          { withCredentials: true } // Wyślij refresh token z cookie
        );

        const newAccessToken = response.data.accessToken;
        setAccessToken(newAccessToken);

        // Ponów oryginalny request z nowym tokenem
        originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
        return apiClient(originalRequest);
      } catch (refreshError) {
        // Refresh się nie powiódł - wyloguj użytkownika
        setAccessToken(null);
        window.location.href = '/login';
        return Promise.reject(refreshError);
      }
    }

    return Promise.reject(error);
  }
);
🔍 Response Interceptor - flow szczegółowo:
// Użytkownik: GET /api/users (Access Token wygasł!)

1. Request idzie do API
   ↓
2. API zwraca 401 Unauthorized (token wygasł)
   ↓
3. Response Interceptor łapie błąd
   ↓ Sprawdza: error.status === 401?
   ↓ Tak!
   ↓ Sprawdza: _retry flag?
   ↓ Nie ma → to pierwsze podejście
   ↓
4. Ustawia originalRequest._retry = true
   ↓
5. POST /auth/refresh (wysyła Refresh Token z cookie)
   ↓
6. Serwer zwraca nowy Access Token
   ↓
7. setAccessToken(newToken)
   ↓
8. Ponawia oryginalny request:
   apiClient(originalRequest) ← ale teraz z nowym tokenem!
   ↓
9. Tym razem 200 OK!
   ↓
10. Zwraca dane do użytkownika

// Użytkownik NIE ZAUWAŻYŁ że token wygasł! ✨
// Wszystko działa płynnie!

Co tu się dzieje?

  1. Request interceptor – dodaje Authorization: Bearer <token> do każdego requesta
  2. Response interceptor – gdy dostanie 401:
    • Próbuje odświeżyć token używając refresh token
    • Jeśli refresh OK → ponawia oryginalny request z nowym tokenem
    • Jeśli refresh fail → wylogowuje użytkownika

Połączenie z AuthContext

🔍 Synchronizacja AuthContext + Axios

Problem: Access Token jest w DWÓCH miejscach:

  • AuthContext state (do React)
  • Axios zmienna (do interceptors)

Rozwiązanie: Helper updateAccessToken() - aktualizuje OBA!

// contexts/AuthContext.tsx
import { setAccessToken } from '../api/client';

export function AuthProvider({ children }: { children: ReactNode }) {
  const [accessToken, setAccessTokenState] = useState(null);

  // Helper do ustawiania tokenu w obu miejscach
  function updateAccessToken(token: string | null) {
    setAccessTokenState(token);
    setAccessToken(token); // Ustaw też w Axios
  }

  async function login(email: string, password: string) {
    const response = await apiClient.post('/auth/login', { email, password });
    const { accessToken, user } = response.data;
    
    updateAccessToken(accessToken); // Używamy helper
    setUser(user);
  }

  async function logout() {
    try {
      await apiClient.post('/auth/logout');
    } finally {
      updateAccessToken(null);
      setUser(null);
    }
  }

  // ... reszta kodu
}
Login i Rejestracja – komponenty

Login Form

🔍 Login Form - najlepsze praktyki:
  • ✅ autoComplete="email" / "current-password" - pomaga password managerom
  • ✅ disabled={isLoading} - zapobiega podwójnemu submit
  • ✅ try/catch - obsługa błędów (złe hasło, brak połączenia)
  • ✅ navigate po sukcesie - przekieruj do dashboardu
  • ✅ Link do rejestracji - łatwe przejście
// pages/LoginPage.tsx
import { useAuth } from '../contexts/AuthContext';
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';

function LoginPage() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [isLoading, setIsLoading] = useState(false);

  const { login } = useAuth();
  const navigate = useNavigate();

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError('');
    setIsLoading(true);

    try {
      await login(email, password);
      navigate('/dashboard'); // Przekieruj po zalogowaniu
    } catch (err: any) {
      setError(err.response?.data?.message || 'Błąd logowania');
    } finally {
      setIsLoading(false);
    }
  }

  return (
            <div className="login-page">
            <div className="login-card">
            <h1>Logowanie</h1>

        {error && (
            <div className="error-message">
            {error}
          </div>
        )}

            <form onSubmit={handleSubmit}>
            <div className="form-group">
            <label htmlFor="email">Email</label>
            <input id="email"
                   type="email"
                   value={email}
                   onChange={(e) => setEmail(e.target.value)}
              required
              autoComplete="email"
            />
          </div>

            <div className="form-group">
            <label htmlFor="password">Hasło</label>
            <input id="password"
                   type="password"
                   value={password}
                   onChange={(e) => setPassword(e.target.value)}
              required
              autoComplete="current-password"
            />
          </div>

            <button type="submit" disabled={isLoading}>
            {isLoading ? 'Logowanie...' : 'Zaloguj się'}
          </button>
        </form>

            <p className="signup-link">
          Nie masz konta? <Link to="/register">Zarejestruj się</Link>
        </p>
      </div>
    </div>
  );
}

export default LoginPage;

Register Form

💪 Ćwiczenie 2: Dodaj walidację do formularza rejestracji

Rozbuduj RegisterPage o dodatkową walidację:

  1. Email - sprawdź format (regex lub biblioteka)
  2. Hasło - minimum 8 znaków, 1 wielka litera, 1 cyfra
  3. Imię - minimum 2 znaki
  4. Pokaż błędy pod każdym inputem (nie tylko globalny error)
  5. Disable submit jeśli walidacja nie przechodzi
  6. Password strength indicator (słabe/średnie/silne)

Bonus: Użyj React Hook Form + zod!

// pages/RegisterPage.tsx (cd.)
            <div className="form-group">
            <label htmlFor="password">Hasło</label>
            <input id="password"
                   name="password"
                   type="password"
                   value={formData.password}
                   onChange={handleChange}
                   required
                   autoComplete="new-password"
                   minLength={6} />
          </div>

            <div className="form-group">
            <label htmlFor="confirmPassword">Potwierdź hasło</label>
            <input id="confirmPassword"
                   name="confirmPassword"
                   type="password"
                   value={formData.confirmPassword}
                   onChange={handleChange}
                   required
                   autoComplete="new-password" />
          </div>

            <button type="submit" disabled={isLoading}>
            {isLoading ? 'Rejestracja...' : 'Zarejestruj się'}
          </button>
        </form>

            <p className="login-link">
          Masz już konto? <Link to="/login">Zaloguj się</Link>
        </p>
    </div>
</div>
  );
}

export default RegisterPage;
Protected Routes – chronione ścieżki

ProtectedRoute Component

🔍 Protected Route - jak działa?

ProtectedRoute = wrapper który sprawdza autoryzację

// Scenariusz 1: Użytkownik NIEZALOGOWANY
<ProtectedRoute>
        <Dashboard />
</ProtectedRoute>

1. isAuthenticated = false
2. ProtectedRoute: "Nie wolno!"
3. <Navigate to="/login" />
4. Użytkownik ląduje na /login

// Scenariusz 2: Użytkownik ZALOGOWANY
<ProtectedRoute>
        <Dashboard />
</ProtectedRoute>

1. isAuthenticated = true
2. ProtectedRoute: "OK, wejdź"
3. return <>{children}</>
4. Dashboard się renderuje! ✅

// Scenariusz 3: ADMIN route, ale USER
<ProtectedRoute requiredRole="admin">
        <AdminPanel />
</ProtectedRoute>

1. isAuthenticated = true
2. user.role = "user" (nie "admin")
3. ProtectedRoute: "Brak uprawnień!"
4. Pokazuje "Access Denied" 🚫
// components/ProtectedRoute.tsx
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';

interface ProtectedRouteProps {
  children: React.ReactNode;
  requiredRole?: string; // Opcjonalnie: wymagana rola
}

function ProtectedRoute({ children, requiredRole }: ProtectedRouteProps) {
  const { isAuthenticated, isLoading, user } = useAuth();
  const location = useLocation();

  // Podczas ładowania pokaż loader
  if (isLoading) {
    return (
    <div className="loading-screen">
    <div className="spinner">Sprawdzanie autoryzacji...</div>
      </div>
    );
  }

  // Jeśli niezalogowany, przekieruj do login
  if (!isAuthenticated) {
    // Zapisz gdzie użytkownik chciał iść (dla przekierowania po loginie)
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  // Jeśli wymagana rola i użytkownik jej nie ma
  if (requiredRole && user?.role !== requiredRole) {
    return (
    <div className="access-denied">
    <h1>Brak dostępu</h1>
    <p>Nie masz uprawnień do tej strony.</p>
      </div>
    );
  }

  // Wszystko OK - pokaż chronioną stronę
  return <>{children}</>;
}

export default ProtectedRoute;
🔍 state={{ from: location }} - po co?

Problem bez state:

// Użytkownik próbuje wejść na /profile
→ Niezalogowany → redirect /login
→ Loguje się
→ Przekierowanie do... /dashboard (domyślnie)
// Ale chciał na /profile! 😕

Rozwiązanie z state:

// Użytkownik próbuje wejść na /profile
→ Niezalogowany → 
→ Loguje się
→ LoginPage czyta: location.state?.from?.pathname
→ navigate(from) → ląduje na /profile! ✅
// Dokładnie tam gdzie chciał!

Użycie w routingu

🔍 Routing strategy - publiczne vs chronione:

Publiczne routes (login, register):

  • Dostępne dla wszystkich
  • Ale jeśli ZALOGOWANY → redirect do dashboard (po co login gdy już zalogowany?)

Chronione routes (dashboard, profile):

  • Owinięte w <ProtectedRoute>
  • Dostępne TYLKO dla zalogowanych

Admin routes (admin panel):

  • <ProtectedRoute requiredRole="admin">
  • Dostępne TYLKO dla adminów
// App.tsx
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from './contexts/AuthContext';
import ProtectedRoute from './components/ProtectedRoute';
import LoginPage from './pages/LoginPage';
import RegisterPage from './pages/RegisterPage';
import Dashboard from './pages/Dashboard';
import AdminPanel from './pages/AdminPanel';
import ProfilePage from './pages/ProfilePage';

function App() {
  const { isAuthenticated } = useAuth();

  return (
    <BrowserRouter>
    <Routes>
        {/* Publiczne routes */}
    <Route path="/login"
           element={
           isAuthenticated ? <Navigate to="/dashboard" /> : <LoginPage />
          }
        />
        <Route
          path="/register"
          element={
            isAuthenticated ? <Navigate to="/dashboard" /> : <RegisterPage />
          }
        />

        {/* Chronione routes */}
        <Route
          path="/dashboard"
          element={
            <ProtectedRoute>
              <Dashboard />
            </ProtectedRoute>
          }
        />

        <Route
          path="/profile"
          element={
            <ProtectedRoute>
              <ProfilePage />
            </ProtectedRoute>
          }
        />

        {/* Route tylko dla admina */}
        <Route
          path="/admin"
          element={
            <ProtectedRoute requiredRole="admin">
              <AdminPanel />
            </ProtectedRoute>
          }
        />

        {/* Domyślne przekierowanie */}
        <Route
          path="/"
          element={
            <Navigate to={isAuthenticated ? "/dashboard" : "/login"} />
          }
        />

        {/* 404 */}
        <Route path="*" element={<div>Strona nie znaleziona</div>} />
      </Routes>
    </BrowserRouter>
  );
}

export default App;
Przekierowanie po zalogowaniu

Zapisz gdzie użytkownik chciał iść przed logowaniem i przekieruj tam po zalogowaniu.

🌟 Przykład: UX z przekierowaniem
// Scenariusz bez przekierowania (ZŁY UX):
1. Użytkownik klika link do /profile
2. Nie zalogowany → /login
3. Loguje się
4. Ląduje na /dashboard
5. "Gdzie mój profil?" 😕
6. Musi ręcznie przejść do /profile

// Scenariusz z przekierowaniem (DOBRY UX):
1. Użytkownik klika link do /profile
2. Nie zalogowany → /login (z state: { from: '/profile' })
3. Loguje się
4. Automatycznie ląduje na /profile! ✅
5. "Wow, to działa!" 🎉
// pages/LoginPage.tsx (poprawiona wersja)
import { useLocation } from 'react-router-dom';

function LoginPage() {
  const navigate = useNavigate();
  const location = useLocation();
  
  // Pobierz gdzie użytkownik chciał iść (z state przekierowania)
  const from = location.state?.from?.pathname || '/dashboard';

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setIsLoading(true);

    try {
      await login(email, password);
      navigate(from, { replace: true }); // Przekieruj gdzie chciał iść
    } catch (err: any) {
      setError(err.response?.data?.message || 'Błąd logowania');
    } finally {
      setIsLoading(false);
    }
  }

  // ... reszta kodu
}

Scenariusz:

  1. Użytkownik próbuje wejść na /profile
  2. Nie jest zalogowany → przekierowanie do /login z state: { from: '/profile' }
  3. Loguje się
  4. Automatyczne przekierowanie do /profile (tam gdzie chciał!)
Navbar z informacją o użytkowniku

🔍 Navbar - dynamiczny UI dla zalogowanych vs niezalogowanych:

Niezalogowany widzi:

  • Logo
  • Link "Zaloguj się"
  • Link "Zarejestruj się"

Zalogowany widzi:

  • Logo
  • Link "Dashboard"
  • Link "Profil"
  • Awatar + imię
  • Przycisk "Wyloguj"

Admin dodatkowo widzi:

  • Link "Panel admina"
// components/Navbar.tsx
import { Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';

function Navbar() {
  const { user, isAuthenticated, logout } = useAuth();

  async function handleLogout() {
    try {
      await logout();
    } catch (error) {
      console.error('Błąd wylogowania:', error);
    }
  }

  return (
    <nav className="navbar">
    <div className="navbar-brand">
    <Link to="/">MyApp</Link>
      </div>

    <div className="navbar-menu">
        {isAuthenticated ? (
          <>
    <Link to="/dashboard">Dashboard</Link>
    <Link to="/profile">Profil</Link>
            
            {/* Pokaż tylko adminowi */}
            {user?.role === 'admin' && (
    <Link to="/admin">Panel admina</Link>
            )}

    <div className="user-info">
    <img src={user?.avatar} alt={user?.name} />
    <span>{user?.name}</span>
            </div>

    <button onClick={handleLogout}>Wyloguj</button>
          </>
        ) : (
          <>
    <Link to="/login">Zaloguj się</Link>
    <Link to="/register">Zarejestruj się</Link>
          </>
        )}
      </div>
    </nav>
  );
}

export default Navbar;
Role-based Access Control - kontrola dostępu oparta na rolach

Hook do sprawdzania uprawnień

🔍 usePermissions - centralizacja logiki uprawnień:

Zamiast wszędzie pisać:

// ❌ Duplikacja logiki
{user?.role === 'admin' && }
{user?.role === 'admin' && }
{user?.role === 'admin' || user?.role === 'moderator' && ...}

Lepiej:

// ✅ Jeden hook, czytelna logika
const { isAdmin, isModerator, hasPermission } = usePermissions();

{isAdmin && }
{(isAdmin || isModerator) && }
{hasPermission('users.edit') && }
// hooks/usePermissions.ts
import { useAuth } from '../contexts/AuthContext';

export function usePermissions() {
  const { user } = useAuth();

  function hasRole(role: string): boolean {
    return user?.role === role;
  }

  function hasAnyRole(roles: string[]): boolean {
    return roles.some(role => user?.role === role);
  }

  function hasPermission(permission: string): boolean {
    // Jeśli backend wysyła listę permissions w user
    return user?.permissions?.includes(permission) || false;
  }

  return {
    hasRole,
    hasAnyRole,
    hasPermission,
    isAdmin: user?.role === 'admin',
    isModerator: user?.role === 'moderator',
    isUser: user?.role === 'user',
  };
}

Conditional rendering based on role

🌟 Przykład: Różne UI dla różnych ról
function Dashboard() {
  const { isAdmin, isModerator, isUser } = usePermissions();

  return (
        <div>
        <h1>Dashboard</h1>

      {/* Wszyscy widzą */}
        <section>
        <h2>Twoje statystyki</h2>
        <StatsWidget />
      </section>

      {/* Tylko moderatorzy i admini */}
      {(isModerator || isAdmin) && (
        <section>
        <h2>Zgłoszenia użytkowników</h2>
        <ReportsPanel />
        </section>
      )}

      {/* Tylko admini */}
      {isAdmin && (
        <section>
        <h2>Panel administracyjny</h2>
        <AdminControls />
        <button onClick={handleDeleteAllUsers}>
            Usuń wszystkich użytkowników
          </button>
        </section>
      )}

      {/* Tylko zwykli użytkownicy (nie admini) */}
      {isUser && !isAdmin && (
        <section>
        <h2>Reklamy</h2>
        <AdsPanel />
        </section>
      )}
    </div>
  );
}
// components/AdminButton.tsx
import { usePermissions } from '../hooks/usePermissions';

function Dashboard() {
  const { isAdmin, hasPermission } = usePermissions();

  return (
    <div>
    <h1>Dashboard</h1>

      {/* Pokaż tylko adminowi */}
      {isAdmin && (
    <button onClick={handleDeleteAllUsers}>
          Usuń wszystkich użytkowników
        </button>
      )}

      {/* Pokaż gdy ma permission */}
      {hasPermission('users.edit') && (
    <button onClick={handleEditUser}>
          Edytuj użytkownika
        </button>
      )}

      {/* Pokaż wszystkim */}
    <button onClick={handleSave}>Zapisz</button>
    </div>
  );
}
💪 Ćwiczenie 3: Role-based Dashboard

Stwórz Dashboard z różnymi widokami dla ról:

  1. Admin widzi:
    • Wszystkich użytkowników (lista z opcją usuwania)
    • Statystyki systemu
    • Logi systemowe
  2. Moderator widzi:
    • Zgłoszenia użytkowników
    • Statystyki moderacji
    • Może banować użytkowników
  3. User widzi:
    • Swój profil
    • Swoje posty
    • Reklamy (admini ich nie widzą!)

Użyj usePermissions() + conditional rendering!

Security Best Practices - najlepsze praktyki bezpieczeństwa

1. HTTPS only

// ✅ Zawsze używaj HTTPS w produkcji
const apiClient = axios.create({
  baseURL: 'https://api.example.com', // HTTPS!
  withCredentials: true,
});

2. HttpOnly cookies dla refresh token

// ✅ Serwer ustawia httpOnly
Response.Cookies.Append("refreshToken", token, new CookieOptions
{
    HttpOnly = true,      // JavaScript nie ma dostępu
    Secure = true,        // Tylko przez HTTPS
    SameSite = SameSiteMode.Strict, // Ochrona przed CSRF
    Expires = DateTimeOffset.UtcNow.AddDays(7)
});

3. Access token w memory, NIE w localStorage

// ✅ Token w pamięci (zmiennej)
let accessToken: string | null = null;

// ❌ NIE robić tego w produkcji
localStorage.setItem('accessToken', token);

4. Krótki czas życia access token

// ✅ Access token wygasa szybko (15 min)
var accessToken = new JwtSecurityToken(
    expires: DateTime.UtcNow.AddMinutes(15),
    // ...
);

// Refresh token żyje dłużej (7 dni)
var refreshToken = GenerateRefreshToken(user, expiresInDays: 7);

5. Token rotation

🔍 Token Rotation - dlaczego ważne?

Bez rotation:

  • Refresh Token = ten sam przez 7 dni
  • Jeśli ukradną → mają dostęp przez 7 dni! 😱

Z rotation:

  • Przy każdym /refresh → NOWY Refresh Token
  • Stary token → invalidate (nieważny)
  • Jeśli ukradną stary → nie zadziała (już invalidated)!
  • Dodatkowo: wykrywamy atak (ktoś używa starego tokenu)
// ✅ Nowy refresh token przy każdym odświeżeniu
[HttpPost("refresh")]
public async Task RefreshToken()
{
    var oldRefreshToken = Request.Cookies["refreshToken"];
    
    // Invalidate stary token
    await _authService.RevokeRefreshToken(oldRefreshToken);
    
    // Wygeneruj NOWY refresh token
    var newRefreshToken = _authService.GenerateRefreshToken(user);
    
    Response.Cookies.Append("refreshToken", newRefreshToken, ...);
    
    return Ok(new { accessToken = newAccessToken });
}
Kompletny flow - krok po kroku
🔍 Cały flow autentykacji - timeline:
🏁 START - użytkownik otwiera aplikację

1. AuthProvider montuje się
   → useEffect wywołuje checkAuth()
   → POST /auth/refresh (próba odświeżenia)
   
   Scenariusz A: Ma ważny Refresh Token
   → Serwer: zwraca Access Token + user
   → setState: accessToken, user
   → Użytkownik ZALOGOWANY! ✅
   
   Scenariusz B: Brak/nieważny Refresh Token
   → Serwer: 401
   → setState: null, null
   → Użytkownik NIEZALOGOWANY ❌

2. Routing sprawdza isAuthenticated
   → Zalogowany? → /dashboard
   → Niezalogowany? → /login

3. Użytkownik loguje się
   → POST /auth/login { email, password }
   → Serwer: Access Token + Refresh Token (cookie)
   → setState: accessToken, user
   → navigate('/dashboard')

4. Użytkownik klika "Pokaż zamówienia"
   → GET /api/orders
   → Interceptor: dodaje "Authorization: Bearer ..."
   → Serwer: 200 OK + dane
   → Pokazuje zamówienia

5. 15 minut później... Access Token wygasł
   → GET /api/orders
   → Serwer: 401 Unauthorized
   → Response Interceptor:
      → POST /auth/refresh (Refresh Token w cookie)
      → Serwer: nowy Access Token
      → Ponawia GET /api/orders
      → 200 OK + dane
   → Użytkownik NIC nie zauważył! ✨

6. Użytkownik klika "Wyloguj"
   → POST /auth/logout
   → Serwer: usuwa Refresh Token
   → setState: null, null
   → navigate('/login')

🏁 KONIEC

1. Użytkownik otwiera aplikację

// AuthProvider sprawdza czy jest zalogowany
useEffect(() => {
  checkAuth(); // Próbuje odświeżyć token
}, []);

2. Login

Frontend: POST /auth/login { email, password }
Backend: 
  - Weryfikuje dane
  - Generuje accessToken (15 min)
  - Generuje refreshToken (7 dni)
  - Ustawia refreshToken w httpOnly cookie
  - Zwraca { accessToken, user }
Frontend: 
  - Zapisuje accessToken w memory
  - Zapisuje user w state
  - Przekierowuje do /dashboard

3. Request do chronionego endpoint

Frontend: GET /api/orders
Axios interceptor: Dodaje header "Authorization: Bearer "
Backend: Weryfikuje token, zwraca dane
Frontend: Wyświetla dane

4. Access token wygasa (po 15 min)

Frontend: GET /api/orders
Backend: 401 Unauthorized (token wygasł)
Axios interceptor:
  - Przechwytuje 401
  - POST /auth/refresh (z refreshToken w cookie)
  Backend:
    - Sprawdza refreshToken
    - Generuje nowy accessToken
    - (Opcjonalnie) Nowy refreshToken
    - Zwraca { accessToken }
  - Zapisuje nowy accessToken
  - Ponawia oryginalny request GET /api/orders
Backend: Zwraca dane
Frontend: Wyświetla dane (użytkownik NIC nie zauważył!)

5. Wylogowanie

Frontend: POST /auth/logout
Backend: 
  - Invalidate refreshToken
  - Usuwa cookie
Frontend:
  - Czyści accessToken (null)
  - Czyści user (null)
  - Przekierowuje do /login
Podsumowanie

Zbudowaliśmy kompletny system autentykacji! Nauczyliśmy się:

  • JWT Tokens – jak działają, access vs refresh
  • Przechowywanie tokenów – memory + httpOnly cookies (hybrid)
  • AuthContext – zarządzanie stanem autentykacji
  • Axios Interceptors – automatyczne dodawanie tokenów
  • Login i Rejestracja – formularze z walidacją
  • Protected Routes – ochrona stron przed niezalogowanymi
  • Role-based access – różne uprawnienia dla ról
  • Refresh Token Mechanism – automatyczne odświeżanie
  • Security best practices – 5 zasad bezpieczeństwa
  • Kompletny flow – cały proces krok po kroku

Twoja aplikacja jest teraz bezpieczna! Użytkownicy mogą się logować, pozostawać zalogowani, a tokeny są automatycznie odświeżane w tle.

W kolejnym wpisie (ostatnim w serii!) zbudujemy kompletną Full Stack aplikację łączącą wszystko czego się nauczyliśmy – React + TypeScript frontend z .NET Core API backend, CRUD operations, autentykacja, React Query, deployed na żywo!

Zadanie dla Ciebie

Zaimplementuj autentykację w swojej aplikacji:

  1. Stwórz AuthContext z login/register/logout
  2. Dodaj Axios interceptors z automatycznym refresh
  3. Zbuduj strony Login i Register z walidacją
  4. Dodaj ProtectedRoute dla chronionych stron
  5. Zaimplementuj navbar z informacją o użytkowniku
  6. (Bonus) Dodaj role-based access control
  7. (Bonus) Zaimplementuj idle timer (session timeout)
🎯 BONUS: Kompletny projekt - Secure Task Manager

Zbuduj zabezpieczoną aplikację do zarządzania zadaniami:

Features:

  • ✅ Rejestracja + Login z walidacją
  • ✅ AuthContext + Axios interceptors
  • ✅ Protected routes (Dashboard, Profile, Admin Panel)
  • ✅ Zarządzanie zadaniami (CRUD)
  • ✅ 3 role: Admin, Manager, User
  • ✅ Admin widzi wszystkie zadania
  • ✅ Manager może przypisywać zadania
  • ✅ User widzi tylko swoje zadania
  • ✅ Automatic token refresh
  • ✅ Session timeout (wyloguj po 30 min bezczynności)

Security requirements:

  • 🔒 Access Token w memory (15 min)
  • 🔒 Refresh Token w httpOnly cookie (7 dni)
  • 🔒 Token rotation przy każdym refresh
  • 🔒 HTTPS tylko
  • 🔒 Password min 8 znaków + 1 wielka + 1 cyfra

To production-ready aplikacja z pełnym bezpieczeństwem! Po ukończeniu tego projektu będziesz gotowy na prawdziwe projekty komercyjne! 🚀🔐