Abschnitt 12 · Lektion 5
Eine einfache Anmeldung
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
<?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
<?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
<?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, 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 3 Beispielen zum Ausprobieren
Steht hier, ohne Konto lesbar.
-
Aufgabe, dein Code läuft auf einem Server
Öffnet sich mit dem Basis Konto.