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;
}
}
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).');
}
}
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;
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_hashprzy rejestracji,password_verifyprzy logowaniu - [x]
session_regenerate_id(true)po pomyślnym logowaniu - [x]
$_SESSION['user_id']sprawdzane na każdej chronionej stronie +exitpoheader() - [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_idw każdym zapytaniuWHERE— 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ć:
- Edycja tytułu — formularz edycji z tokenem CSRF i metodą
aktualizujTytul(int $id, int $userId, string $tytul)w repozytorium. - Paginacja — gdy zadań jest wiele, pokaż np. po 10 przez
LIMIT 10 OFFSET :offsetw SQL. - Filtrowanie — osobny widok aktywnych/ukończonych przez
WHERE ukonczone = 0lub= 1. - Data wykonania — kolumna
deadline DATEw tabeli zadania z formularzem daty. - Zmiana hasła — formularz wymagający podania starego hasła (
password_verify) i nowego (password_hash). - Usunięcie konta — DELETE z kaskadą na zadania, wylogowanie.
🚀 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.
Funkcje w tej lekcji
Sprawdź się
W którym momencie przepływu logowania wywołujemy session_regenerate_id(true)?
session_regenerate_id(true) wywołujemy po weryfikacji przez password_verify(), ale przed zapisem danych do sesji. Nowe ID sesji zrywa połączenie między ewentualnym napastnikiem a sesją ofiary (ochrona przed session fixation).
Dlaczego każdy formularz CRUD (dodaj zadanie, usuń, ukończ) musi mieć token CSRF?
CSRF (Cross-Site Request Forgery) dotyczy każdej akcji modyfikującej dane. Atakujący mógłby zmusić zalogowanego użytkownika do nieświadomego dodania lub usunięcia zadania. Token powiązany z sesją sprawia, że żądanie z zewnętrznej strony zostanie odrzucone.
Dlaczego przy pobieraniu zadań danego użytkownika używamy WHERE user_id = :user_id zamiast pobierać wszystkie i filtrować w PHP?
Filtrowanie w SQL (WHERE user_id = :user_id) jest fundamentem izolacji danych. Gdybyś pobrał wszystkie zadania i filtrował w PHP, jeden błąd logiczny mógłby ujawnić zadania innych użytkowników. Filtruj zawsze jak najbliżej danych.
Ć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;