Login Systeme von Einfach bis Profi

Relativ häufig wird nach Möglichkeiten gefragt, wie man Uploads, einzelne Seiten oder ganze Bereiche einer Webseite mit einem Passwortschutz versehen kann. Dabei können die Anforderungen an so einen Passwortschutz ganz unterschiedlich ausfallen. In manchen Fällen reicht es aus, das nur ein Benutzer mit einem Passwort Zugriff erhält, während es in anderen Fällen nötig ist mehrere Benutzer, unabhängig voneinander, zu verwalten und Zugriff zu gestatten. Um verschiedene Anforderungen und Bereiche abzudecken, möchte ich Euch mit diesem Tutorial 3 Techniken etwas näher bringen, die Ihr als Basis für ein eigenes Login System verwenden könnt. Hauptbestandteil dieser Techniken ist das Session Management System von PHP. Aufgeteilt ist dieses Tutorial in:

  1. Einfach – Ein simples Login System mit 1 Benutzer und 1 Passwort. Ausreichend um Uploads oder einzelne Seiten vor Zugriffe Unbefugter zu schützen. Kommt mit wenigen Zeilen Code und ohne Datenbank aus.
  2. Fortgeschritten – Mit diesem System kann man beliebig viele Benutzer verwalten, da wir mit einer MySQL Datenbank arbeiten. Neben dem einfachen Login setzen wir auch Cookies ein, damit sich die Seite an den Benutzer erinnert.
  3. Profi – Auch hier arbeiten wir mit einer MySQL Datenbank, wodurch beliebig viele Benutzer verwaltet werden können. Schwerpunkt bei diesem System liegt aber ganz klar auf dem Punkt Sicherheit. Es werden Schutzmaßnahmen gegen Session Fixation und Session Hijacking getroffen; der Benutzer wird bei jedem Seitenaufruf neu validiert; Hackversuche (Brute Force) werden erkannt und Benutzerkonten automatisch deaktiviert, bevor sie geknackt wurden; Passwörter werden durch die Zugabe von „Salts“ sicherer gemacht; Benutzer werden bei längerer Inaktivität automatisch ausgeloggt, damit kein Unbefugter die Session übernehmen (hijacken) kann.

Voraussetzungen

  • PHP 5.2.x oder höher
  • MySQL 5.x oder höher
  • PHP/MySQL Kenntnisse je nach Login System von Grundlagenwissen bis semi-professionell
  • … und natürlich Lernbereitschaft

Da der Quelltext weitestgehend kommentiert ist und entsprechende PHP Kenntnisse vorausgesetzt werden, werde ich die Erklärungen zu den Listings kürzer halten und nur wichtige Stellen etwas näher erläutern. Übrigens, für alle 3 Varianten lauten die Demo Zugangsdaten: Otto // geheim

Variante Einfach

Um „mal eben“ eine Seite zu schützen oder für ein Datei Upload ein Passwort abzufragen, bietet sich diese Variante an. Die Zugangsdaten, sowie alle relevanten Funktionen für die Anmeldung sind direkt in die Login Datei geschrieben. Listing der Datei login_einfach.php:

<?php
 
// Zugangsdaten
$benutzername = 'Otto';
$passwort     = 'geheim';
 
// Session starten
session_start();
 
// Variablen deklarieren
$_SESSION['angemeldet'] = false;
$fehlermeldung          = '';
 
// Wurde das Formular abgeschickt?
if (isset( $_POST['login'] ))
{
    // Maskierende Slashes aus POST Array entfernen
    if (get_magic_quotes_gpc())
    {
        $_POST = array_map( 'stripslashes', $_POST );
    }
    // Benutzereingabe mit Zugangsdaten vergleichen
    if (strtolower( $benutzername ) == strtolower( trim( $_POST['benutzer'] )) &&
        $passwort == trim( $_POST['passwort'] ))
    {
        // Wenn die Anmeldung korrekt war Session Variable setzen
        // und auf die geheime Seite weiterleiten
        $_SESSION['angemeldet'] = true;
        header( 'location: geheim_einfach.php' );
        exit;
    }
    else
    {
        // Wenn die Anmeldung fehlerhaft war, Fehlermeldung setzen
        $fehlermeldung = '<h3>Die Anmeldung war fehlerhaft!</h3>';
    }
}
 
?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="de" lang="de">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Login Script</title>
</head>
 
<body>
 
<?php
// Falls die Fehlermeldung gesetzt ist
if ($fehlermeldung) echo $fehlermeldung;
?>
 
<form id="loginform" method="post" action="<?php echo $_SERVER['PHP_SELF']; ?>">
    <label for="benutzer">Benutzer: </label><input type="text" name="benutzer" id="benutzer" value="" /><br />
    <label for="passwort">Passwort: </label><input type="password" name="passwort" id="passwort" value="" /><br />
    <input type="submit" name="login" id="login" value="Anmelden" />
</form>
 
</body>
</html>

Die Funktionsweise ist einfach. Oben im Script legen wir Benutzername und Passwort fest, starten die Session, bereiten einige Variablen vor und sind für die Anmeldung gerüstet. Wird das HTML Formular abgeschickt, entfernen wir erst mal störende Maskierungen, die manchmal durch den Server hinzugefügt werden. Eine simple Kontrolle des eingegebenen Benutzername und Passwort mit den festgelegten Werten teilt uns mit, ob der Zugriff berechtigt ist oder nicht. Dazu konvertieren wir den Benutzername in Kleinbuchstaben, weil manche User faul sind und sich hartnäckig weigern die Shift-Taste zu benutzen. Beim Passwort wäre das natürlich fatal, da hier eine Mischung aus Groß-/Kleinbuchstaben sogar gewünscht ist. Erweisen sich die Zugangsdaten als korrekt, setzen wir die Session Variable $_SESSION[‚angemeldet‘] auf den Wert true und wissen somit, dass sich der Benutzer korrekt angemeldet hat. Über die header() Anweisungen leiten wir den Benutzer auf die geheime (geschützte) Seite. Waren die eingegebenen Daten fehlerhaft, weisen wir der Variable $fehlermeldung die entsprechende Meldung zu, wodurch sich deren Existenz auf wahr ändert. Im HTML Teil fragen wir den Status dieser Variable ab und ist der Wert wahr, was er ja durch die Fehlermeldung ist, geben wir die Meldung an den Benutzer aus.

