Hashowanie haseł — password_hash i password_verify

🎯 Po tej lekcji będziesz bezpiecznie hashować hasła przez password_hash i weryfikować je przez password_verify, rozumiejąc dlaczego md5 i sha1 są niebezpieczne.

Baza danych wycieka. Statystycznie to nie „czy" — to „kiedy". Jeśli hasła twoich użytkowników są w bazie jako zwykły tekst lub md5, atakujący w kilka minut pozna je wszystkie. Ta lekcja nauczy cię jedynego właściwego sposobu przechowywania haseł w PHP.

Dlaczego nie wolno używać md5 ani sha1

Zobaczmy najpierw, jak nie hashować haseł:

<?php
// ❌ ANTY-PRZYKŁAD — NIGDY tak nie rób!
declare(strict_types=1);

$haslo = 'tajneHaslo';
$hash  = md5($haslo);   // wynik: np. "d0f85e85b1..."  ← NIEBEZPIECZNE
$hash2 = sha1($haslo);  // wynik: np. "f5af..."        ← TEŻ NIEBEZPIECZNE

// Oba algorytmy są łatwe do złamania przez:
// - Bazy tęczowych tablic (miliardy preobliczonych par haslo→hash)
// - Szybkie GPU (miliardy prób na sekundę)

Problemy z md5 i sha1:

  • zbyt szybkie — GPU może sprawdzić miliardy kombinacji na sekundę.
  • Istnieją ogólnodostępne bazy (rainbow tables) z miliardami gotowych hashy.
  • deterministyczne bez soli — to samo hasło daje ten sam hash u każdego użytkownika.
⚠️ Absolutny zakaz: md5, sha1, sha256 i inne ogólne funkcje skrótu są nieodpowiednie do haseł. Nawet z własnoręcznie dodaną solą (np. md5($sól . $hasło)) — nie rób tego. PHP ma gotowe, bezpieczne narzędzie.

password_hash — jedyne właściwe narzędzie

PHP 5.5 dodał funkcję password_hash, która rozwiązuje wszystkie powyższe problemy:

<?php
declare(strict_types=1);

$haslo = 'tajneHaslo';
$hash  = password_hash($haslo, PASSWORD_DEFAULT);

echo $hash;
// wynik: $2y$12$UJ7FSmB2s4eP7y...  (60 znaków, bcrypt)

Co robi password_hash za kulisami:

  1. Generuje losową sól — automatycznie, przy każdym wywołaniu. Dlatego ten sam ciąg wejściowy daje różny hash za każdym razem.
  2. Używa bcrypt lub argon2 — algorytmów celowo zaprojektowanych do haseł, celowo powolnych, żeby GPU nie pomogło.
  3. Zawiera sól w hashu — nie musisz jej przechowywać osobno. Wynikowy ciąg to pełna informacja: algorytm + koszt + sól + hash.
<?php
declare(strict_types=1);

$haslo = 'tajneHaslo';

$hash1 = password_hash($haslo, PASSWORD_DEFAULT);
$hash2 = password_hash($haslo, PASSWORD_DEFAULT);

// Te dwa hashe RÓŻNIĄ SIĘ, choć hasło to samo:
var_dump($hash1 === $hash2); // wynik: bool(false)

// Oba są jednak poprawne — sprawdzisz to przez password_verify.

Algorytmy

Stała PASSWORD_DEFAULT to zalecana wartość:

  • W PHP 8 wybiera bcrypt ($2y$).
  • Gdy PHP doda lepszy domyślny algorytm, twoje nowe hashe automatycznie go użyją.
  • Możesz też użyć PASSWORD_ARGON2ID (argon2id — nowszy, odporniejszy na ataki GPU/FPGA) jeśli twój serwer ma PHP skompilowane z obsługą argon2.
<?php
declare(strict_types=1);

// bcrypt (domyślny, zawsze dostępny):
$hash = password_hash($haslo, PASSWORD_DEFAULT);

// argon2id (nowszy, bezpieczniejszy, wymaga obsługi serwera):
$hash = password_hash($haslo, PASSWORD_ARGON2ID);
💡 Długość kolumny w bazie: Hash bcrypt ma 60 znaków; argon2id — do 96 i więcej. Bezpiecznie użyj VARCHAR(255) dla kolumny haslo_hash. Nie wpisuj 32 znaków jak dla md5 — hash nie zmieści się i zostanie obcięty.

password_verify — sprawdzenie hasła przy logowaniu

Przy logowaniu nie możesz porównać hashy wprost ($podany === $hash) — bo sól jest losowa i każdy hash jest inny. Używasz password_verify:

<?php
declare(strict_types=1);

// Symulacja: hash przechowywany w bazie
$hashZBazy = password_hash('tajneHaslo', PASSWORD_DEFAULT);

// Użytkownik próbuje się zalogować:
$hasloPodaneWFormularzu = 'tajneHaslo';

if (password_verify($hasloPodaneWFormularzu, $hashZBazy)) {
    echo 'Zalogowano!';
} else {
    echo 'Złe hasło.';
}
// wynik: Zalogowano!

