Session mit Cookie und Loginsysteme

Ein bischen mehr Klarheit zu diesem Thema täte vielen Entwicklern gut

Ungezählte Tutorials zu diesem Thema fassen diesen Komplex so an, als wäre das wahnsinnig kompliziert. Hier der erste erhellende Schritt:

Etabliere eine Session, die unabhängig davon ist, ob jemand eingeloggt ist, oder nicht. Ein späteres Login/Logout setzt auf eine bereits vorhandene Session auf, behält diese nach einem erfolgreichen Login bei oder handelt eine neue Session aus.

Session established (SID: d84b278f7b3be8de37b20325cec275e0)

Eine Session liegt vor, wenn sich der Webserver und der Client (Browser, Useragent: UA) auf einen gemeinsamen Schlüssel (Session-Key, Session-ID: SID) geeinigt haben. Im einfachsten Fall wird sowas über einen Cookie realisiert, die SID ist der Wert im Cookie und letzterer wird mit dem Ende einer Browsersitzung verworfen.

Das ist im Prinzip schon alles, war doch einfach, oder? Na gut, wir gehen das mal durch:

Muss bis hierher irgendwas gespeichert werden auf dem Server? Oha, nein, die Session steht auch so schonmal. SID siehe oben in der Überschrift. Wenn sich die SID bei jedem Reload ändert, sendet der Browser keinen Cookie, ansonsten steht die Session.

Speichern müssen wir erst dann, wenn es was zu Speichern gibt, einen erfolgreichen Login zum Beispiel. Damit wir diesen bei jedem weiteren Request serverseitig abfragen können!

Login

Der Besucher folgt dem Link zum Login-Formular und gibt dort seine Credentials (Benutzername, Passwort) ein. Jetzt wird es ein bischen komplizierter, diese Credentials müssen ja irgendwo hinterlegt sein, damit wir die prüfen können. Im einfachsten Fall, insbesondere wenn es nur eine Handvoll an privilegierten Benutzern gibt, sind die Credentials in einer Datei hinterlegt.

Auf jeden Fall brauchen wir eine solche Datei für den Administrator! Denn der muss ja ersteinmal in sein Backend reinkommen können. Also schauen wir uns mal um, was es da schon gibt, Benutzername(n) und Passwort(e) in einer Datei zu hinterlegen. Es wäre gut und sicher, wenn das Passwort als Hash in der Datei steht und wenn uns derselbe Hash-Algorithmus im Code zur Verfügung stehen würde, was die Prüfung ermöglicht.

Achtung: Es muss ein Einweg-Krypt-Algorithmus sein, warum!? Nun, das ist eine Frage des Vertrauens. Wenig vertrauenserweckend ist es, wenn die Benutzer wüssten, dass wir (oder ein Eindringling) denen ihre Passworte entschlüsseln könnten. Also: Einweg (krypt).

Für unsere Scriptsprache (PHP, Perl, ...) haben wir nun was gefunden, womit wir einerseits die Datei erstellen und anderererseits auch prüfen können, ob die Credentials passen. Die nächste Frage ist: Wo speichern wir einen erfolgreichen Login? Serverseitig, das ist klar, Datei oder Datenbank?

Auf jeden Fall ist die SID der Key in einer solchen Tabelle und wer es besonders sicher haben will, erzeugt vor dem Speichern eine neue SID (sofern für die bisherigen Session noch nichts anderes gespeichert wurde).

Serialisierte Daten

Zugegeben, PHP machts einfach, es gibt eine Variable $_SESSION und den Serializer gibts gratis dazu. PHP ist in Sachen Session jedoch ein bischen eigenwillig, das so zu konfigurieren, damit es auch das macht, was gewünscht ist.

In Perl müssen wir uns was einfallen lassen, oder wir verwenden Module, die möglicherweise auch mehr oder andere Sachen machen, als das was wir eigentlich brauchen. Also, was wir brauchen, ist nur ein Serializer für die Login-Tabelle und siehe da, es gibt sogar ein Modul im Core: Storable. Jetzt müssen wir uns nur noch darüber einig werden, ob wir für jede Login-Tabelle eine eigene Datei (Dateiname => SID) anlegen oder alle Login-Tabellen in einer einzigen Datei verwalten. Beides hat seine Vor- und Nachteile, wenn es eine gemeinsame Datei ist, müssen Schreib-Prozesse atomar sein sonst sind die Daten futsch.