Nachdem der Benutzer, nach korrekter Anmeldung, auf die geschützte Seite geleitet wurde, prüfen wir hier natürlich auch noch einmal, ob sich der Benutzer zurecht hier aufhält. Es könnte ja auch sein, dass er nur den Name der Datei weiß und die Seite direkt im Browser aufgerufen hat. Hier das Listing der Datei geheim_einfach.php:

<?php
 
// Session starten
session_start();
// Prüfen ob der Benutzer angemeldet ist
if (!$_SESSION['angemeldet'])
{
    // Zum Login umleiten
    header( 'location: login_einfach.php' );
    exit;
}
 
?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="de" lang="de">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Geheime Seite</title>
</head>
 
<body>
 
<h3>Willkommen im geschützten Bereich! ;-)</h3>
 
</body>
</html>

Uns interessiert nur der obere Bereich. Als erstes starten wir wieder die Session und prüfen, ob $_SESSION[‚angemeldet‘] den Wert true hat. Ist das nicht der Fall, kam der Besucher nicht über die Login Seite und genau dorthin schicken wir ihn mit der header() Anweisung.

Wichtig: Wenn man in PHP mit Sessions arbeitet, muß man in jeder Datei das session_start() im oberen Teil einfügen. Unterlässt man es die Session zu starten, gehen die Daten aus der Session verloren!

Das war’s schon! Mit so wenigen Zeilen Code kann man ein Mini-Login System verwirklichen. Wie mehrfach erwähnt, eignet sich diese Variante aber lediglich um eher unwichtige Bereiche/Seiten zu schützen. Wer wirklich sensible Inhalte schützen möchte, sollte sich Variante Profi anschauen.

Wer mehrere Benutzer hat, die individuelle Zugangsdaten haben sollen, für den bietet sich die folgende Variante an.

Variante Fortgeschritten

Diese Variante macht Gebrauch von einer MySQL Datenbank und speichert den Anmeldestatus in einem Cookie. Schauen wir uns zunächst einmal das Listing von login_fortgeschritten.php an:

<?php
 
// Session starten
session_start();
 
// Variablen deklarieren
$_SESSION['angemeldet'] = false;
$benutzername           = '';
$passwort               = '';
$fehlermeldung          = '';
 
// Funktion zum verbinden zur Datenbank
function db_connect()
{
    // Zugangsdaten für die DB
    $dbhost = 'localhost';
    $dbuser = 'root';
    $dbpass = '';
    $dbname = 'testlogin';
 
    // Verbindung herstellen und Verbindungskennung zurückgeben
    $conid = mysql_connect( $dbhost, $dbuser, $dbpass ) or die( 'Verbindungsfehler!' );
    if (is_resource( $conid ))
    {
        mysql_select_db( $dbname, $conid ) or die( 'Datenbankfehler!' );
    }
    return $conid;
}
 
// Prüfen ob ein Cookie existiert und zu einem gültigen User gehört 
if (isset( $_COOKIE['UserLogin'] ))
{
    // Wert aus dem Cookie mit dem Wert in der Datenbank vergleichen
    $conid = db_connect();
    $sql = "SELECT
                `id`
            FROM
                `login_fortgeschritten`
            WHERE
                `cookie_hash` = '" .mysql_real_escape_string( $_COOKIE['UserLogin'] ). "' AND
                `aktiviert` = 1";
 
    $ergebnis = mysql_query( $sql, $conid );
 
    // Stimmt der Cookie Hash überein, wurde 1 Datensatz gefunden
    if (mysql_num_rows($ergebnis) == 1)
    {
        // Wenn der Hash aus dem Cookie mit dem aus der DB übereinstimmt,
        // Session Variable setzen und auf die geheime Seite weiterleiten
        $_SESSION['angemeldet'] = true;
        header( 'location: geheim_fortgeschritten.php' );
        exit;
    }
}
 
// Wenn das Formular abgeschickt wurde
if (isset( $_POST['login'] ))
{
    // Maskierende Slashes aus POST Array entfernen
    if (get_magic_quotes_gpc())
    {
        $_POST = array_map( 'stripslashes', $_POST );
    }
 
    // Benutzereingabe umladen, von Leerzeichen befreien und 
    $benutzername = strtolower( trim( $_POST['benutzer'] ) );
    $passwort     = md5( trim( $_POST['passwort'] ) );
 
    // Benutzereingabe mit User in der Datenbank vergleichen
    $conid = db_connect();
    $sql = "SELECT
                `cookie_hash`
            FROM
                `login_fortgeschritten`
            WHERE
                LOWER(`benutzername`) = '" .mysql_real_escape_string( $benutzername ). "' AND
                `passwort` = '" .mysql_real_escape_string( $passwort ). "' AND
                `aktiviert` = 1";
 
    $ergebnis = mysql_query( $sql, $conid );
 
    // Stimmen die Benutzereingaben überein, wurde 1 Datensatz gefunden
    if (mysql_num_rows($ergebnis) == 1)
    {
        // Abfrageergebnis fetchen
        $usercookie = mysql_fetch_assoc( $ergebnis );
 
        // Wenn die Anmeldung korrekt war Session Variable setzen,
        // COOKIE an Browser schicken und auf die geheime Seite weiterleiten
        $_SESSION['angemeldet'] = true;
        setcookie( 'UserLogin', $usercookie['cookie_hash'], time()+600 );
        header( 'location: geheim_fortgeschritten.php' );
        exit;
    }
    else
    {
        $fehlermeldung = '<h3>Die Anmeldung war fehlerhaft!</h3>';
    }
}
 
?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="de" lang="de">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Login Script</title>
</head>
 
<body>
 
