Projekt końcowy cz. 1 — baza danych i rejestracja

🎯 Po tej lekcji postawisz szkielet aplikacji z bazą danych, klasą połączenia PDO i działającą rejestracją użytkownika z bezpiecznym hashowaniem hasła.

Czas połączyć wszystko w jedną spójną aplikację. Przez następne dwie lekcje zbudujesz Moje Zadania — aplikację z kontami użytkowników, logowaniem i listą zadań. Każdy użytkownik widzi tylko swoje zadania.

Ta lekcja to fundament: baza danych i rejestracja. Lekcja 6 doda logowanie, CRUD i pełne zabezpieczenia.

Struktura katalogów

moje-zadania/
├── public/
│   ├── index.php          ← przekierowanie na /zadania lub /logowanie
│   ├── rejestracja.php
│   ├── logowanie.php
│   ├── wylogowanie.php
│   ├── zadania.php        ← lista zadań (strona chroniona)
│   └── zadanie-akcja.php  ← dodaj/usuń/ukończ (POST only)
├── src/
│   ├── Db.php                    ← klasa połączenia PDO
│   └── UzytkownikRepozytorium.php ← dostęp do tabeli users
├── .env                   ← sekrety (w .gitignore)
├── .env.example           ← szablon (commituj)
└── .gitignore

Krok 1 — schemat bazy danych

Utwórz bazę i dwie tabele w MySQL:

-- Utwórz bazę danych
CREATE DATABASE IF NOT EXISTS zadania_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE zadania_db;

-- Tabela użytkowników
CREATE TABLE users (
    id         INT AUTO_INCREMENT PRIMARY KEY,
    login      VARCHAR(60)  NOT NULL UNIQUE,
    haslo_hash VARCHAR(255) NOT NULL,             -- bcrypt = 60 znaków; argon2id = 96+
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Tabela zadań — powiązana z users przez klucz obcy
CREATE TABLE zadania (
    id         INT AUTO_INCREMENT PRIMARY KEY,
    user_id    INT          NOT NULL,
    tytul      VARCHAR(200) NOT NULL,
    ukonczone  TINYINT(1)   NOT NULL DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

ON DELETE CASCADE — gdy usuniesz użytkownika, jego zadania zostaną automatycznie usunięte.

Krok 2 — sekrety w .env

# .env — NIGDY nie commituj!
DB_HOST=localhost
DB_NAME=zadania_db
DB_USER=root
DB_PASS=TwojeHasloDoMySql
# .env.example — commituj jako szablon
DB_HOST=localhost
DB_NAME=zadania_db
DB_USER=
DB_PASS=
# .gitignore
.env

Krok 3 — klasa Db (połączenie PDO)

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

final class Db
{
    private static ?PDO $instance = null;

    public static function polaczenie(): PDO
    {
        if (self::$instance === null) {
            $envSciezka = dirname(__DIR__) . '/.env';

            if (!file_exists($envSciezka)) {
                throw new RuntimeException('Brak pliku .env');
            }

            $config = parse_ini_file($envSciezka);
            if ($config === false) {
                throw new RuntimeException('Błąd odczytu .env');
            }

            self::$instance = new PDO(
                dsn: 'mysql:host=' . $config['DB_HOST']
                   . ';dbname=' . $config['DB_NAME']
                   . ';charset=utf8mb4',
                username: $config['DB_USER'],
                password: $config['DB_PASS'],
                options: [
                    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
                    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                    PDO::ATTR_EMULATE_PREPARES   => false,
                ],
            );
        }

        return self::$instance;
    }

    // Blokuj klonowanie i tworzenie instancji
    private function __construct() {}
    private function __clone() {}
}

Wzorzec Singleton — połączenie PDO tworzone jest raz i reużywane przez cały czas życia żądania.

Krok 4 — klasa UzytkownikRepozytorium

To ten sam wzorzec repozytorium, który budowałeś w Module 8 (lekcja „Repozytorium" — ZadanieRepozytorium), teraz zastosowany dla kont użytkowników. W lekcji 6 dołączy ZadanieRepozytorium i całość stanie się kompletną aplikacją.

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

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

    /**
     * Rejestruje nowego użytkownika. Zwraca id lub null gdy login zajęty.
     */
    public function zarejestruj(string $login, string $haslo): ?int
    {
        // Sprawdź czy login już istnieje
        $stmt = $this->pdo->prepare(
            'SELECT COUNT(*) FROM users WHERE login = :login'
        );
        $stmt->execute([':login' => $login]);

        if ((int) $stmt->fetchColumn() > 0) {
            return null; // Login zajęty
        }

        // Hashuj hasło — ZAWSZE przez password_hash, NIGDY md5/sha1
        $hashHasla = password_hash($haslo, PASSWORD_DEFAULT);

        $stmt = $this->pdo->prepare(
            'INSERT INTO users (login, haslo_hash) VALUES (:login, :hash)'
        );
        $stmt->execute([':login' => $login, ':hash' => $hashHasla]);

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

    /**
     * Weryfikuje login i hasło. Zwraca tablicę użytkownika lub null.
     *
     * @return array<string,mixed>|null
     */
    public function weryfikuj(string $login, string $haslo): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, login, haslo_hash FROM users WHERE login = :login'
        );
        $stmt->execute([':login' => $login]);
        $user = $stmt->fetch();

        if ($user === false) {
            return null; // Login nie istnieje — nie mów o tym użytkownikowi
        }

        if (!password_verify($haslo, $user['haslo_hash'])) {
            return null; // Złe hasło
        }

        return $user;
    }
}

Krok 5 — formularz rejestracji

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

session_start();

// Jeśli już zalogowany — idź do zadań
if (isset($_SESSION['user_id'])) {
    header('Location: /zadania.php');
    exit;
}

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

$bledy  = [];
$sukces = false;
$login  = '';

// Generuj token CSRF przy pierwszym dostępie do strony
if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // ✅ Weryfikacja tokenu CSRF — pierwsze co robimy w każdym handlerze POST
    $tokenZFormularza = $_POST['csrf_token'] ?? '';
    if (!hash_equals($_SESSION['csrf_token'], $tokenZFormularza)) {
        http_response_code(403);
        exit('Nieprawidłowe żądanie (błąd CSRF).');
    }

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

    // Walidacja
    if (strlen($login) < 3) {
        $bledy[] = 'Login musi mieć co najmniej 3 znaki.';
    }
    if (strlen($haslo) < 8) {
        $bledy[] = 'Hasło musi mieć co najmniej 8 znaków.';
    }
    if ($haslo !== $haslo2) {
        $bledy[] = 'Hasła nie są identyczne.';
    }

    if ($bledy === []) {
        $pdo  = Db::polaczenie();
        $repo = new UzytkownikRepozytorium($pdo);
        $id   = $repo->zarejestruj($login, $haslo);

        if ($id === null) {
            $bledy[] = 'Ten login jest już zajęty.';
        } else {
            $sukces = true;
        }
    }
}
?>
<!DOCTYPE html>
<html lang="pl">
<head><meta charset="UTF-8"><title>Rejestracja — Moje Zadania</title></head>
<body>
<h1>Rejestracja</h1>