Oder: Login-Tabelle in MySQL, könnte so aussehen:

+----------------------------+
| sid | user_id | time_stamp |
+----------------------------+

Der Zeitstempel ist wichtig, denn diese Tabelle wäre ab und an mal aufzuräumen und ein Login kann auf eine bestimmte Zeit begrenzt werden. Es soll ja Leute geben, die ihren Browser nie schließen ;)

Ach ja, noch etwas:

Über das Feld 'user_id' kann zu weiteren Tabellen gejoined werden, wo ein bischen mehr über den Benutzer steht, wichtig wäre auf jeden Fall die Gruppe. Hier kann sich ein DB-Designer nach Herzenslust austoben. Die Login-Tabelle kannste bauen, wie Du willst. Weiter unten kannst du lesen, wie ich das mache.

Ist Jemand eingeloggt?

Kein Problem das rauszukriegen, wir haben ja die SID und befragen die Login-Tabelle.

Die eigentliche Zugangskontrolle

Grundsätzlich muss bei jedem Request geprüft werden, ob für die in der Login-Tabelle vorhandene Benutzergruppe eine Berechtigung vorliegt, womit dem Ausliefern der Response nichts mehr im Wege steht. Also: Gruppe über die Login-Tabelle abfragen und mit dem für einen URL gesetzten Gruppen-Attribut, falls gesetzt, gegenprüfen.

Idealerweise werden Links zu URLs, welcher einer Zugangskontrolle unterliegen, gar nicht erst angeboten. Und noch besser ist es, wenn einer ACL unterstehende URLs gar nicht erst erreichbar sind oder gar einen völlig anderen Inhalt als Response ausliefern.

Hier zeigt sich die Mächtigkeit meines Frameworks: Für Benutzergruppen wird der komplette Content der gesamten Webpräsenz ausgetauscht, d.h., wenn ein Login in einer bestimmten Benutzergruppe vorliegt, wird eine völlig andere Website-Configuration geladen samt Routing-Table. Das hat den Vorteil, dass in einer Response-Klasse keine weiteren ACL-Prüfungen erforderlich sind, was den Code erheblich verkürzt.

Logout!

Die Session kaputtmachen, neue SID? Wäre eine Lösung, aber möglicherweise gibt es weitere Daten, wo wir danach nicht mehr rankommen würden. Das wäre also zu bedenken. Besser ist es, einfach den Eintrag in der Login-Tabelle zu entfernen.

Ein klärendes Schlusswort

Namentlich in PHP-Entwicklerkreisen ist SESSION ein Synonym für alles, was gespeichert werden kann und zwar in der SESSION (Logins, Warenkörbe, IP-Adressen,...). Leute, schmeißt das nicht alles in einen Topf! Wer meinen Artikel aufmerksam gelesen hat, hat auch mitbekommen, dass es bereits vor dem Login eine Session geben kann. Hier könnten nämlich auch Daten einer serverseitigen Speicherung würdig sein, die mit einem Login gar nicht zusammenhängen müssen, zum Beispiel ein Warenkorb. Login => Neue Session? Ok, dann ist aber auch der Warenkorb weg für alle Zeiten.

Was spricht dagegen, für Logins eine weitere SID in einem weiteren Cookie zu erzeugen? Nichts! Es sei denn, wir lassen die SID einfach weiterleben, egal ob ein Login erfolgte oder nicht. Der Haken in Sachen Sicherheit ist der: Vom UA ausgehend kann eine SID vorgegeben werden. Diese Lücke lässt sich schließen, wenn nach einem erfolgreichen Login die SID erneuert wird, die ist dann garantiert vom Server vorgegeben.

Und Überhaupt

Gehts auch ohne Cookie? Freilich, geht auch. Bedenke jedoch, dass sich damit gewisse Abhängigkeiten und Mehraufwände ergeben, ein solcher Parameter ist dann sozusagen reserviert und mehrere Parameter mit gleichem Namen ergeben eine Liste. Jeder zur Session gehörige Link und jedes Formular muss die SID als Parameter haben, damit die vom UA zurückgeschickt wird.

Meine Antwort: Machs mit Cookie. Wer Cookies nicht annimmt, will sich auch nirgendwo einloggen.

Anhang: Code Beispiele