// Próba z błędnym hasłem:
if (password_verify('innePaslo', $hashZBazy)) {
    echo 'Zalogowano!';
} else {
    echo 'Złe hasło.';
}
// wynik: Złe hasło.

password_verify wyciąga sól i algorytm z hasha, oblicza hash podanego hasła i porównuje bezpiecznie (timing-safe comparison wbudowane w PHP).

Kompletny przykład — rejestracja i logowanie

Oto uproszczona implementacja rejestracji i logowania z hashowaniem:

<?php
declare(strict_types=1);

// ── REJESTRACJA ──────────────────────────────────────────────
// (w rzeczywistości $pdo pochodzi z klasy połączenia PDO)

function zarejestrujUzytkownika(PDO $pdo, string $login, string $haslo): void
{
    // Walidacja
    if (strlen($login) < 3) {
        throw new InvalidArgumentException('Login musi mieć co najmniej 3 znaki.');
    }
    if (strlen($haslo) < 8) {
        throw new InvalidArgumentException('Hasło musi mieć co najmniej 8 znaków.');
    }

    // Hashowanie — JEDYNA właściwa metoda
    $hashHasla = password_hash($haslo, PASSWORD_DEFAULT);

    // Zapis do bazy przez prepared statement
    $stmt = $pdo->prepare(
        'INSERT INTO users (login, haslo_hash) VALUES (:login, :hash)'
    );
    $stmt->execute([':login' => $login, ':hash' => $hashHasla]);
}

// ── LOGOWANIE ────────────────────────────────────────────────

function zalogujUzytkownika(PDO $pdo, string $login, string $podaneHaslo): ?array
{
    // Pobierz użytkownika z bazy
    $stmt = $pdo->prepare(
        'SELECT id, login, haslo_hash FROM users WHERE login = :login'
    );
    $stmt->execute([':login' => $login]);
    $uzytkownik = $stmt->fetch();

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

    if (!password_verify($podaneHaslo, $uzytkownik['haslo_hash'])) {
        return null;
    }

    return $uzytkownik;
}

Kolejność sprawdzania — ważna dla bezpieczeństwa

Zauważ: gdy login nie istnieje, zwracamy null — ten sam wynik co przy złym haśle. Nie mów użytkownikowi „login nie istnieje" (atakujący mógłby sprawdzać, które loginy istnieją w systemie).

Nigdy własne solenie — dlaczego

Może masz ochotę napisać: md5($sol . $haslo). Nie rób tego:

  • Sól nie ratuje słabego algorytmu — md5 dalej jest za szybki, GPU rozbije hash z solą.
  • password_hash soli automatycznie — lepiej, bezpieczniej, bez twojego udziału.
  • Własna implementacja = nieznane błędy — kryptografia to teren, gdzie drobny błąd niszczy całe zabezpieczenie.
⚠️ Złota zasada: Nie implementuj własnego systemu bezpieczeństwa haseł. PHP ma password_hash i password_verify — używaj ich. Koniec.

Podsumowanie

| Sytuacja | Co robisz | |---|---| | Rejestracja (zapis hasła) | password_hash($haslo, PASSWORD_DEFAULT) → zapisz wynik do bazy | | Logowanie (sprawdzenie hasła) | password_verify($podane, $hashZBazy) → true/false | | Nigdy | md5, sha1, sha256, własne solenie, zwykły tekst |

W kolejnej lekcji zajmiemy się ochroną przed XSS i CSRF — dwoma atakami, które działają po stronie przeglądarki użytkownika.

Funkcje w tej lekcji

Sprawdź się

Dlaczego nie wolno używać md5() do hashowania haseł użytkowników?

Co zwraca password_verify('tajne', $hash), gdy $hash jest poprawnym hashem słowa 'tajne'?

Który stały algorytm zaleca się przekazać do password_hash() w PHP 8?

Ćwiczenie

Napisz dwie funkcje: zarejestrujUzytkownika(string $login, string $haslo): array — zwraca tablicę ['login' => $login, 'hash' => $hash]. Oraz zalogujUzytkownika(string $podaneHaslo, array $uzytkownik): bool — sprawdza czy podane hasło pasuje do hasha w tablicy. Przetestuj obie funkcje.

Pokaż rozwiązanie
<?php
declare(strict_types=1);

function zarejestrujUzytkownika(string $login, string $haslo): array
{
    $hash = password_hash($haslo, PASSWORD_DEFAULT);
    return ['login' => $login, 'hash' => $hash];
}

function zalogujUzytkownika(string $podaneHaslo, array $uzytkownik): bool
{
    return password_verify($podaneHaslo, $uzytkownik['hash']);
}

// Symulacja rejestracji:
$uzytkownik = zarejestrujUzytkownika('ola', 'SuperHaslo99!');
echo $uzytkownik['hash']; // np. $2y$12$...

// Symulacja logowania:
var_dump(zalogujUzytkownika('SuperHaslo99!', $uzytkownik)); // wynik: bool(true)
var_dump(zalogujUzytkownika('zleHaslo',     $uzytkownik)); // wynik: bool(false)