<?php
// Falls die Fehlermeldung gesetzt ist
if ($fehlermeldung) echo $fehlermeldung;
?>
 
<form id="loginform" method="post" action="<?php echo $_SERVER['PHP_SELF']; ?>">
    <label for="benutzer">Benutzer: </label><input type="text" name="benutzer" id="benutzer" value="" /><br />
    <label for="passwort">Passwort: </label><input type="password" name="passwort" id="passwort" value="" /><br />
    <input type="submit" name="login" id="login" value="Anmelden" />
</form>
 
</body>
</html>

Der HTML Teil dieses Scripts ist identisch mit dem vorherigen Beispiel. Ebenfalls starten wir auch hier als erstes eine Session und bereiten einige Variablen vor, die wir im weiteren Verlauf benötigen. Danach folgt eine Funktion mit dem Namen db_connect(), die für uns die Kommunikation mit der DB übernimmt. Aus der Funktion wird das Resource Handle zurückgeliefert, mit dem wir weiterarbeiten können. Wer nicht versteht was in dieser Funktion vorsich geht, ist falsch in diesem Tutorial und sollte zunächst einmal einen PHP/MySQL Crashkurs durcharbeiten!

Mit der Zeile

if (isset( $_COOKIE['UserLogin'] ))

wird geprüft, ob ein Cookie existiert, das auf den Name UserLogin hört. $_COOKIE gehört, wie u.a. auch $_GET oder $_POST, ebenfalls zu den Superglobalen Arrays, die einen globalen Sichtbarkeitsbereich haben. Man kann also auch direkt aus einer Funktion darauf zugreifen. Der Name des Cookies stellt auch zugleich den Schlüssel des assoziativen Arrays dar. Wir können also mit $_COOKIE[‚UserLogin‘] auf den Inhalt des Cookies zugreifen. Genau das machen wir auch im SQL Statement, indem wir einen Datensatz in der DB suchen, dessen Feld cookie_hash mit dem Wert im Cookie übereinstimmt. Finden wir einen Datensatz, handelt es sich offenbar um einen uns bekannten User. In diesem Fall setzen wir unsere Variable $_SESSION[‚angemeldet‘] auf true und leiten den User auf unsere geheime Seite.

Wurde kein Cookie gefunden, wird die Seite mit der Loginform angezeigt. Nach Abschicken des Formulars bereiten wir die Benutzereingaben auf, erzeugen einen md5-Hash vom eingegebenen Passwort und benutzen dieses, zusammen mit dem Benutzername, um unser SQL Statement zu formulieren. Wir lesen hier das Feld cookie_hash aus -das beim Anlegen des Benutzerkontos erzeugt werden muß und einmalig sein sollte! (ein md5-Hash der Registrierzeit würde sich anbieten)- das zum Datensatz gehört, bei dem die eingegebenen Zugangsdaten übereinstimmen. Haben wir einen Treffer, sind die Zugangsdaten korrekt und wir können den Benutzer einloggen.
Wir setzen also die Variable $_SESSION[‚angemeldet‘] wieder auf true, schicken einen Cookie zum Benutzer, damit wir ihn später wiedererkennen und leiten den Benutzer auf die geheime Seite weiter.

Noch einmal zum setzen des Cookies. Der ganze Zauber findet in dieser Zeile statt:

setcookie( 'UserLogin', $usercookie['cookie_hash'], time()+600 );

Mit setcookie() senden wir einen Cookie an den Browser des Benutzers. Ob ein Benutzer den Keks annehmen möchte oder nicht, liegt allein beim Benutzer. Es gibt keine Möglichkeit dem Benutzer einen Keks aufzuzwingen. Ebenso kann man nicht sofort prüfen ob der Keks beim Benutzer angekommen ist, sondern muß erst irgend eine Art von Reload auslösen. In unserem Fall geschieht das durch den header()-Redirect. Die Funktion setcookie() erwartet einige Parameter:

  • $name – Der Name des Cookie, über den wir den Inhalt auslesen können ($_COOKIE[$name])
  • $wert – Der Inhalt des Cookie. In der Regel Informationen die wir dort selbst abgelegt haben
  • $verfall – Ein Zeitstempel als INT Wert, bis wann der Keks gültig ist. (z.b. time()+60*60*24*30 wenn der Keks 30 Tage lang gültig sein soll)
  • $pfad – Damit kann man festlegen für welches Verzeichnis (mit Unterverzeichnisse) der Keks gültig sein soll. Wird kein Pfad angegeben, wird standardmäßig das Verzeichnis genommen in dem der Keks abgesetzt wurde.
  • $domain – Damit kann man den Keks allgemeingültig machen oder auf einzelne Sub-Domains beschränken
  • $sicher – Legt fest ob der Keks nur gültig ist, wenn er über eine HTTPS Leitung gesendet wurde. Der Wert von $sicher kann true oder false sein.
  • $nurHTTP – true oder false und legt fest, ob der Keks nur akzeptiert wird, wenn er über das HTTP Protokoll geschickt wurde. Es ist also nicht möglich den Keks durch Scripts o.ä. zu schicken. Dieser Wert sollte eigentlich immer auf true gesetzt werden, da er XSS Angriffe ungemein erschwert und somit wesentlich zur Sicherheit beiträgt. Hinweis: diese Option wurde erst mit PHP 5.2.0 eingeführt!

Damit sollte das Login Script soweit klar sein. Kommen wir nun zur geheimen Seite; hier das Listing:

<?php
 
// Session starten
session_start();
 
// Prüfen ob der Benutzer angemeldet ist
if (!$_SESSION['angemeldet'])
{
    // Zum Login umleiten
    header( 'location: login_fortgeschritten.php' );
    exit;
}
 
// Abmelden, Cookie löschen und zum Login umleiten
if ($_GET['keks'] == 'loeschen')
{
    setcookie( 'UserLogin', '', time()-3600 );
    session_destroy();
    header( 'location: login_fortgeschritten.php' );
    exit;
}
 
?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="de" lang="de">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Geheime Seite</title>
</head>
 
