Q2 Datenbanken

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.

Die Aufgabenstellung lautet

Die Datenbank

users.sql
/* meininfokurs.cmrg.site */
-- 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 und Login Schritt für Schritt

Registrierung

registrieren.php
/* meininfokurs.cmrg.site */
<?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

login.php
/* meininfokurs.cmrg.site */
<?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

geheim.php
/* meininfokurs.cmrg.site */
<?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

  1. session_start() muss in jeder Datei ganz oben stehen, die $_SESSION benutzt
  2. PHP schickt dem Browser eine zufällige Session-ID als Cookie
  3. Die eigentlichen Daten bleiben auf dem Server; der Browser hat nur die ID
  4. 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

bearbeiten.php
/* meininfokurs.cmrg.site */
<?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.

logout.php
<?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.

Für den Unterricht in XAMPP reicht das so. Für eine Seite, die wirklich im Netz steht, kämen noch dazu: HTTPS, damit das Passwort nicht im Klartext übertragen wird, eine Begrenzung der Login-Versuche gegen automatisches Durchprobieren, und CSRF-Tokens in den Formularen.

Aufgabe suchen

Tippe los. Strg+K öffnet die Suche von überall.

Zuletzt angesehen

Werbung