Projekt końcowy cz. 2 — logowanie, sesje i CRUD zadań

🎯 Po tej lekcji będziesz mieć działającą aplikację z logowaniem, wylogowaniem, ochroną tras i CRUD zadań — spinającą OOP, PDO i wszystkie techniki bezpieczeństwa z modułu.

W poprzedniej lekcji zbudowałeś fundament: bazę danych i rejestrację. Teraz dodamy logowanie, ochronę tras i pełne zarządzanie zadaniami. Na koniec tej lekcji masz działającą aplikację webową.

Krok 1 — klasa ZadanieRepozytorium

<?php
// src/ZadanieRepozytorium.php
declare(strict_types=1);

final class ZadanieRepozytorium
{
    public function __construct(
        private readonly PDO $pdo,
    ) {}

    /**
     * Zwraca wszystkie zadania danego użytkownika (od najnowszego).
     *
     * @return array<int,array<string,mixed>>
     */
    public function wszystkieUzytkownika(int $userId): array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, tytul, ukonczone, created_at
               FROM zadania
              WHERE user_id = :uid
           ORDER BY id DESC'
        );
        $stmt->execute([':uid' => $userId]);

        return $stmt->fetchAll();
    }

    /**
     * Dodaje zadanie. Zwraca id nowego rekordu.
     */
    public function dodaj(int $userId, string $tytul): int
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO zadania (user_id, tytul) VALUES (:uid, :tytul)'
        );
        $stmt->execute([':uid' => $userId, ':tytul' => $tytul]);

        return (int) $this->pdo->lastInsertId();
    }

    /**
     * Oznacza zadanie jako ukończone — tylko jeśli należy do danego użytkownika.
     */
    public function oznaczUkonczone(int $id, int $userId): bool
    {
        $stmt = $this->pdo->prepare(
            'UPDATE zadania SET ukonczone = 1 WHERE id = :id AND user_id = :uid'
        );
        $stmt->execute([':id' => $id, ':uid' => $userId]);

        return $stmt->rowCount() > 0;
    }

    /**
     * Usuwa zadanie — tylko jeśli należy do danego użytkownika.
     */
    public function usun(int $id, int $userId): bool
    {
        $stmt = $this->pdo->prepare(
            'DELETE FROM zadania WHERE id = :id AND user_id = :uid'
        );
        $stmt->execute([':id' => $id, ':uid' => $userId]);

        return $stmt->rowCount() > 0;
    }
}
⚠️ Izolacja danych — AND user_id = :uid: Każda metoda modyfikująca dane sprawdza zarówno id zadania jak i user_id właściciela. Bez tego warunek zalogowany użytkownik mógłby przez manipulację POST usunąć zadanie innego użytkownika (Insecure Direct Object Reference — IDOR).

Krok 2 — pomocnicze funkcje (auth.php)

<?php
// src/auth.php — wczytaj przez require w każdym pliku
declare(strict_types=1);

/**
 * Sprawdza czy użytkownik jest zalogowany. Jeśli nie — przekierowuje.
 */
function wymagajLogowania(): void
{
    if (!isset($_SESSION['user_id'])) {
        header('Location: /logowanie.php');
        exit;
    }
}

/**
 * Generuje token CSRF i zapisuje w sesji jeśli jeszcze nie istnieje.
 */
function pobierzTokenCsrf(): string
{
    if (empty($_SESSION['csrf_token'])) {
        $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }

    return $_SESSION['csrf_token'];
}

/**
 * Weryfikuje token CSRF z POST. Kończy żądanie jeśli nieprawidłowy.
 */
function sprawdzCsrf(): void
{
    $tokenZFormularza = $_POST['csrf_token'] ?? '';
    $tokenZSesji      = $_SESSION['csrf_token'] ?? '';

    if (!hash_equals($tokenZSesji, $tokenZFormularza)) {
        http_response_code(403);
        exit('Nieprawidłowe żądanie (błąd CSRF).');
    }
}
💡 Token per-sesję czy per-żądanie? Lekcja 02 regeneruje token po każdym udanym POST — to strategia per-żądanie (ciaśniejsza: zużyty token staje się nieważny). Projekt używa tokenu per-sesję (prostszy: jeden token na cały czas trwania sesji). Obie strategie są poprawne — wybierz tę, która pasuje do wymagań aplikacji.