Untenstehende Beispiele verstehen sich nicht als Komplettlösung. Sie zeigen nur, wie bestimmte Teilaufgaben in meinem Framework erledigt werden.

Session etablieren mit Perl

################### Session Utils #########################################
sub sid{
    my $self = shift || main->new(cookieabfrage => 1);
    my $cookiename = shift;
    my %cookies = CGI::Cookie->fetch;
    my $id = defined $cookies{$cookiename} ? $cookies{$cookiename}->value : do{
        $self->{NOCOOKIE} = 'Browser sendet keinen Cookie, bitte Seite neu laden...';
        '';
    };

    if($id){
        $self->{SID} = $id;
        return $id;
    }
    else{
        return if $self->{cookieabfrage};
        $self->{SID} = Methods->makesid;
        $self->header("Set-Cookie" => "$cookiename=$self->{SID}");
        return $self->{SID};
    }
}

Obenstehende Funktion sid() ist innerhalb Class 'main' eine Methode und kann entweder mit einer Instanz der Klasse main, oder ohne Instanz aufgerufen werden. Letzteres dient dazu, lediglich die SID abzufragen wenn es in der main noch keine Instanz dieser Klasse gibt. In diesem Fall erstellt die Funktion selbst eine Instanz mit dem Parameter 'cookieabfrage'.

Die SID selbst wird erzeugt mit Methods->makesid(), eine klassenunabhängige Methode, deren Code nur bei Bedarf kompiliert wird. Schließlich wird die SID als Attribut in die Instanz der Klasse main geschrieben und steht damit allen weiteren Methoden des Singleton zur Verfügung.

Der HTTP-Cookie-Header wird hier lediglich in einen Puffer gesetzt, ausgegeben wird er später.

Die Ausgabe von HTTP-Response-Headers puffern

################### Methods for the main class ############################
# setzt HTTP Header
sub header{
    my $self = shift;
    my %header = @_;
    %{$self->{HEADERS}} = (%{$self->{HEADERS}}, %header) if @_;
    my @hs = ();
    # Default Header Content-Type sicherstellen
    if(not exists $self->{HEADERS}{'Content-Type'}){
        $self->{HEADERS}{'Content-Type'} = 'text/html; charset=UTF-8';
    }
    foreach my $h(keys %{$self->{HEADERS}}){
        push @hs, "$h: $self->{HEADERS}{$h}";
    }
    return join("\n", @hs)."\n\n";
}

Die Ähnlichkeit des Methodname 'header' mit einer PHP-Builtin-Funktion ist kein Zufall. Header können so innerhalb einer Singleton-Factory praktisch an jeder beliebigen Stelle in den Ausgabe-Puffer gesetzt werden. Im return dieser Methode wird der Puffer komplett geliefert.

Credentials validieren

Für den Administrator-Login wurde eine Benutzername-Passwort-Datei mit dem Apache-Tool 'htpasswd' erstellt. Diese Datei wird jedoch nicht für eine Authorization-Basic verwendet, sondern mit einer eigenen Methode, welche denselben Apache-MD5-Algorithmus benutzt, verarbeitet. Dieser Algorithmus ist in diversen CPAN-Modulen zu finden.

        my $group = Methods->htpasswd(
            file => $file, # Passwort-Datei, mit htpasswd erstellt
            user => $user, # Benutzername aus Eingabe im Login-Formular
            pass => $pass, # Passwort aus Eingabe in Login-Formular
        ) or die $@;

Methods->htpasswd verwendet den Apache-MD5-Algorithmus und liefert die Benutzergruppe, wenn die Credentials passen.

Erfolgreichen Login speichern

        # Credentials OK ab hier
        $self->{SESSION}{LOGINTAB} = {
            group => $group,
            user  => $user,
            ts    => time(),
        };

$self->{SESSION} ist eine Referenz auf einen mit tie() gebundenen Hash. Zum Speichern wird die Instanz aus der Klasse extrahiert und die Methode write() aufgerufen.

 tied(%{$self->{SESSION}})->write;

Die Methode write() serialisiert mit einem eigens entwickelten Algorithmus rekursiv einen beliebig verschachtelten Hash in eine Tabelle nach MySQL. Die Login-Tabelle liegt dem Singleton (Response-Object) als Hash vor und kann an jeder beliebigen Stelle im Code befragt werden. Da es in der Login-Tabelle nur einen Benutzer geben kann, erübrigt sich hier die Angabe der SID, der gesamte Hash SESSION ist ohnehin nur einer bestimmten SID zugewiesen und wird bereits vor dem Erstellen des Response-Object (Instanz der Klasse main) innerhalb der main als Variable erstellt. Dem Konstruktor, welcher die Instanz der Klasse main erstellt, wird %SESSION dann übergeben.

