mitmario.dev

Eine einfache Anmeldung

PHP Sandbox 5 Min Lesezeit 3 BeispieleLektion 5 von 6

Alles, was eine Anmeldung braucht, hast du in den letzten beiden Abschnitten schon einzeln gebaut. Diese Lektion setzt es zusammen und ergänzt genau eine neue Sache: den Umgang mit dem Passwort.

Drei Teile, mehr ist es nicht

Die drei Teile einer Anmeldung
<?php

// Eine vollstaendige Anmeldung in einer Datei. Das Passwort lautet
// geheim123. Melde dich im Browser an, ruf danach den geschuetzten
// Bereich auf und melde dich wieder ab.

session_start();

const PASSWORT_HASH = '$2y$12$p0YSJBmbnhkM6LDoxs/7POMFNtRXR7puLllRVtWCYNSzXQZnFqgHu';

$pfad = parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH);
$angemeldet = !empty($_SESSION["angemeldet"]);
$fehler = null;

if ($pfad === "/login" && $_SERVER["REQUEST_METHOD"] === "POST") {
    if (password_verify($_POST["passwort"] ?? "", PASSWORT_HASH)) {
        // Neue Kennung, damit eine untergeschobene alte wertlos wird.
        session_regenerate_id(true);
        $_SESSION["angemeldet"] = true;

        header("Location: /geheim", true, 302);
        exit;
    }

    $fehler = "Das Passwort stimmt nicht.";
}

if ($pfad === "/geheim" && !$angemeldet) {
    header("Location: /login", true, 302);
    exit;
}

if ($pfad === "/logout") {
    $_SESSION = [];
    setcookie(session_name(), "", ["expires" => time() - 3600, "path" => "/"]);
    session_destroy();

    header("Location: /", true, 302);
    exit;
}

?>
<!doctype html>
<html lang="de">
<head>
    <meta charset="utf-8">
    <title>Mitgliederbereich</title>
</head>
<body>

<?php if ($pfad === "/geheim") { ?>
    <h1>Streng geheim</h1>
    <p>Der Umsatz des Monats liegt bei 84.120 Euro.</p>
    <p><a href="/logout">Abmelden</a></p>

<?php } elseif ($pfad === "/login") { ?>
    <h1>Anmelden</h1>

    <?php if ($fehler !== null) { ?>
    <p><strong><?= htmlspecialchars($fehler) ?></strong></p>
    <?php } ?>

    <form method="post" action="/login">
        <p><label>Passwort: <input type="password" name="passwort"></label></p>
        <button type="submit">Anmelden</button>
    </form>

<?php } else { ?>
    <h1>Willkommen</h1>
    <p>Diese Seite darf jeder sehen.</p>
    <p><a href="/geheim">Zum geschuetzten Bereich</a></p>
<?php } ?>

</body>
</html>

Eine Anmeldung besteht aus einem Formular, einem Vergleich und einem Merker.

Das Formular kennst du aus Abschnitt 11: ein Feld, ein Knopf, method="post". Der Vergleich prüft, ob das eingegebene Passwort zu dem passt, das du hinterlegt hast. Und der Merker ist eine einzige Zeile: $_SESSION["angemeldet"] = true;.

Alles Weitere folgt daraus. Eine geschützte Seite fragt oben nach diesem Merker und schickt jeden weg, bei dem er fehlt. Das Abmelden räumt die Sitzung ab, wie in 12.3 gezeigt.

Was der Besucher dabei mit sich herumträgt, ist nur die Sitzungskennung. Der Merker selbst liegt in der Datei auf deinem Server, und deshalb kann ihn niemand von außen setzen. Genau das war der Unterschied zum Cookie aus Lektion 12.2, wo sich jeder zum Chef machen konnte. Im Reiter Netzwerk siehst du beim Anmelden, wie wenig hin und her geht: POST /login mit 302 und Location: /geheim unter den Antwort-Kopfzeilen, darunter GET /geheim mit 200. An beiden hängt eine cookie: PHPSESSID=…-Zeile, und zwar nicht dieselbe; warum, steht weiter unten. In den Kopfzeilen steht vom Passwort nichts, es ist im Rumpf des POST mitgereist. Klapp den POST /login auf, dann liest du es im Anfrage-Rumpf im Klartext, genau so, wie es über die Leitung ging.

Das Passwort selbst kommt nie in deine Nähe