<body>
 
<h3>Willkommen im geschützten Bereich! ;-)</h3>
 
<p><a href="<?php echo $_SERVER['PHP_SELF']; ?>?keks=loeschen">Abmelden und Cookie löschen</a></p>
 
</body>
</html>

Der HTML Teil ist wieder unspektakulär. Im Vergleich zu vorher ist hier nur ein Link eingefügt, mit dem sich ein Benutzer abmelden kann.
Im oberen Teil wieder das übliche Spiel: Session starten!
Anschließend prüfen wir ob die Variable $_SESSION[‚angemeldet‘] gesetzt ist. Falls nicht, leiten wir den Benutzer zur Login Seite. Der aufmerksame Leser wird jetzt feststellen: „Aber wieso haben wir einen Keks gesetzt, wenn der User die Seite hier nicht direkt aufrufen kann?“. Das ist korrekt, da der Wert in der Session noch nicht gesetzt wurde, wird der Benutzer zum Login umgeleitet. Auf der Login Seite wird aber als erstes geprüft ob ein Cookie existiert und leitet ggfs. wieder zur geheimen Seite weiter. Es ist also quasi nur eine doppelte Umleitung.

Als letztes im Script wird noch geprüft ob die Variable $_GET[‚keks‘] den Wert loeschen hat und falls dem so ist, löschen wir den Keks, zerstören die Session und leiten den Benutzer zur Login Seite weiter.
Um ein Cookie zu löschen muß es von der gleichen Domain gesendet werden, den selben Namen haben und die Zeit bis zum Verfall muß in der Vergangenheit liegen. Dann, und nur dann, weiß der Browser, dass der Keks gelöscht werden soll.

