Das Account-System
Lösung geprüft
Die Aufgabe der heutigen Stunde ist das Account-System! Das ist die größte PHP-Aufgabe des Halbjahres: Registrierung, Login, geschützter Bereich und Accountverwaltung. Alles, was wir über PDO, Formulare und Prepared Statements gelernt haben, kommt hier zusammen.
- Code verstehen
- Datenbank implementieren in XAMPP
- Form erstellen für die Eingabe des Benutzernamens und Passworts
- Form erstellen zum Editieren, Löschen und Anlegen eines Accounts
- Eine HTML-Seite, auf der die gesammelten Daten zum aktuellen Account und der Session ausgegeben werden
Die Datenbank
-- Datenbank users mit Kodierung utf8_unicode_ci anlegen
CREATE DATABASE users CHARACTER SET utf8 COLLATE utf8_unicode_ci;
USE users;
CREATE TABLE user (
id INT NOT NULL AUTO_INCREMENT,
email VARCHAR(100) NOT NULL,
passwort VARCHAR(255) NOT NULL,
vorname VARCHAR(50),
nachname VARCHAR(50),
PRIMARY KEY (id),
UNIQUE (email)
);
Zwei Entscheidungen in diesem CREATE TABLE sind wichtig:
VARCHAR(255) für das Passwort. Da steht nie
das Passwort selbst drin, sondern ein Hash. Der von
password_hash() ist aktuell 60 Zeichen lang, kann in
künftigen PHP-Versionen aber länger werden. 255 ist die Empfehlung, damit
nichts abgeschnitten wird – und ein abgeschnittener Hash bedeutet, dass
sich der Benutzer nie wieder einloggen kann.
UNIQUE (email). Damit verhindert die
Datenbank selbst, dass sich dieselbe E-Mail zweimal registriert. Auf die
Prüfung im PHP-Code allein sollte man sich nicht verlassen: klickt jemand
zweimal schnell hintereinander auf Absenden, können beide Anfragen die
Prüfung passieren, bevor die erste gespeichert hat.
flowchart TD
subgraph R["registrieren.php"]
R1["E-Mail und Passwort aus dem Formular"]
R2{"E-Mail gültig und<br>Passwort lang genug?"}
R3["SELECT id FROM user WHERE email = ?"]
R4{"E-Mail schon vergeben?"}
R5["password_hash(...)<br>hier wird gehasht -<br>nie im Klartext speichern"]
R6["INSERT INTO user (email, passwort)"]
R7["Erfolgsmeldung, Formular ausblenden"]
RF["Fehlermeldung,<br>Formular bleibt stehen"]
end
subgraph L["login.php"]
L1["E-Mail und Passwort aus dem Formular"]
L2["SELECT id, email, passwort<br>FROM user WHERE email = ?"]
L3{"password_verify(Eingabe, Hash)<br>stimmt?"}
L4["session_regenerate_id(true)"]
L5["$_SESSION['userid'] setzen"]
L6["Weiterleitung auf geheim.php"]
LF["E-Mail oder Passwort ist falsch"]
end
G{"geheim.php:<br>ist userid in der Session?"}
GJ["geschützter Inhalt"]
R1 --> R2
R2 -- nein --> RF
R2 -- ja --> R3
R3 --> R4
R4 -- ja --> RF
R4 -- nein --> R5
R5 --> R6
R6 --> R7
R7 --> L1
L1 --> L2
L2 --> L3
L3 -- nein --> LF
L3 -- ja --> L4
L4 --> L5
L5 --> L6
L6 --> G
G -- ja --> GJ
G -- nein --> L1
Registrierung
<?php
/**
*
* Registrierung eines neuen Accounts
*
* @version 1.0 vom 13.06.2019
* @meininfokurs.cmrg.site
*/
session_start();
$pdo = new PDO('mysql:host=localhost;dbname=users', 'root', '',
array(PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8"));
$showFormular = true;
$meldung = "";
if (isset($_POST['register'])) {
$email = $_POST['email'];
$passwort = $_POST['passwort'];
// 1. Eingaben pruefen
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$meldung = "Bitte eine gültige E-Mail-Adresse angeben.";
} else if (strlen($passwort) < 8) {
$meldung = "Das Passwort muss mindestens 8 Zeichen lang sein.";
} else {
// 2. Gibt es die E-Mail schon?
$sth = $pdo->prepare("SELECT id FROM user WHERE email = ?");
$sth->execute(array($email));
if ($sth->rowCount() > 0) {
$meldung = "Diese E-Mail ist bereits registriert.";
} else {
// 3. Passwort hashen - NIEMALS im Klartext speichern
$hash = password_hash($passwort, PASSWORD_DEFAULT);
$sth = $pdo->prepare(
"INSERT INTO user (email, passwort) VALUES (?, ?)");
$sth->execute(array($email, $hash));
$meldung = "Registrierung erfolgreich. Du kannst dich jetzt einloggen.";
$showFormular = false;
}
}
}
?>
<body>
<h1>Registrierung</h1>
<p><?php echo htmlspecialchars($meldung); ?></p>
<?php if ($showFormular) { ?>
<form action="registrieren.php" method="post">
<p>
<label for="email">E-Mail:</label>
<input type="email" name="email" id="email" />
</p>
<p>
<label for="passwort">Passwort:</label>
<input type="password" name="passwort" id="passwort" />
</p>
<input type="submit" name="register" value="Registrieren" />
</form>
<?php } ?>
</body>
password_hash – warum kein Klartext
$hash = password_hash($passwort, PASSWORD_DEFAULT);
// ergibt z.B. $2y$10$qCgb4MKzbMKAqUU2LOFBQ.wGoAD6yBElFA7V7EPwK.QGCViJjx4mu
Ein Hash ist eine Einbahnstraße: aus dem Passwort wird der Hash, aus dem Hash aber nicht wieder das Passwort. Wird die Datenbank gestohlen, hat der Angreifer damit erstmal nichts anfangen können.
Das ist auch der Grund, warum seriöse Seiten dir dein Passwort nie zuschicken können, sondern immer nur einen Link zum Zurücksetzen. Wer dir dein altes Passwort per Mail schickt, speichert es im Klartext – und da sollte man sofort misstrauisch werden.
password_hash würfelt bei jedem Aufruf einen zufälligen
Salt dazu. Zwei Benutzer mit demselben Passwort bekommen deshalb
unterschiedliche Hashes, und an den Hashes kann man nicht ablesen, wer
dasselbe Passwort hat.
Login
<?php
/**
*
* Login
*
* @version 1.0 vom 13.06.2019
* @meininfokurs.cmrg.site
*/
session_start();
$pdo = new PDO('mysql:host=localhost;dbname=users', 'root', '',
array(PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8"));
$meldung = "";
if (isset($_POST['login'])) {
$email = $_POST['email'];
$passwort = $_POST['passwort'];
$sth = $pdo->prepare("SELECT id, email, passwort FROM user WHERE email = ?");
$sth->execute(array($email));
$user = $sth->fetch();
// password_verify vergleicht Eingabe mit dem gespeicherten Hash
if ($user !== false && password_verify($passwort, $user['passwort'])) {
// Session-ID erneuern - schuetzt vor Session-Fixation
session_regenerate_id(true);
$_SESSION['userid'] = $user['id'];
$_SESSION['email'] = $user['email'];
header("Location: geheim.php");
exit;
} else {
// Bewusst keine Angabe, WAS falsch war
$meldung = "E-Mail oder Passwort ist falsch.";
}
}
?>
<body>
<h1>Login</h1>
<p><?php echo htmlspecialchars($meldung); ?></p>
<form action="login.php" method="post">
<p>
<label for="email">E-Mail:</label>
<input type="email" name="email" id="email" />
</p>
<p>
<label for="passwort">Passwort:</label>
<input type="password" name="passwort" id="passwort" />
</p>
<input type="submit" name="login" value="Einloggen" />
</form>
</body>
Warum die Fehlermeldung so unpräzise ist
„E-Mail oder Passwort ist falsch" statt „Diese E-Mail gibt es nicht" ist Absicht. Die zweite Meldung würde verraten, welche E-Mail-Adressen registriert sind. Jemand könnte damit durchprobieren, wer bei euch einen Account hat – und das ist schon eine Information, die niemanden angeht.
Der geschützte Bereich
<?php
/**
*
* Geschuetzter Bereich mit Account- und Session-Daten
*
* @version 1.0 vom 13.06.2019
* @meininfokurs.cmrg.site
*/
session_start();
// Die wichtigste Zeile der ganzen Datei:
// wer nicht eingeloggt ist, fliegt sofort raus
if (!isset($_SESSION['userid'])) {
header("Location: login.php");
exit;
}
$pdo = new PDO('mysql:host=localhost;dbname=users', 'root', '',
array(PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8"));
$sth = $pdo->prepare("SELECT * FROM user WHERE id = ?");
$sth->execute(array($_SESSION['userid']));
$user = $sth->fetch(PDO::FETCH_ASSOC);
?>
<body>
<h1>Willkommen, <?php echo htmlspecialchars($user['email']); ?></h1>
<h2>Daten zum Account</h2>
<table border="1">
<?php
foreach ($user as $spalte => $wert) {
if ($spalte == 'passwort') {
$wert = '(Hash wird nicht angezeigt)';
}
echo "<tr><th>".htmlspecialchars($spalte)."</th>"
."<td>".htmlspecialchars($wert)."</td></tr>";
}
?>
</table>
<h2>Daten zur Session</h2>
<table border="1">
<tr><th>Session-ID</th><td><?php echo session_id(); ?></td></tr>
<?php
foreach ($_SESSION as $schluessel => $wert) {
echo "<tr><th>".htmlspecialchars($schluessel)."</th>"
."<td>".htmlspecialchars($wert)."</td></tr>";
}
?>
</table>
<p><a href="bearbeiten.php">Account bearbeiten</a>
| <a href="logout.php">Abmelden</a></p>
</body>
Wie eine Session funktioniert
session_start()muss in jeder Datei ganz oben stehen, die$_SESSIONbenutzt- PHP schickt dem Browser eine zufällige Session-ID als Cookie
- Die eigentlichen Daten bleiben auf dem Server; der Browser hat nur die ID
- Bei jedem Aufruf schickt der Browser die ID mit, und PHP findet die passenden Daten wieder
Deshalb ist es ungefährlich, die userid in die Session zu
legen: der Benutzer kann sie nicht verändern, sie liegt ja gar nicht bei
ihm. Ein verstecktes Formularfeld mit der ID wäre dagegen fatal – das
könnte jeder im Browser umschreiben und wäre plötzlich als jemand anders
eingeloggt.
Die drei Zeilen, die den Schutz ausmachen
if (!isset($_SESSION['userid'])) {
header("Location: login.php");
exit;
}
Das exit; darf man auf keinen Fall vergessen.
header() schickt nur eine Weiterleitung mit – das
Skript läuft danach ganz normal weiter und gibt den geheimen Inhalt trotzdem
aus. Ein Browser folgt der Weiterleitung, aber jemand, der die Antwort
direkt anschaut, bekommt alles zu sehen. Ohne exit ist der
Schutz also nur Dekoration.
Account bearbeiten und löschen
<?php
/**
* Account bearbeiten und loeschen
* @meininfokurs.cmrg.site
*/
session_start();
if (!isset($_SESSION['userid'])) {
header("Location: login.php");
exit;
}
$pdo = new PDO('mysql:host=localhost;dbname=users', 'root', '',
array(PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8"));
$meldung = "";
// --- Aendern ---
if (isset($_POST['aendern'])) {
$sth = $pdo->prepare(
"UPDATE user SET vorname = ?, nachname = ? WHERE id = ?");
$sth->execute(array($_POST['vorname'], $_POST['nachname'],
$_SESSION['userid']));
$meldung = "Daten gespeichert.";
}
// --- Loeschen ---
if (isset($_POST['loeschen'])) {
$sth = $pdo->prepare("DELETE FROM user WHERE id = ?");
$sth->execute(array($_SESSION['userid']));
session_destroy();
header("Location: login.php");
exit;
}
$sth = $pdo->prepare("SELECT * FROM user WHERE id = ?");
$sth->execute(array($_SESSION['userid']));
$user = $sth->fetch(PDO::FETCH_ASSOC);
?>
<body>
<h1>Account bearbeiten</h1>
<p><?php echo htmlspecialchars($meldung); ?></p>
<form action="bearbeiten.php" method="post">
<p>
<label for="vorname">Vorname:</label>
<input type="text" name="vorname" id="vorname"
value="<?php echo htmlspecialchars($user['vorname']); ?>" />
</p>
<p>
<label for="nachname">Nachname:</label>
<input type="text" name="nachname" id="nachname"
value="<?php echo htmlspecialchars($user['nachname']); ?>" />
</p>
<input type="submit" name="aendern" value="Speichern" />
</form>
<form action="bearbeiten.php" method="post">
<input type="submit" name="loeschen" value="Account endgültig löschen" />
</form>
</body>
Beachte das WHERE id = ? mit der ID
aus der Session und nicht aus dem Formular. Käme die ID
aus einem Formularfeld, könnte jeder eingeloggte Benutzer die Nummer
ändern und damit fremde Accounts bearbeiten oder löschen. Die Session ist
die einzige Quelle, der man hier trauen darf.
<?php
session_start();
$_SESSION = array();
session_destroy();
header("Location: login.php");
exit;
?>
Warum überall Prepared Statements stehen
In der ganzen Datei kommt kein einziges Mal eine Variable direkt im SQL-Text vor. Der Grund steht im Kapitel über SQL-Injection: sobald man Benutzereingaben in den Text klebt, kann man die Abfrage von außen umschreiben.
// So NICHT - der Klassiker aus dem Unterrichtsmaterial
$sql = "SELECT * FROM user WHERE id = ".$_GET['id'];
// Ruft jemand seite.php?id=1 OR id > 1 auf, bekommt er alle Benutzer.
Bei einem Login-Formular wäre das besonders bitter, weil dort die
Passwort-Hashes liegen. Mit prepare() und
execute(array(...)) passiert das nicht: die Eingabe wird
immer als Wert behandelt, nie als Teil des Befehls – egal
was drinsteht.