Teraz gdy masz pobierzTokenCsrf() i sprawdzCsrf() jako współdzielone helpery, zaktualizuj też rejestracja.php z części 1 — zastąp jej lokalny kod obsługi CSRF wywołaniami tych funkcji, żeby cała aplikacja działała spójnie.

Krok 3 — logowanie (logowanie.php)

<?php
// public/logowanie.php
declare(strict_types=1);

session_start();

if (isset($_SESSION['user_id'])) {
    header('Location: /zadania.php');
    exit;
}

require dirname(__DIR__) . '/src/Db.php';
require dirname(__DIR__) . '/src/UzytkownikRepozytorium.php';
require dirname(__DIR__) . '/src/auth.php';

$blad  = '';
$login = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    sprawdzCsrf(); // ✅ CSRF check — zawsze pierwsze

    $login = trim($_POST['login'] ?? '');
    $haslo = $_POST['haslo']     ?? '';

    $pdo  = Db::polaczenie();
    $repo = new UzytkownikRepozytorium($pdo);
    $user = $repo->weryfikuj($login, $haslo);

    if ($user !== null) {
        // ✅ Ochrona przed session fixation — nowe ID po zalogowaniu
        session_regenerate_id(true);

        $_SESSION['user_id']   = $user['id'];
        $_SESSION['user_name'] = $user['login'];

        header('Location: /zadania.php');
        exit;
    }

    $blad = 'Nieprawidłowy login lub hasło.';
}

$csrfToken = pobierzTokenCsrf();
?>
<!DOCTYPE html>
<html lang="pl">
<head><meta charset="UTF-8"><title>Logowanie — Moje Zadania</title></head>
<body>
<h1>Logowanie</h1>

<?php if ($blad !== ''): ?>
    <p style="color:red"><?= htmlspecialchars($blad, ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>

<form method="post" action="">
    <input type="hidden" name="csrf_token"
           value="<?= htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8') ?>">
    <p>
        <label>Login:<br>
        <input type="text" name="login"
               value="<?= htmlspecialchars($login, ENT_QUOTES, 'UTF-8') ?>">
        </label>
    </p>
    <p>
        <label>Hasło:<br>
        <input type="password" name="haslo">
        </label>
    </p>
    <button type="submit">Zaloguj się</button>
</form>
<p><a href="/rejestracja.php">Nie masz konta? Zarejestruj się</a></p>
</body>
</html>

Krok 4 — wylogowanie (wylogowanie.php)

<?php
// public/wylogowanie.php
declare(strict_types=1);

session_start();
$_SESSION = [];
session_destroy();

header('Location: /logowanie.php');
exit;

Krok 5 — strona zadań (zadania.php)

<?php
// public/zadania.php
declare(strict_types=1);

session_start();

require dirname(__DIR__) . '/src/Db.php';
require dirname(__DIR__) . '/src/ZadanieRepozytorium.php';
require dirname(__DIR__) . '/src/auth.php';

// 🔒 Ochrona trasy — tylko zalogowani
wymagajLogowania();

$userId   = (int) $_SESSION['user_id'];
$userName = (string) ($_SESSION['user_name'] ?? '');

$pdo  = Db::polaczenie();
$repo = new ZadanieRepozytorium($pdo);

// Obsługa dodawania nowego zadania
$blad = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['akcja']) && $_POST['akcja'] === 'dodaj') {
    sprawdzCsrf();

    $tytul = trim($_POST['tytul'] ?? '');
    if ($tytul === '') {
        $blad = 'Tytuł zadania jest wymagany.';
    } else {
        $repo->dodaj($userId, $tytul);
        header('Location: /zadania.php');
        exit;
    }
}