Bevor wir uns an die Variante Profi machen möchte ich noch erwähnen, dass die Anmeldung bei Login Scripts, sowie die Kommunikation zwischen Server und Client bei sensiblen Daten stets über eine sichere HTTPS Leitung geschehen sollte, da eine normale, unverschlüsselte Leitung nicht sicher ist. Speziell dieser Punkt ist bei einfachen Webpaketen nicht immer möglich, da extra eine SSL Lizenz für eine verschlüsselte Leitung gekauft werden müsste, die oftmals mehrere Hundert Euro pro Jahr kostet. Manche Provider bieten aber, bei höherwertigen Paketen, einen SSL Proxy an. Dann ist man zwar nur über eine allgemeine Domain (meist nach dem Schema https://ssl.provider.de/meine-domain/) erreichbar, aber dafür ist es wenigstens sicher!

Variante Profi

Die Variante Profi legt Wert auf einen möglichst hohen Sicherheitgrad. Manche der hier gezeigten Dinge sind auf Mietwebspace meistens, falls überhaupt, nur bei sehr guten Providern möglich, da sie ein Anpassen der php.ini erfordern. Schauen wir uns zunächst einmal das Listing der Datei login_profi.php an. Danach erläutere ich die einzelnen Schritte und binde eigene Funktionen mit in die Erklärung ein. Sämtliche Funktionen wurden in die Datei funktionen.inc.php ausgelagert. Das komplette Script gibt es am Ende des Tutorials noch einmal als Download zum selber testen. Inhalt der Datei login_profi.php:

<?php
 
// Fehlermeldungen unterdrücken
error_reporting( 0 );
 
// Erzwingen das Session-Cookies benutzt werden und die SID nicht per URL transportiert wird
ini_set( 'session.use_only_cookies', '1' );
ini_set( 'session.use_trans_sid', '0' );
 
// Session starten
session_start();
 
// Sicherstellen das die SID durch den Server vergeben wurde
// um einen möglichen Session Fixation Angriff unwirksam zu machen
if (!isset( $_SESSION['server_SID'] ))
{
    // Möglichen Session Inhalt löschen
    session_unset();
    // Ganz sicher gehen das alle Inhalte der Session gelöscht sind
    $_SESSION = array();
    // Session zerstören
    session_destroy();
    // Session neu starten
    session_start();
    // Neue Server-generierte Session ID vergeben
    session_regenerate_id();
    // Status festhalten
    $_SESSION['server_SID'] = true;
}
 
// Funktionen einbinden
include( 'funktionen.inc.php' );
 
// Variablen deklarieren
$_SESSION['angemeldet'] = false;
$conid                  = '';
$eingabe                = array();
$anmeldung              = false;
$update                 = false;
$fehlermeldung          = '';
 
// Datenbankverbindung öffnen
$conid = db_connect();
 
// Wenn das Formular abgeschickt wurde
if (isset( $_POST['login'] ))
{
    // Benutzereingabe bereinigen
    $eingabe = cleanInput();
    // Benutzer anmelden
    $anmeldung = loginUser( $eingabe['benutzername'], $eingabe['passwort'], $conid );
    // Anmeldung war korrekt
    if ($anmeldung)
    {
        // Benutzer Identifikationsmerkmale in DB speichern
        $update = updateUser( $eingabe['benutzername'], $conid );
        // Bei erfolgreicher Speicherung
        if ($update)
        {
            // Auf geheime Seite weiterleiten
            header( 'location: geheim_profi.php' );
            exit;
        }
        else
        {
            $fehlermeldung = '<h3>Bei der Anmeldung ist ein Problem aufgetreten!</h3>';
        }
    }
    else
    {
        $fehlermeldung = '<h3>Die Anmeldung war fehlerhaft!</h3>';
    }
}
 
?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="de" lang="de">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Login Script</title>
</head>
 
<body>
 
<?php
// Falls die Fehlermeldung gesetzt ist
if ($fehlermeldung) echo $fehlermeldung;
?>
 
<!-- Hier steht die Login-Form -->
 
</body>
</html>

Als erstes setzen wir das Error Reporting auf 0, damit sämtliche Fehlermeldungen unterdrückt werden. So gut Fehlermeldungen während der Entwicklung sind, so schädlich können sie im live Einsatz sein. Fehlermeldungen verraten einem Angreifer sehr viel über die Struktur einer Datei, Datenbank, Funktionsweise des Scripts und erleichtern somit seine Arbeit.
Die nächsten beiden Zeilen betreffen die php.ini. Damit wird erzwungen, dass die Session Kennung via Session Cookie transportiert wird. Auf diese Weise kann man keine Referrer mit sichtbarer Kennung in fremden Logfiles hinterlassen, was zu gestohlenen Sessions (Session Hijacking) führen kann. Ob und in welchem Umfang man Zugriff auf die php.ini hat, ist von Provider zu Provider unterschiedlich. Manche erlauben ein verändern der Werte via ini_set() oder via .htaccess, während wieder andere Provider eigene php.ini Dateien direkt im Verzeichnis erlauben oder man kann über die Accountverwaltung Werte anpassen. Die Frage ob und wie das bei Euch möglich ist kann nur euer Provider beantworten!
Dann folgt das obligatorische starten der Session. Da ein Benutzer mit einer bereits aktiven Session auf unsere Seite kommen kann und unser session_start() in diesem Fall lediglich die Session fortführen würde, müssen wir sicherstellen, dass die Session auch tatsächlich zuvor von unserem Server initiiert wurde und nicht aus fremder Quelle übergeben wurde. Dieses „aus fremder Quelle“ übergeben einer Session Kennung an ein potentielles Opfer nennt sich Session Fixation. Ein Angreifer kann eine Session nur dann übernehmen/stehlen, wenn die Session Kennung bekannt ist. Da der Angreifer diese Kennung selbst erzeugt haben kann und an sein Opfer weitergegeben hat, kennt er die Kennung natürlich! Um das zu verhindern stellen wir sicher, dass nur eine von uns selbst vergebene Kennung Gültigkeit hat.
Dazu prüfen wir, ob in der Session die Variable $_SESSION[’server_SID‘] den Wert true hat. Trifft das nicht zu, resetten wir die Session, wandeln die Session in ein leeres Array um, zerstören die Session, starten die Session komplett neu, generieren eine neue Kennung (dem session_regenerate_id() fällt eine besonders wichtige Rolle zu, auf die ich weiter unten noch detaillierter eingehen werde) und setzen letztendlich den Wert der Variable $_SESSION[’server_SID‘] auf true.
Durch das starten, vollständige zerstören, neu starten der Session stellen wir sicher, dass unser Script gegen Session Fixation abgesichert ist!
Jetzt binden wir die benötigten Funktionen ein, bereiten einige Variablen vor und öffnen eine Datenbankverbindung, deren Handle wir in $conid ablegen.

Wir prüfen ob das Forumlar abgeschickt wurde und falls ja, bereinigen wir erst einmal die Benutzereingabe.

$eingabe = cleanInput();

Die dazugehörige Funktion sieht so aus:

function cleanInput()
{
    // Maskierende Slashes aus POST Array entfernen
    if (get_magic_quotes_gpc())
    {
        $eingabe['benutzername'] = stripslashes( $_POST['benutzer'] );
        $eingabe['passwort']     = stripslashes( $_POST['passwort'] );
    }
    else
    {
        $eingabe['benutzername'] = $_POST['benutzer'];
        $eingabe['passwort']     = $_POST['passwort'];
    }
    // Trimmen
    $eingabe['benutzername'] = trim( $eingabe['benutzername'] );
    $eingabe['passwort']     = trim( $eingabe['passwort'] );
    // In Kleinschrift umwandeln
    $eingabe['benutzername'] = strtolower( $eingabe['benutzername'] );
    // Eingabe zurückgeben
    return $eingabe;
}

Große Erklärungen sollten nicht nötig sein. Die Eingabe wird aufbereitet und ein Array mit dem Benutzername und Passwort zurückgeliefert, wonach im öffentlichen Bereich des Scripts fortan das Array $eingabe zur Verfügung steht.

Nun wird geprüft, ob ein entsprechender Benutzer in unserer Datenbank existiert.

$anmeldung = loginUser( $eingabe['benutzername'], $eingabe['passwort'], $conid );

Die Funktion loginUser() erwartet 3 Parameter: 1) Benutzername 2) Passwort 3) Verbindungskennung. Die Rückgabewert wird in $anmeldung abgelegt und kann entweder true (Benutzer gefunden) oder false (Login fehlerhaft) sein.
Diese Funktion loginUser() ist der größte Brocken im ganzen Script, also werfen wir mal einen Blick auf das, was in der Funktion passiert.