Warum das Passwort nicht im Klartext verglichen wird
<?php

// Ein Passwort wird nie gespeichert, sondern nur sein Hash. Aus dem
// Hash laesst sich das Passwort nicht zurueckrechnen, aus dem
// Passwort der Hash sehr wohl. Genau das reicht zum Anmelden.

$passwort = "geheim123";

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

echo "Laenge eines Hashs: ", strlen($hash1), " Zeichen", PHP_EOL;
echo "Beide Hashs gleich? ", $hash1 === $hash2 ? "ja" : "nein", PHP_EOL;
echo PHP_EOL;

// Zweimal dasselbe Passwort, zweimal ein anderer Hash: In jedem
// steckt ein zufaelliges Stueck mit, der Salt. Deshalb vergleicht
// man Hashs nie mit === , sondern mit password_verify().

echo "Richtiges Passwort: ", password_verify("geheim123", $hash1) ? "passt" : "passt nicht", PHP_EOL;
echo "Falsches Passwort:  ", password_verify("geheim124", $hash1) ? "passt" : "passt nicht", PHP_EOL;
echo "Gegen den zweiten Hash: ", password_verify("geheim123", $hash2) ? "passt" : "passt nicht", PHP_EOL;

Ein Passwort wird nicht gespeichert. Gespeichert wird ein Hash: eine Zeichenfolge, die sich aus dem Passwort berechnen lässt, aus der sich das Passwort aber nicht zurückholen lässt.

password_hash($passwort, PASSWORD_DEFAULT) erzeugt ihn, password_verify($eingabe, $hash) prüft ihn. Mehr brauchst du nicht, und vor allem brauchst du nichts selbst zu bauen.

Zwei Dinge sieht man am ersten Beispiel sofort. Es ist ein Skript und läuft im Terminal, denn hier antwortet niemand auf eine Anfrage, es wird nur gerechnet. Erstens ist der Hash bei jedem Aufruf ein anderer, obwohl das Passwort dasselbe ist: In ihm steckt ein zufälliges Stück mit, der Salt. Zweitens funktioniert password_verify trotzdem gegen beide, denn der Salt steht mit im Hash drin.

Daraus folgt die Regel, die man sich merken muss: Hashs vergleicht man nie mit ===. Ein Vergleich auf Gleichheit würde bei richtigem Passwort fehlschlagen, und das ist der Punkt, an dem Leute anfangen, sich etwas Eigenes auszudenken. Warum das eine schlechte Idee ist, mit welchen Verfahren man es zu tun hat und was ein Hash sonst noch aushalten muss, steht in Lektion 15.4.

Bis dahin gilt: password_hash zum Anlegen, password_verify zum Prüfen, und das Passwort im Klartext existiert nur für die Dauer einer Anfrage.

Die Kennung wechseln, direkt nach dem Anmelden

Was session_regenerate_id bewirkt
<?php

// Die Kennung deiner Sitzung, vor und nach einer Neuvergabe.
//
// Lad die Seite im Browser ein paar Mal neu: Die Kennung bleibt. Klick
// dann auf den Link darunter: Sie ist eine andere, und der Zaehler
// steht trotzdem noch da, wo er war.

session_start();

$vorher = session_id();
$neu = false;

if (isset($_GET["neuekennung"])) {
    session_regenerate_id(true);
    $neu = true;
}

$_SESSION["stand"] = ($_SESSION["stand"] ?? 0) + 1;