$zadania   = $repo->wszystkieUzytkownika($userId);
$csrfToken = pobierzTokenCsrf();
?>
<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Moje Zadania</title>
    <style>
        body        { font-family: sans-serif; max-width: 700px; margin: 2rem auto; padding: 0 1rem; }
        .ukonczone  { text-decoration: line-through; color: #888; }
        .akcje      { display: inline-flex; gap: .5rem; }
    </style>
</head>
<body>
<h1>Moje Zadania</h1>
<p>Cześć, <strong><?= htmlspecialchars($userName, ENT_QUOTES, 'UTF-8') ?></strong>!
   <a href="/wylogowanie.php">Wyloguj</a></p>

<?php if ($blad !== ''): ?>
    <p style="color:red"><?= htmlspecialchars($blad, ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>

<h2>Dodaj zadanie</h2>
<form method="post" action="">
    <input type="hidden" name="csrf_token"
           value="<?= htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8') ?>">
    <input type="hidden" name="akcja" value="dodaj">
    <label>Tytuł:
        <input type="text" name="tytul" size="40" required>
    </label>
    <button type="submit">Dodaj</button>
</form>

<h2>Lista zadań (<?= count($zadania) ?>)</h2>

<?php if ($zadania === []): ?>
    <p>Brak zadań. Dodaj pierwsze!</p>
<?php else: ?>
    <ul>
    <?php foreach ($zadania as $z): ?>
        <li>
            <span class="<?= $z['ukonczone'] ? 'ukonczone' : '' ?>">
                <?= htmlspecialchars($z['tytul'], ENT_QUOTES, 'UTF-8') ?>
            </span>
            <span class="akcje">
                <?php if (!$z['ukonczone']): ?>
                <form method="post" action="/zadanie-akcja.php" style="display:inline">
                    <input type="hidden" name="csrf_token"
                           value="<?= htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8') ?>">
                    <input type="hidden" name="akcja" value="ukoncz">
                    <input type="hidden" name="id"    value="<?= (int) $z['id'] ?>">
                    <button type="submit">✓ Ukończ</button>
                </form>
                <?php endif; ?>
                <form method="post" action="/zadanie-akcja.php" style="display:inline">
                    <input type="hidden" name="csrf_token"
                           value="<?= htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8') ?>">
                    <input type="hidden" name="akcja" value="usun">
                    <input type="hidden" name="id"    value="<?= (int) $z['id'] ?>">
                    <button type="submit"
                            onclick="return confirm('Usunąć to zadanie?')">✕ Usuń</button>
                </form>
            </span>
        </li>
    <?php endforeach; ?>
    </ul>
<?php endif; ?>

</body>
</html>

Krok 6 — handler akcji (zadanie-akcja.php)

<?php
// public/zadanie-akcja.php — obsługuje tylko POST
declare(strict_types=1);

session_start();

require dirname(__DIR__) . '/src/Db.php';
require dirname(__DIR__) . '/src/ZadanieRepozytorium.php';
require dirname(__DIR__) . '/src/auth.php';

wymagajLogowania();
sprawdzCsrf(); // ✅ CSRF check — przed jakąkolwiek akcją

$userId = (int) $_SESSION['user_id'];
$id     = (int) ($_POST['id']    ?? 0);
$akcja  = $_POST['akcja'] ?? '';

$pdo  = Db::polaczenie();
$repo = new ZadanieRepozytorium($pdo);

if ($akcja === 'ukoncz') {
    $repo->oznaczUkonczone($id, $userId);
} elseif ($akcja === 'usun') {
    $repo->usun($id, $userId);
}

header('Location: /zadania.php');
exit;
💡 Jeden plik akcji: Zamiast osobnych plików usun.php, ukoncz.php — jeden plik zadanie-akcja.php obsługuje wszystkie akcje POST. To czyste podejście — łatwo dodać nową akcję bez nowego pliku. Wszystkie mają wspólne sprawdzenie CSRF i ochronę trasy.

Bezpieczeństwo — lista kontrolna

Przejrzyj aplikację i sprawdź:

  • [x] password_hash przy rejestracji, password_verify przy logowaniu
  • [x] session_regenerate_id(true) po pomyślnym logowaniu
  • [x] $_SESSION['user_id'] sprawdzane na każdej chronionej stronie + exit po header()
  • [x] Token CSRF w każdym formularzu POST, weryfikowany przez hash_equals()
  • [x] Każde zapytanie SQL z danymi użytkownika przez prepared statements (prepare + execute)
  • [x] Każde wyjście danych przez htmlspecialchars(..., ENT_QUOTES, 'UTF-8')
  • [x] user_id w każdym zapytaniu WHERE — nie możesz modyfikować cudzych danych
  • [x] Hasła do bazy w .env, poza repozytorium

Rozbuduj sam

Masz działającą aplikację — oto co możesz dodać:

  1. Edycja tytułu — formularz edycji z tokenem CSRF i metodą aktualizujTytul(int $id, int $userId, string $tytul) w repozytorium.
  2. Paginacja — gdy zadań jest wiele, pokaż np. po 10 przez LIMIT 10 OFFSET :offset w SQL.
  3. Filtrowanie — osobny widok aktywnych/ukończonych przez WHERE ukonczone = 0 lub = 1.
  4. Data wykonania — kolumna deadline DATE w tabeli zadania z formularzem daty.
  5. Zmiana hasła — formularz wymagający podania starego hasła (password_verify) i nowego (password_hash).
  6. Usunięcie konta — DELETE z kaskadą na zadania, wylogowanie.
💡 Gratulacje! Właśnie zbudowałeś swoją pierwszą pełną aplikację PHP z kontami użytkowników, sesją, bazą danych i wszystkimi kluczowymi zabezpieczeniami. To solidny fundament pod każdą aplikację webową.

🚀 Pokaż swój projekt światu

Twoja aplikacja działa na localhost — czas, żeby zobaczyli ją inni. Potrzebujesz hostingu z PHP 8 i bazą MySQL. Zanim wybierzesz, warto porównać oferty pod kątem ceny, wersji PHP i limitów.

Porównaj hostingi pod PHP na jakwybrachosting.pl →

Funkcje w tej lekcji

Sprawdź się

W którym momencie przepływu logowania wywołujemy session_regenerate_id(true)?

Dlaczego każdy formularz CRUD (dodaj zadanie, usuń, ukończ) musi mieć token CSRF?

Dlaczego przy pobieraniu zadań danego użytkownika używamy WHERE user_id = :user_id zamiast pobierać wszystkie i filtrować w PHP?

Ćwiczenie

Rozbuduj aplikację: dodaj kolumnę 'opis' (TEXT, domyślnie NULL) do tabeli zadania i zaktualizuj ZadanieRepozytorium::dodaj() aby przyjmowała opcjonalny opis. Wyświetl opis pod tytułem zadania — jeśli nie jest pusty. Pamiętaj o htmlspecialchars przy wyświetlaniu.

Pokaż rozwiązanie
-- Migracja SQL:
ALTER TABLE zadania ADD COLUMN opis TEXT NULL AFTER tytul;

<?php
// W ZadanieRepozytorium — nowa sygnatura dodaj():
public function dodaj(int $userId, string $tytul, string $opis = ''): int
{
    $stmt = $this->pdo->prepare(
        'INSERT INTO zadania (user_id, tytul, opis) VALUES (:uid, :tytul, :opis)'
    );
    $stmt->execute([':uid' => $userId, ':tytul' => $tytul, ':opis' => $opis]);
    return (int) $this->pdo->lastInsertId();
}

// W szablonie HTML:
foreach ($zadania as $z):
    echo '<li>';
    echo htmlspecialchars($z['tytul'], ENT_QUOTES, 'UTF-8');
    if (!empty($z['opis'])) {
        echo '<br><small>' . htmlspecialchars($z['opis'], ENT_QUOTES, 'UTF-8') . '</small>';
    }
    echo '</li>';
endforeach;