function loginUser( $benutzer, $passwort, $conid )
{
    // Anweisung zusammenstellen
    $sql = "SELECT
                `passwort_zusatz`
            FROM
                `login_profi`
            WHERE
                LOWER(`benutzername`) = '" .mysql_real_escape_string( $benutzer ). "' AND
                `aktiviert` = 1";
    // Anweisung an DB schicken
    $ergebnis = mysql_query( $sql, $conid );
    // Wurde ein Datensatz gefunden, existiert dieser Benutzername, also
    // prüfen wir ob die Anmeldedaten korrekt ist
    if (mysql_num_rows($ergebnis) == 1)
    {
        $datensatz = mysql_fetch_array( $ergebnis );
        // Resourcen freigeben
        mysql_free_result( $ergebnis );
        // Anmeldepasswort vorbereiten
        $zusatz    = $datensatz['passwort_zusatz'];
        $anmeldepw = md5( $passwort.$zusatz );
        // Anweisung zusammenstellen
        $sql = "SELECT
                    `id`, `fehlversuche`
                FROM
                    `login_profi`
                WHERE
                    LOWER(`benutzername`) = '" .mysql_real_escape_string( $benutzer ). "' AND
                    `passwort` = '" .mysql_real_escape_string( $anmeldepw ). "' AND
                    `aktiviert` = 1";
        // Anweisung an DB schicken
        $ergebnis = mysql_query( $sql, $conid );
        // Prüfen ob ein Datensatz gefunden wurde. In dem Fall stimmen die Anmeldedaten
        if (mysql_num_rows( $ergebnis ) == 1)
        {
            // Counter für Fehlversuche resetten
            $angriff = mysql_fetch_array( $ergebnis );
            if ($angriff['fehlversuche'] != 0)
            {
                $sql = "UPDATE
                            `login_profi`
                        SET
                            `fehlversuche` = 0
                        WHERE
                            LOWER(`benutzername`) = '" .mysql_real_escape_string( $benutzer ). "'
                        LIMIT
                            1";
                mysql_query( $sql, $conid );
            }
            // Resourcen freigeben
            mysql_free_result( $ergebnis );
            // Korrekte Anmeldung zurückgeben
            return true;
        }
        else
        {
            // Das angegebene Passwort war nicht korrekt, also gehen wir von einem Angriffsversuch aus
            // und erhöhen den Counter der fehlerhaften Anmeldeversuche
            $sql = "UPDATE
                        `login_profi`
                    SET
                        `fehlversuche` = `fehlversuche` + 1
                    WHERE
                        LOWER(`benutzername`) = '" .mysql_real_escape_string( $benutzer ). "'
                    LIMIT
                        1";
            mysql_query( $sql, $conid );
            // Abfragen ob das Limit von 10 Fehlversuche erreicht wurde und in diesem Fall ...
            $sql = "SELECT
                        `fehlversuche`
                    FROM
                        `login_profi`
                    WHERE
                        LOWER(`benutzername`) = '" .mysql_real_escape_string( $benutzer ). "'";
            $ergebnis = mysql_query( $sql, $conid );
            $anzahl = mysql_fetch_array( $ergebnis );
            mysql_free_result( $ergebnis );
            // ... das Konto deaktivieren
            if ($anzahl['fehlversuche'] > 9)
            {
                $sql = "UPDATE
                            `login_profi`
                        SET
                            `fehlversuche` = 0,
                            `aktiviert` = 0
                        WHERE
                            LOWER(`benutzername`) = '" .mysql_real_escape_string( $benutzer ). "'
                        LIMIT
                            1";
                mysql_query( $sql, $conid );
            }
        }
    }
}

Als erstes stellen wir eine Anweisung zusammen mit der wir das Feld passwort_zusatz auslesen, das zum Datensatz des Benutzername gehört und welches aktiviert=1 hat. Der Benutzername muß in der DB vom Typ Unique sein, damit es hier keine Überschneidungen gibt. Dieser Passwort-Zusatz wird auch Salt (eng. Salz) genannt und dient dazu Passwörter sicherer zu machen. Dieses Salt muß für jeden Benutzer einmalig zufällig erstellt werden (beim anlegen des Benutzer) und wird in der Datenbank abgelegt. Wurde von der DB ein Treffer zurückgeliefert wissen wir, das der Benutzername in unserem System existiert. Aus der Kombination eingegebenes Passwort + Salt aus der DB erzeugen wir einen md5-Hash, der unser tatsächliches Anmeldepasswort darstellt.

Klären wir noch schnell die Frage „Was soll das mit dem Salt?“.
Wenn mehrere Benutzer das selbe Passwort haben, so ist der md5-Hash immer identisch. Kennt man also ein Passwort mit zugehörigem Hash und sieht irgendwo diesen Hash wieder weiß man, dass der andere Benutzer das selbe Passwort verwendet. Der Angreifer hat also keinerlei Arbeit sich Zugang zu einem Account zu verschaffen. Um es den Angreifern noch einfacher zu machen gibt es sogenannte Rainbow Tables, in denen etliche Millionen Hashes gespeichert sind, mit denen man binnen Sekunden die echte Identität eines Passwort ermitteln kann. Weil wir aber einen zufälligen Salt mit in’s Passwort mischen, ist der Hash jedesmal anders, selbst wenn das reine Passwort identisch ist.
Lange Rede kurzer Sinn: Passwörter anhand ihres Hash zu erraten oder mithilfe von Rainbow Tables zurückzuwandeln ist unmöglich, wenn man Passwörter mit einem geheimen Salt versieht und den damit erzeugten Hash in der DB ablegt. Passwörter sollten niemals im Klartext gespeichert werden!

Zurück zum Script …
Wir haben jetzt also das Salt ausgelesen und unser Anmeldepasswort erzeugt. Wir schicken jetzt eine neue Anweisung an die Datenbank und lesen das Feld fehlversuche aus, das zum Datensatz gehört auf den der Benutzername + Anmeldepasswort passt. Haben wir einen Treffer, stimmen die Anmeldedaten offensichtlich überein und es handelt sich hier um unseren User. Wir resetten noch kurz den Wert von fehlversuche, falls dieser nicht 0 ist und melden mit return true; die erfolgreiche Anmeldung an’s Hauptscript.

Haben wir bei der Abfrage mit dem Benutzername + Passwort keinen Treffer erzielt wissen wir, dass zwar der Benutzername stimmt, nicht aber das Passwort! Da wir auf Sicherheit setzen, gehen wir also vom Schlimmsten aus und denken erst mal, dass es sich um einen Angriff handelt. Deswegen aktualisieren wir im else-Zweig das Feld fehlversuche und erhöhen den Counter um 1. Anschließend lesen wir das Feld fehlversuche erneut aus um zu sehen, ob das Limit Fehlversuche (in unserem Fall 10) erreicht wurde. Da sich vermutlich kein Mensch 10 mal beim eigenen Passwort verschreibt, hat hier jemand versucht mit Brute Force das Passwort zu knacken. Um dem einen Riegel vorzuschieben, deaktivieren wir das Benutzerkonto, indem wir aktiviert auf 0 setzen. Jetzt kann unser Angreifer so lange probieren wie er möchte, er wird sich nicht einloggen können, selbst wenn das Passwort korrekt wäre. Wer aufgepasst hat wird gesehen haben, dass wir bei jeder DB Abfrage immer den aktiviert Status mit einbezogen haben. Ist das Konto deaktiviert, kann man gar nichts machen, bis das Konto vom Admin wieder aktiviert wurde.