<?php if ($sukces): ?>
    <p style="color:green">Konto utworzone! <a href="/logowanie.php">Zaloguj się</a></p>
<?php else: ?>

    <?php if ($bledy !== []): ?>
        <ul style="color:red">
        <?php foreach ($bledy as $blad): ?>
            <li><?= htmlspecialchars($blad, ENT_QUOTES, 'UTF-8') ?></li>
        <?php endforeach; ?>
        </ul>
    <?php endif; ?>

    <form method="post" action="">
        <input type="hidden" name="csrf_token"
               value="<?= htmlspecialchars($_SESSION['csrf_token'], ENT_QUOTES, 'UTF-8') ?>">
        <p>
            <label>Login (min. 3 znaki):<br>
            <input type="text" name="login"
                   value="<?= htmlspecialchars($login, ENT_QUOTES, 'UTF-8') ?>">
            </label>
        </p>
        <p>
            <label>Hasło (min. 8 znaków):<br>
            <input type="password" name="haslo">
            </label>
        </p>
        <p>
            <label>Powtórz hasło:<br>
            <input type="password" name="haslo2">
            </label>
        </p>
        <button type="submit">Zarejestruj się</button>
    </form>
    <p><a href="/logowanie.php">Masz konto? Zaloguj się</a></p>
<?php endif; ?>
</body>
</html>
💡 Dlaczego nie eskejpujemy hasła? Pole type="password" nie wyświetla wartości — nie wstawiamy jej w atrybucie value. Hasła nigdy nie trafiają z powrotem do HTML (tylko do bazy przez hash). Jeśli walidacja się nie powiedzie, pole hasła jest puste — użytkownik wpisuje je od nowa.

Jak to przetestować

  1. Uruchom serwer: php -S localhost:8000 -t public
  2. Otwórz http://localhost:8000/rejestracja.php
  3. Zarejestruj użytkownika
  4. Sprawdź bazę — w tabeli users powinien pojawić się rekord z loginem i hashem bcrypt (zaczynającym się od $2y$)
SELECT id, login, LEFT(haslo_hash, 10) AS hash_prefix FROM users;
-- id | login | hash_prefix
-- 1  | jan   | $2y$12$UJ7...

Jeśli widzisz hash zaczynający się od $2y$ (bcrypt) lub $argon2id$ — wszystko działa poprawnie. Nigdy nie powinieneś widzieć oryginalnego hasła.

W kolejnej lekcji dodamy logowanie, ochronę tras, zarządzanie zadaniami i CSRF.

Funkcje w tej lekcji

Sprawdź się

Dlaczego kolumna haslo_hash w tabeli users powinna mieć typ VARCHAR(255), a nie VARCHAR(32)?

Dlaczego tabela zadania ma kolumnę user_id zamiast przechowywać login użytkownika?

Po co sprawdzamy przed rejestracją, czy login już istnieje w bazie?

Ćwiczenie

Na podstawie kodu z tej lekcji: (1) utwórz tabelę users i zadania w swojej bazie MySQL, (2) utwórz plik .env z danymi połączenia, (3) zaimplementuj klasę Db i zarejestruj testowego użytkownika. Sprawdź przez phpMyAdmin lub CLI czy rekord pojawił się w tabeli users z zahashowanym hasłem (nie jako zwykły tekst).

Pokaż rozwiązanie
-- SQL do utworzenia tabel:
CREATE TABLE users (
    id         INT AUTO_INCREMENT PRIMARY KEY,
    login      VARCHAR(60)  NOT NULL UNIQUE,
    haslo_hash VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE zadania (
    id         INT AUTO_INCREMENT PRIMARY KEY,
    user_id    INT          NOT NULL,
    tytul      VARCHAR(200) NOT NULL,
    ukonczone  TINYINT(1)   NOT NULL DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

<?php
// Weryfikacja rejestracji:
declare(strict_types=1);
require 'src/Db.php';
$pdo = Db::polaczenie();
$stmt = $pdo->query('SELECT id, login, haslo_hash FROM users');
foreach ($stmt->fetchAll() as $row) {
    echo $row['login'] . ': ' . substr($row['haslo_hash'], 0, 7) . '...' . PHP_EOL;
    // Powinno wypisać np.: jan: $2y$12$...
}