Abschnitt 18 · Lektion 3
Anmeldung
In Lektion 12.5 hast du eine Anmeldung gebaut, und das Passwort stand als Hash in einer Konstante. Das reicht für eine einzige Person. Sobald es zwei sein können, gehören die Konten in eine Tabelle, und aus der einen Anmeldung werden drei Wege: registrieren, anmelden, abmelden.
Drei Wege hinein, einer hinaus
<?php
// Anmeldung in klein: vier Adressen, ein Konto, kein Speicher. Im Reiter
// Browser kannst du es wirklich durchspielen, dein Browser haelt die
// Sitzung zusammen. Das Passwort lautet miamia.
session_start();
// Einmal mit password_hash("miamia", PASSWORD_DEFAULT) erzeugt. In der
// Anwendung steht so ein Hash in der Spalte passwort_hash.
$konten = ["mia" => '$2y$12$yJUrXrbGx8rG2g04Ohr0AOgiaJ00BI96ln6TmrLi0Lz/bN5FMMeFC'];
$pfad = parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH);
$meldung = null;
if ($pfad === "/login" && $_SERVER["REQUEST_METHOD"] === "POST") {
$name = trim($_POST["name"] ?? "");
$hash = $konten[$name] ?? "";
// Der Vergleich laeuft auch dann, wenn es den Namen gar nicht gibt.
// Sonst waere an der Antwortzeit abzulesen, welche Namen vergeben sind.
if ($hash !== "" && password_verify($_POST["passwort"] ?? "", $hash)) {
$_SESSION["name"] = $name;
header("Location: /geschuetzt", true, 302);
exit;
}
$meldung = "Name oder Passwort stimmt nicht.";
}
if ($pfad === "/logout") {
$_SESSION = [];
setcookie(session_name(), "", ["expires" => time() - 3600, "path" => "/"]);
session_destroy();
header("Location: /", true, 302);
exit;
}
// Die Wand: eine Zeile, und sie steht vor allem, was geschuetzt sein soll.
if ($pfad === "/geschuetzt" && empty($_SESSION["name"])) {
header("Location: /login", true, 302);
exit;
}
?>
<!doctype html>
<html lang="de">
<head><meta charset="utf-8"><title>Anmeldung in klein</title>
<style>body{font-family:system-ui,sans-serif;max-width:34rem;margin:2rem auto}</style></head>
<body>
<h1>Anmeldung in klein</h1>
<?php if ($pfad === "/geschuetzt") { ?>
<p>Hallo <?= htmlspecialchars($_SESSION["name"], ENT_QUOTES, "UTF-8") ?>. Hier steht, was nur du sehen darfst.</p>
<p><a href="/logout">Abmelden</a></p>
<?php } elseif ($pfad === "/login") { ?>
<?php if ($meldung !== null) { ?><p><strong><?= htmlspecialchars($meldung, ENT_QUOTES, "UTF-8") ?></strong></p><?php } ?>
<form method="post" action="/login">
<p><label>Name: <input type="text" name="name" value="mia"></label></p>
<p><label>Passwort: <input type="password" name="passwort"></label></p>
<button type="submit">Anmelden</button>
</form>
<?php } else { ?>
<p>Diese Seite darf jeder sehen.</p>
<p><a href="/geschuetzt">Zum geschuetzten Bereich</a> (ohne Anmeldung fliegst du raus)</p>
<?php } ?>
</body>
</html> Das erste Beispiel ist die kleinste Anmeldung, die etwas taugt. Ein Konto, vier Adressen, keine
Datenbank. Im Reiter Browser kannst du sie wirklich bedienen: Ruf /geschuetzt auf, ohne
angemeldet zu sein, und du landest auf der Anmeldeseite. Melde dich mit miamia an, und du kommst
hinein. Melde dich ab, und du bist wieder draußen.
Drei Dinge daran sind kein Zufall:
Registrieren und Anmelden enden gleich. Beide setzen am Ende denselben Wert in die Sitzung und leiten an dieselbe Stelle weiter. Wer sich gerade registriert hat, ist angemeldet, ohne sich noch einmal anmelden zu müssen. Alles andere wäre eine Zumutung, und im Code ist es dieselbe Zeile.
Der Vergleich läuft auch dann, wenn es den Namen gar nicht gibt. Im Beispiel steht dafür ein
leerer Hash bereit. Der Grund ist unscheinbar: password_verify braucht gut 150 Millisekunden.
Wer den Aufruf überspringt, sobald der Name unbekannt ist, antwortet in diesem Fall
messbar schneller, und schon lässt sich von außen herausfinden, welche Namen vergeben sind.
Das Abmelden ist mehr als ein Wert weniger in der Sitzung. Es sind die drei Schritte aus Lektion 12.3: leeren, das Cookie löschen, die Sitzung zerstören. Wer nur den einen Wert entfernt, lässt die Sitzungsdatei liegen.
Ein Hash ist kein verschlüsseltes Passwort
<?php
declare(strict_types=1);
// Zwei Hashes desselben Passworts, und sie sind verschieden. Das ist kein
// Fehler, das ist der Sinn: In jedem steckt ein zufaelliger Salt.
$erster = password_hash("miamia", PASSWORD_DEFAULT);
$zweiter = password_hash("miamia", PASSWORD_DEFAULT);
echo "Erster Hash: ", $erster, "\n";
echo "Zweiter Hash: ", $zweiter, "\n";
echo "Gleich? ", $erster === $zweiter ? "ja" : "nein", "\n\n";
// Trotzdem passen beide zum selben Passwort. Vergleichen kann man Hashes
// also nicht, pruefen schon.
echo "password_verify gegen den ersten: ", password_verify("miamia", $erster) ? "passt" : "passt nicht", "\n";
echo "password_verify gegen den zweiten: ", password_verify("miamia", $zweiter) ? "passt" : "passt nicht", "\n";
echo "password_verify mit falschem Wort: ", password_verify("miamiaa", $erster) ? "passt" : "passt nicht", "\n\n";
// Was in einem Hash steht, kann man ablesen. Das Passwort gehoert nicht dazu.
$info = password_get_info($erster);
echo "Verfahren laut Praefix: ", $info["algo"], " (", $info["algoName"], ")\n";
echo "Kostenfaktor: ", $info["options"]["cost"], "\n";
echo "Laenge: ", strlen($erster), " Zeichen\n\n";
// Und die Frage, die eine Anwendung nach Jahren stellt: ist der Hash noch
// gut genug? Der hier ist mit Kosten 4 gerechnet, absichtlich zu billig.
$alt = password_hash("miamia", PASSWORD_BCRYPT, ["cost" => 4]);
echo "Alter Hash passt noch: ", password_verify("miamia", $alt) ? "ja" : "nein", "\n";
echo "Neu rechnen faellig: ", password_needs_rehash($alt, PASSWORD_DEFAULT) ? "ja" : "nein", "\n"; Das zweite Beispiel rechnet zweimal denselben Hash und bekommt zwei verschiedene Ergebnisse. Wer Verschlüsselung erwartet, stolpert genau hier: Ein verschlüsselter Text lässt sich zurückrechnen, ein Hash nicht. Und in jedem Hash steckt ein zufälliger Salt, damit zwei Leute mit demselben Passwort nicht denselben Eintrag in der Datenbank haben.
Daraus folgt die Regel, an der die ganze Anmeldung hängt: Hashes vergleicht man nicht, man prüft
sie. password_hash($eingabe) === $ausDerDatenbank ist immer falsch, auch beim richtigen Passwort.
Richtig ist password_verify($eingabe, $ausDerDatenbank), denn die Funktion liest den Salt aus dem
gespeicherten Hash und rechnet damit nach.
Ablesen lässt sich an einem Hash trotzdem einiges: Am Präfix $2y$ erkennt man bcrypt, an der Zahl
dahinter den Kostenfaktor, und password_needs_rehash() sagt, ob er nach heutigen Maßstäben zu
billig gerechnet ist. Nur das Passwort selbst steht nicht darin, und das ist der Sinn der Sache.
Die Wand steht an einer Stelle
Die eigentliche Arbeit einer Anmeldung ist nicht das Anmelden. Es ist die Frage, was mit allen anderen Seiten passiert.
Der teure Fehler sieht so aus: Jede geschützte Seitenfunktion fragt selbst nach, ob jemand angemeldet ist. Das funktioniert, solange man daran denkt. Beim Anlegen der siebten Funktion denkt niemand mehr daran, und dann gibt es eine Adresse, die ohne Anmeldung geht, während alle anderen geschützt sind. Solche Lücken findet man nicht durch Nachdenken, sondern durch Zufall.
Die Antwort ist eine einzige Funktion, die die Frage beantwortet, und ein Aufruf davon als erste Zeile jeder geschützten Seite. Sie gibt entweder die Kennung des Angemeldeten zurück oder leitet weg und beendet die Anfrage. Damit steht die Regel an einer Stelle, und man sieht an jeder Funktion in einer Zeile, ob sie geschützt ist.
Genau die baust du gleich, und ab der nächsten Lektion ruft jede neue Seite sie auf.
Was in die Sitzung gehört
Wenig. In diesem Projekt zwei Werte: die Kennung des Angemeldeten und sein Name für die Anzeige.
Die Kennung ist die Wahrheit, alles andere ist Bequemlichkeit. Wer die Lesezeichen einer Person laden will, nimmt die Kennung aus der Sitzung, niemals eine aus dem Formular oder der Adresse. Das ist die kürzeste Fassung der ganzen Rechteprüfung, und die längere kommt in Lektion 18.5.
Eine Zeile aus Lektion 12.5 fehlt hier mit Absicht, nämlich session_regenerate_id(true) nach der
erfolgreichen Anmeldung. Auf einem echten Server gehört sie dorthin, sie tauscht die Sitzungskennung
gegen eine frische. Die Prüfliste gibt die Kennung aber in jeder Zeile von Hand mit, und eine
Sitzung, die sich mittendrin umbenennt, zöge ihr den Boden weg.
Zum Mitnehmen
Eine Anmeldung besteht aus drei Wegen hinein und einem hinaus. Der schwierige Teil ist keiner davon, sondern die Frage, wo die Wand steht, die alle anderen Seiten schützt.
Jetzt du
Basis Konto, kostenlosZu 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 2 Beispielen zum Ausprobieren
Steht hier, ohne Konto lesbar.
-
Aufgabe, dein Code läuft auf einem Server
Öffnet sich mit dem Basis Konto.