Wir haben nun also geklärt ob die eingegebenen Benutzerdaten korrekt waren oder nicht, oder ob jemand versucht hat das Konto zu hacken. Demnach haben wir im Hauptscript in $anmeldung jetzt ein true oder false stehen. Im Falle von true geht’s weiter mit

$update = updateUser( $eingabe['benutzername'], $conid );

In der Funktion updateUser() findet die eigentliche Anmeldung statt. Zusätzlich speichern wir noch verschiedene Daten, anhand deren wir den Benutzer während des Aufenthalts auf unserer Seite identifizieren.

function updateUser( $benutzer, $conid )
{
    // Benutzer-Datensatz aktualisieren
    $sql = "UPDATE
                `login_profi`
            SET
                `ip` = '" .mysql_real_escape_string( $_SERVER['REMOTE_ADDR'] ). "',
                `benutzerinfo` = '" .mysql_real_escape_string( $_SERVER['HTTP_USER_AGENT'] ). "',
                `anmeldung` = '" .mysql_real_escape_string( md5( $_SERVER['REQUEST_TIME'] ) ). "',
                `zuletzt_aktiv` = NOW()
            WHERE
                LOWER(`benutzername`) = '" .mysql_real_escape_string( $benutzer ). "'
            LIMIT
                1";
    mysql_query( $sql, $conid );
    // Prüfen ob der datensatz aktualisiert wurde
    if (mysql_affected_rows( $conid ) == 1)
    {
        // Session Variablen setzen
        $_SESSION['angemeldet']   = true;
        $_SESSION['benutzername'] = $benutzer;
        $_SESSION['anmeldung']    = md5( $_SERVER['REQUEST_TIME'] );
        return true;
    }
}

Wie wir sehen aktualisieren wir den Benutzerdatensatz und speichern neben der IP Adresse auch die Identifikationsmerkmale des Browsers (Type, OS, Build Version, usw.). Da die IP Adresse generell eher ungeeignet ist als Identifikationsmerkmal, und auch die Browserkennung nicht einmalig ist, speichern wir auch den md5-Hash der Anmeldezeit. Dieser Wert dürfte, in Verbindung mit dem Benutzername, ziemlich zuverlässig sein. Wurde der Datensatz erfolgreich aktualisiert, findet erst jetzt die eigentliche Anmeldung statt!
Nun setzen wir die Session Variablen $_SESSION[‚angemeldet‘] auf true, sowie den Benutzername und die Anmeldezeit und liefern ein true in’s Hauptscript zurück. Anschließend leiten wir den Benutzer mit einer header()-Anweisung zur geheimen Seite weiter.

Wichtig: Wenn Fehler auftreten, sei es falsches Passwort oder der Benutzername ist unbekannt, teilen wir lediglich mit das bei der Anmeldung ein Fehler aufgetreten ist, nicht aber welcher! Würden ein Angreifer versuchen den Account mit dem Name Peterchen zu hacken und das System meldet „Benutzer unbekannt“, bräuchte der Angreifer gar nicht weiter versuchen. Wieso sollte er auch einen Account hacken, der gar nicht existiert?! Versucht er nun sein Glück mit dem Account Fritzchen und auf einmal taucht die Meldung „Passwort ist falsch“ auf, wo worher immer „Benutzer unbekannt“ stand, weiß der Angreifer, dass dieses Konto tatsächlich existiert und kann sich an’s knacken des Passworts machen.

Okay, der Benutzer ist nun auf der geheimen Seite geheim_profi.php:

<?php
 
ini_set( 'session.use_only_cookies', '1' );
ini_set( 'session.use_trans_sid', '0' );
 
// Session starten
session_start();
 
// Funktionen einbinden
include( 'funktionen.inc.php' );
 
// Datenbankverbindung öffnen
$conid = db_connect();
 
// Benutzer prüfen
if (!checkUser( $conid ))
{
    resetUser();
}
 
// Benutzer abmelden
if ($_GET['benutzer'] == 'abmelden')
{
    resetUser();
}
 
?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="de" lang="de">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Geheime Seite</title>
</head>
 
<body>
 
<h3>Willkommen im geschützten Bereich! ;-)</h3>
<p><a href="<?php echo $_SERVER['PHP_SELF']. "?benutzer=abmelden"; ?>">Benutzer abmelden</a></p>
 
</body>
</html>

Der obere PHP Teil, der so auf jeder (!) geschützten Seite stehen muß, ist bereits vertraut und bedarf (hoffentlich) keiner Worte!
Hier kontrollieren wir mithilfe von

if (!checkUser( $conid ))

ob der Benutzer sich auf dieser Seite aufhalten darf. Die Funktion checkUser() hat folgenden Inhalt:

function checkUser( $conid )
{
    // Alte Session löschen und Sessiondaten in neue Session transferieren
    session_regenerate_id( true );
    if ($_SESSION['angemeldet'] !== true) return false;
    // Benutzerdaten aus DB laden
    $sql = "SELECT
                `ip`, `benutzerinfo`, `anmeldung`, UNIX_TIMESTAMP(`zuletzt_aktiv`) as zuletzt_aktiv
            FROM
                `login_profi`
            WHERE
                `benutzername` = '" .mysql_real_escape_string( $_SESSION['benutzername'] ). "' AND
                `aktiviert` = 1";
    $ergebnis = mysql_query( $sql, $conid );
    if (mysql_num_rows( $ergebnis ) == 1)
    {
        $benutzerdaten = mysql_fetch_array( $ergebnis );
        // Resourcen freigeben
        mysql_free_result( $ergebnis );
        // Daten aus der DB mit den Benutzerdaten vergleichen
        if ($benutzerdaten['ip'] != $_SERVER['REMOTE_ADDR']) return false;
        if ($benutzerdaten['benutzerinfo'] != $_SERVER['HTTP_USER_AGENT']) return false;
        if ($benutzerdaten['anmeldung'] != $_SESSION['anmeldung']) return false;
        if (($benutzerdaten['zuletzt_aktiv'] + 600) <= $_SERVER['REQUEST_TIME']) return false;
    }
    else
    {
        return false;
    }
    // Letzte Aktivität aktualisieren
    $sql = "UPDATE
                `login_profi`
            SET
                `zuletzt_aktiv` = NOW()
            WHERE
                LOWER(`benutzername`) = '" .mysql_real_escape_string( $_SESSION['benutzername'] ). "'
            LIMIT
                1";
    mysql_query( $sql, $conid );
    // Status zurückgeben
    return true;
}