?>
<!doctype html>
<html lang="de">
<head>
    <meta charset="utf-8">
    <title>Kennung</title>
    <style>
        body { font-family: system-ui, sans-serif; }
        code { background: #eee; padding: .1rem .3rem; }
    </style>
</head>
<body>
    <h1>Deine Sitzungskennung</h1>

    <p>Vorher: <code><?= htmlspecialchars($vorher) ?></code></p>
    <p>Jetzt: <code><?= htmlspecialchars(session_id()) ?></code></p>
    <p>Neu vergeben: <?= $neu ? "ja" : "nein" ?></p>

    <p>Stand des Zaehlers: <strong><?= $_SESSION["stand"] ?></strong></p>

    <p>
        <a href="/">Neu laden</a> ·
        <a href="/?neuekennung=1">Kennung neu vergeben</a>
    </p>
</body>
</html>

Es gibt einen Angriff, der ohne jedes Passwort auskommt. Der Angreifer besorgt sich eine Sitzungskennung, schiebt sie dem Opfer unter, etwa über einen präparierten Link, und wartet. Meldet das Opfer sich an, wird genau diese Kennung zur Kennung eines angemeldeten Besuchers, und der Angreifer kennt sie längst. Das nennt man Session Fixation.

Die Gegenmaßnahme ist eine Zeile: session_regenerate_id(true) direkt nach dem erfolgreichen Vergleich. PHP vergibt eine neue Kennung, schickt sie als Cookie mit, und der Inhalt der Sitzung zieht um. Die untergeschobene Kennung zeigt danach auf nichts mehr.

Das true ist kein Beiwerk, sondern die halbe Wirkung: Ohne das Argument bleibt die alte Datei liegen, und zwar mit dem Inhalt, den die Sitzung im Moment des Umzugs hatte. Wer den Merker vorher setzt, lässt dem Angreifer also genau das da, was er wollte. Mit true wird die alte Datei gelöscht.

Im dritten Beispiel siehst du beides nebeneinander: Die Kennung ändert sich, der Zähler bleibt. Im Reiter Netzwerk steht an der Anfrage mit neuekennung ein Set-Cookie: PHPSESSID=… mit der neuen Kennung, so wie beim allerersten Aufruf; ab dann schickt der Browser die neue mit.

Was die Prüfung in der nächsten Aufgabe nicht kann

Die Aufgabe gleich baut diese Anmeldung, und sie lässt session_regenerate_id(true) bewusst weg. Der Grund liegt an der Prüfung und nicht an der Sache: Sie schickt für jede Zeile eine eigene Anfrage und behält dazwischen kein Cookie. Sie gibt die Kennung deshalb von Hand mit, und eine frisch vergebene bekäme sie nie zu sehen. Nach dem Anmelden käme sie an keine geschützte Seite mehr, obwohl alles richtig wäre.

Auf deinem eigenen Server gehört die Zeile hinein, direkt hinter den erfolgreichen Vergleich, genau so wie im zweiten Beispiel oben. Und gegen dieses Beispiel kannst du den Schutz im Terminal vorführen, sobald es läuft. Erst der Weg eines Browsers, mit einer Datei, in der curl seine Cookies aufhebt: curl -si -c jar -b jar -d 'passwort=geheim123' localhost:3000/login | head -1 antwortet mit HTTP/1.1 302 Found, und curl -s -b jar localhost:3000/geheim | grep '<h1>' zeigt danach Streng geheim. Der Server hat eine neue Kennung vergeben, curl hat sie aus dem Set-Cookie in die Datei übernommen, alles in Ordnung. Dann der Weg eines Angreifers, mit einer Kennung, die er sich vorher ausgesucht hat: curl -si -H 'Cookie: PHPSESSID=festekennung12345' -d 'passwort=geheim123' localhost:3000/login | head -1 meldet ebenfalls die 302, aber curl -si -H 'Cookie: PHPSESSID=festekennung12345' localhost:3000/geheim | head -1 antwortet mit einer weiteren 302, zurück zur Anmeldung. Die untergeschobene Kennung ist nach dem Anmelden wertlos, genau wie versprochen. Und das ist zugleich der Grund, warum die Prüfliste die Zeile nicht verträgt: Sie ist der Angreifer mit der festen Kennung, nur ohne böse Absicht.

Zum Mitnehmen

Jetzt liegen alle Teile auf dem Tisch: ein Formular aus Abschnitt 11, eine Weiterleitung aus 11.3, eine Sitzung aus 12.3 und eine Meldung aus 12.4. Zusammengesetzt ergeben sie eine Anmeldung, und die ist kürzer, als die meisten vermuten.

Jetzt du

Basis Konto, kostenlos

Zu dieser Lektion gehört eine Aufgabe. Du schreibst den Code selbst, und nach jedem Lauf sagt dir eine Prüfliste, was schon stimmt.

Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.

In diesem Kurs läuft dein Code auf einem Server. Dafür hat das Basis Konto 1 Stunde im Monat, mehr Zeit gibt es mit dem Premium Konto.

Was in dieser Lektion steckt

  • Artikel mit 3 Beispielen zum Ausprobieren

    Steht hier, ohne Konto lesbar.

  • Aufgabe, dein Code läuft auf einem Server

    Öffnet sich mit dem Basis Konto.