Logout

Kurz und schmerzlos wird die Login-Tabelle einfach leergemacht:

        $self->{SESSION}{LOGINTAB} = {};
        tied(%{$self->{SESSION}})->write or die $@;

Redirection, vergiß den Status nicht

Wenn beim Login keine Fehler festgestellt wurden, oder beim Logout, wird umgeleitet. Entweder auf einen in der Website-Config vorgegebenen URL oder auf den für Login/Logout konfigurierten URL:

        $self->header(
            Status => '302 Moved',
            Location => $self->eav('location') ? $self->eav('location') : $self->{URL},
        );

Diese Header werden später ausgegeben, wenn die Response zusammengestellt wird und bis dahin keine Exceptions geworfen wurden.

Mechanize Login

Kurzum: Mit einem speziellen UserAgent UA soll der Login-Prozess automatisiert werden. Anschließend soll es möglich sein, einer ACL (Access Control List) unterstehende Webressourcen (z.B. Webservices) zu nutzen. Der Ablauf ist wie folgt:

Die HTTP-Spezifikation erlaubt eigene Response-Header, die müssen mit x- beginnen, gefolgt von ASCII-Zeichen. Ich verwende einen Header x-login, welcher nach einer UA-seitigen Befragung die dem Login entsprechende Benutzergruppe ausgibt, beispielsweise als Indikator für einen erfolgreichen Login.

Somit ist nur der Response-Header zu parsen zur Prüfung, was einer Automatisierung weiterer Prozesse entgegenkommt. Perl stellt eine Reihe an Modulen bereits im Core zur Verfügung, womit das Ganze umgesetzt werden kann.

#!/usr/bin/perl

use strict;
use warnings;
use LWP::UserAgent;
use HTTP::Request::Common qw(POST);
use HTTP::Cookies;
use HTTP::Headers;

# Objekt zum lokalen Cookie-Handling
my $cookie_jar = HTTP::Cookies->new(
    autosave => 1,
    ignore_discard => 1,
    file => "/tmp/keks",
);

sub login{
    my %cfg = (
        url  => '',
        user => '',
        pass => '',
    @_);

    my $h = HTTP::Headers->new;
    my $ua = LWP::UserAgent->new;

    # zuerst ein Request auf Login-URL und den Cookie einfangen
    # nur ein GET ohne Parameter
    # es wird die Session aufgebaut
    my $reqGET = HTTP::Request->new('GET', $cfg{url});
    my $resGET = $ua->request($reqGET);
    $cookie_jar->extract_cookies($resGET);
    $cookie_jar->save;   # Cookie sichern für spätere Verwendung

    # der nun folgende Request sendet die Credentials
    # und den eben erhaltenen Cookie im Request-Header
    my $content = qq(login=1;user=$cfg{user};pass=$cfg{pass});
    my $reqPOST = HTTP::Request->new('POST', $cfg{url}, $h, $content);
    $cookie_jar->add_cookie_header( $reqPOST );

    my $resPOST = $ua->request($reqPOST);
    print $resPOST->header('x-login'), "\n"; # gibt die der Anmeldung entsprechende Benutzergruppe aus
}

Nutze das Framework: Der spezielle x-Response-Header, welcher den Namen der Benutzergruppe beeinhaltet, falls ein Login vorliegt, wird zentral gesetzt.

    # die Anmelde-Gruppe immer senden, soweit vorhanden
    if($ro->{SESSION}{LOGINTAB}{group}){
        $ro->header('x-login' => $ro->{SESSION}{LOGINTAB}{group});
    }

Datenschutzerklärung: Diese Seite dient rein privaten Zwecken. Funktionsbedingt wird hier ein Session-Cookie verwendet der beim Schließen des Browsers gelöscht wird. Auf den für diese Domäne installierten Seiten werden grundsätzlich keine personenbezogenen Daten erhoben. Das Loggen der Zugriffe mit Ihrer Remote Adresse erfolgt beim Provider soweit das technisch erforderlich ist. s​os­@rolf­rost.de.