Das erste das in der Funktion geschieht ist

session_regenerate_id( true );

Diese Zeile ist ungemein wichtig! Diese PHP Funktion sorgt dafür, dass eine neue Session Kennung initiiert wird und der Inhalt der alten Session in die neue übernommen wird. Der Parameter true existiert erst seit PHP 5.2.x und sorgt dafür, dass nicht nur eine neue Kennung generiert wird, sondern es werden auch sämtliche alten Reste der vorherigen Session gelöscht. Das erhöht die Sicherheit von Sessions deutlich, da es nicht mehr möglich ist, dass sich Unbefugte über einen Shell-Zugriff die alte Session Datei auf dem Server aneignen und auslesen und so u.U. an geheime Informationen gelangen.

Als nächstes wird geprüft, ob der Benutzr mit $_SESSION[‚angemeldet‘] angemeldet ist. Nun lesen wir die gespeicherten Vergleichsmerkmale aus der DB aus und vergleichen jeden dieser Punkte mit dem aktuell angemeldeten Benutzer. Stimmt ein Wert nicht überein, liefern wir false zurück. In der Zeile

if (($benutzerdaten['zuletzt_aktiv'] + 600) <= $_SERVER['REQUEST_TIME']) return false;

prüfen wir zusätzlich, wie lange die letzte Aktivität auf der Seite zurückliegt. Wurde für 10 Minuten keine Aktivität registriert, wird der Benutzer zwangsabgemeldet. Das macht man deswegen, weil es Benutzer gibt die sich anmelden und dann auf anderen Seiten surfen oder den Rechner verlassen, während der Browser geöffnet bleibt. Das Problem dabei ist, dass die Session erst erlischt, wenn der Browser geschlossen wird. Mit anderen Worten: der Benutzer bleibt so lange angemeldet, bis er den Browser schließt, selbst wenn er schon lange nicht mehr auf der Seite war. Es könnte also jemand die Session übernehmen, während die Session aktiv ist. Durch das 10 Minuten Limit begrenzen wir das Zeitfenster in dem Schaden entstehen könnte.

Wurde die Funktion bisher nicht durch ein return false; verlassen, handelt es sich um den echten Benutzer und wir aktualisieren den Datensatz dahingehend, dass wir die Zeit der letzten Aktivität festhalten.

Kommen wir zur letzten Funktion. Wurde ein false zurückgeliefert oder der Benutzer klickt den „Abmelden“-Link, wird die Funktion resetUser() aufgerufen:

function resetUser()
{
    session_destroy();
    header( 'location: login_profi.php' );
    exit;
}

Hier geschieht nichts spektakuläres. Es wird lediglich der Inhalt der Session gelöscht und auf die Login Seite umgeleitet. Auf der Login Seite angekommen treten wieder unsere Anmelde- und Schutzmechanismen in Aktion.

Hinweis: Noch ein Wort zu den Erkennungsmerkmalen eines Benutzers. Während die Browserkennung als zusätzliches Merkmal durchaus brauchbar ist, auch wenn viele den selben Browser und OS verwenden, gibt es doch erstaunlich häufig Abweichungen in SP Nummern, Build Versionen, etc., so ist die IP in den meisten Fällen ungeeignet!
Zum einen kann es sein das Benutzer durch Proxies surfen und sich somit ständig die IP ändert (z.B. AOL User benutzen zwangsweise Proxies, weil AOL es seinen Benutzern diktiert!), zum anderen sind speziell in Firmen meistens NAT Netzwerke installiert, wodurch sich eine handvoll bis mehrere Hundert oder Tausend Benutzer eine identische IP teilen. Man sollte sich also überlegen, ob man die IP zur Identifikation benutzen möchte! Auf jeden Fall sollte aber noch etwas einmaliges zur Identifizierung genommen werden. In unserem Beispiel ist das der Benutzername in Kombination mit dem Hash der Anmeldezeit.

Fazit

Damit kommen wir zum Ende dieses Tutorials. Es wurde mal wieder länger als geplant, aber beim Thema Sicherheit sollte man das verzeihen können. 🙂
Bleibt zu hoffen, dass der ein oder andere etwas damit anfangen kann und die eigene Seite damit etwas sicherer wird. Um Missverständnisse auszuschließen hier noch der Hinweis:
Dieses Tutorial soll als Grundlage für eigene Login System dienen und ist nicht dazu gedacht, dass es genau so 1:1 übernommen wird! Ebenso geschieht die Benutzung auf eigene Gefahr und weder das Traum-Projekt noch der Autor dieses Tutorials können für Schäden, die durch das Script entstanden sein könnten, haftbar gemacht werden!

Zum Abschluß noch einige Links zu den verwendeten Funktionen und weiterführende Informationen zum Thema Sessions und Sicherheit.

Linkübersicht

Sessions und Sicherheit

PHP Funktionen

Christof Servit Administrator
Inhaber und Entwickler bei servit.biz

Ich bin spezialisiert auf die Bereiche Webentwicklung, WordPress und .net Applikationen.
Dabei spielt es keine Rolle ob Frontend oder Backend (Full-Stack). Ich unterstütze meine Kunden bei der Enwicklung, Beratung und Einrichtung in allen Bereichen.

follow me
Entwickler gesucht? Ich kann Ihnen helfen.
Ich bin spezialisiert auf die Bereiche Webentwicklung, WordPress und .net Applikationen.
Dabei spielt es keine Rolle ob Frontend oder Backend (Full-Stack).
Jetzt kontaktieren

Kommentar verfassen

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.

Scroll to Top