borisbaer: Remember-Me-Funktion einrichten (CSRF-Token erstellen)

Hallo!

Ich versuche gerade, das Konzept der Remember-Me-Funktion nachzuvollziehen.

Im Moment ist es so, dass ich beim Login einfach nur die Session-Variable für die UserID speichere:

public function signIn(): void
{
	session_regenerate_id( true );

	$user = parent::read( [ 'username' => $this -> username ], 'obj' );

	$_SESSION['id'] = $user -> id;
	$_SESSION['username'] = $user -> username;

	header( 'Location:' . $_SERVER['HTTP_REFERER'], true, 303 );
}

Wenn ich den Browser schließe, dann bleiben beide Variablen erhalten. Wie ich gelesen habe, ist in der PHP-Config eingestellt, wie lange die Session-Variablen bzw. Cookies „überleben“.

Gerne würde ich es so programmieren, dass im Normalfall die Cookies nach dem Schließen des Browsers gelöscht werden, es sei denn man aktiviert das Häkchen bei „eingeloggt bleiben“.

EDIT: Okay, eine Frage konnte ich mir mittlerweile selbst beantworten. Das Normalverhalten ist das Löschen der SESSID, doch dies wird verhindert, wenn man beim Browser einstellt, die Tabs der letzten Sitzung beim Start wiederherzustellen. Deswegen wurde nichts bei mir gelöscht.

Nun habe ich aber auch gehört, dass es so einfach nicht ist mit dieser Angelegenheit, weil es Cross-Site Request Forgery gibt. Wenn ich bei meinem Firefox-Browser unter Web-Speicher den Reiter Cookies öffne, dann sehe je einen Eintrag für phpMyAdmin, PHPSESSID und pma_lang. Daneben befindet sich ein Wert. Ist das die Gefahrenquelle? Ich muss sagen, ich verstehe das ganze Konzept von CSRF nicht, nur dass man da vorbeugen sollte. Wo wird der CSRF-Token übergeben? Bei Datenbank-Interaktionen? Mir stellen sich da zig Fragen.

Zudem wollte ich noch fragen, ob ein session_unset() beim Sign-off reicht oder ein session_destroy() her sollte.

Grüße
Boris

akzeptierte Antworten

  1. Hallo Boris,

    ich habe deinen Code als PHP ausgezeichnet – das kannst du in Zukunft auch selbst tun 😉

    Viele Grüße
    Robert

  2. Hallo borisbaer,

    der PHP Sessioncookie sollte immer temporär sein.

    Ein RememberMe-Token ist dagegen ein persistenter Cookie, heißt: Unter der "Remember Me" Checkbox muss ein DSGVO-Hinweis stehen („Wenn Sie diesen Schalter aktivieren, gestatten Sie, dass auf Ihrem Computer ein Cookie gespeichert wird“ - oder so). Inhalt des RememberMe-Tokens ist beispielsweise ein langer Zufallswert, den Du auch in der User-DB speicherst. Kommt ein Webrequest herein, für den die Session leer ist, aber für den ein RememberMe Cookie vorliegt, kannst Du den zugehörigen User automatisch anmelden.

    Problem 1: Was machst Du, wenn sich ein User auf Gerät 1 mit "Remember Me" anmeldet und dann von Gerät 2 kommt. Er meldet sich dort auch an und aktiviert "Remember Me". Speicherst Du nun auf beiden Geräten das gleiche Token?

    Wenn ja, wird sich dieses Token im Lauf der Zeit auf diverse Geräte verbreiten. Ist das sicher? Hm. Weiß nicht. Aber vermutlich unvermeidbar, denn:

    Wenn nein, überschreibst Du das Token in der DB durch ein anderes und machst damit den "Remember Me" vom ersten Gerät kaputt.

    Problem 2: Wenn es irgendwie gelingt, das Token zu "klauen" (keine Ahnung welche boshaften Leaks es dafür gibt) und auf ein anderes Gerät zu implantieren, ist damit auch der Login geklaut. Eigentlich sollen die Browser ja dafür mittlerweile die nötigen Schutzschirme aufrichten, und bei https-Transport und einer HttpOnly Kennung am Cookie sollte das auch vor JavaScript und Drahthaien versteckt sein, aber HTTPS lässt sich aufbrechen, wenn Du auf einem Fremd-PC unterwegs bist, dem man geeignete Zertifikate untergeschoben hat (wie z.B. der, auf dem ich meine Brötchen verdiene. Mein Arbeitgeber bricht https auf und signiert den Traffic mit dem eigenen Zertifikat neu. Der PC ist in der Firmen-Domäne, bekommt das Zertifikat per Policy aufgezwungen und schon ist https für den Eimer).

    Eine Alternative zu einem Remember-Me Zufallstoken, das im Userprofil gespeichert ist, ist ein JWT (JSON Web Token). Das ist ein vorgeschlagener Internetstandard für die Vermittlung einer Authentication durch Identitätsprovider. Ein JWT ist letztlich ein JSON-Objekt mit geeigneten Inhalten, dass Du zum String machst, base64-codierst, mit einem Server-Key signierst und beispielsweise als Cookie übermittelst. Im JWT kannst Du z.B. den Usernamen ablegen. Wenn Du magst, kannst Du den JSON-String auch noch aufwändig verschlüsseln, bevor Du ihn signierst.

    Kommt ein Request ohne Login, aber mit korrekt signiertem JWT herein, akzeptierst Du das als gültigen Login. In regelmäßigen Abständen musst Du den Key erneuern, und JWTs, die mit der vorigen Keyversion hereinkommen, signierst Du neu. Wieviele vorherige Keyversionen du akzeptierst, musst Du von der gewünschten Lebensdauer des JWT-Cookie abhängig machen. Aber es gilt das gleiche wie beim Zufallstoken: Wird das JWT geklaut, hast Du ein Security-Loch.

    Was für mich bedeutet: RememberMe sollte für Anwendungen, bei denen gebrochene Security ein Problem darstellt, NICHT verwendet werden.

    Rolf

    --
    sumpsi - posui - obstruxi
    1. Hallo Rolf,

      Ein RememberMe-Token ist dagegen ein persistenter Cookie, heißt: Unter der "Remember Me" Checkbox muss ein DSGVO-Hinweis stehen („Wenn Sie diesen Schalter aktivieren, gestatten Sie, dass auf Ihrem Computer ein Cookie gespeichert wird“ - oder so).

      das kennt man ja. 🍪

      Inhalt des RememberMe-Tokens ist beispielsweise ein langer Zufallswert, den Du auch in der User-DB speicherst. Kommt ein Webrequest herein, für den die Session leer ist, aber für den ein RememberMe Cookie vorliegt, kannst Du den zugehörigen User automatisch anmelden.

      Problem 1: Was machst Du, wenn sich ein User auf Gerät 1 mit "Remember Me" anmeldet und dann von Gerät 2 kommt. Er meldet sich dort auch an und aktiviert "Remember Me". Speicherst Du nun auf beiden Geräten das gleiche Token?

      Wenn ja, wird sich dieses Token im Lauf der Zeit auf diverse Geräte verbreiten. Ist das sicher? Hm. Weiß nicht. Aber vermutlich unvermeidbar, denn:

      Wenn nein, überschreibst Du das Token in der DB durch ein anderes und machst damit den "Remember Me" vom ersten Gerät kaputt.

      Ja, das stimmt. Daran hatte ich gar nicht gedacht.

      Problem 2: Wenn es irgendwie gelingt, das Token zu "klauen" (keine Ahnung welche boshaften Leaks es dafür gibt) und auf ein anderes Gerät zu implantieren, ist damit auch der Login geklaut. Eigentlich sollen die Browser ja dafür mittlerweile die nötigen Schutzschirme aufrichten, und bei https-Transport und einer HttpOnly Kennung am Cookie sollte das auch vor JavaScript und Drahthaien versteckt sein, aber HTTPS lässt sich aufbrechen, wenn Du auf einem Fremd-PC unterwegs bist, dem man geeignete Zertifikate untergeschoben hat (wie z.B. der, auf dem ich meine Brötchen verdiene. Mein Arbeitgeber bricht https auf und signiert den Traffic mit dem eigenen Zertifikat neu. Der PC ist in der Firmen-Domäne, bekommt das Zertifikat per Policy aufgezwungen und schon ist https für den Eimer).

      Also sollte man immer secure und httponly aktivieren?

      Eine Alternative zu einem Remember-Me Zufallstoken, das im Userprofil gespeichert ist, ist ein JWT (JSON Web Token). Das ist ein vorgeschlagener Internetstandard für die Vermittlung einer Authentication durch Identitätsprovider. Ein JWT ist letztlich ein JSON-Objekt mit geeigneten Inhalten, dass Du zum String machst, base64-codierst, mit einem Server-Key signierst und beispielsweise als Cookie übermittelst. Im JWT kannst Du z.B. den Usernamen ablegen. Wenn Du magst, kannst Du den JSON-String auch noch aufwändig verschlüsseln, bevor Du ihn signierst.

      Was ein JWT habe ich zwar verstanden, aber keine Ahnung, wie man ihn im PHP-Code verwendet. Kennst du da eine erste Anlaufstelle?

      Kommt ein Request ohne Login, aber mit korrekt signiertem JWT herein, akzeptierst Du das als gültigen Login. In regelmäßigen Abständen musst Du den Key erneuern, und JWTs, die mit der vorigen Keyversion hereinkommen, signierst Du neu. Wieviele vorherige Keyversionen du akzeptierst, musst Du von der gewünschten Lebensdauer des JWT-Cookie abhängig machen. Aber es gilt das gleiche wie beim Zufallstoken: Wird das JWT geklaut, hast Du ein Security-Loch.

      Was für mich bedeutet: RememberMe sollte für Anwendungen, bei denen gebrochene Security ein Problem darstellt, NICHT verwendet werden.

      Das trifft bei mir nicht zu.

      Vielen Dank für deine Hilfe!

      Grüße
      Boris

      1. Hallo borisbaer,

        Kennst du da eine erste Anlaufstelle?

        Naja, den Wikipedia-Artikel. Es gibt natürlich auch Libs, die das für Dich tun wollen (kannst Du selbst googlen), aber letztlich steckt nicht viel dahinter.

        Du musst Dich auch nicht zwingend an JWT halten. Der Username, signiert mit einem SHA256 Key und base64-codiert, kann auch schon reichen.

        Aber JWT wäre schick - wer Dich hacken will, kriegt einen Wow-Effekt und traut Dir gleich Fort-Knox Security zu 🤣.

        Ich musste jetzt auch mal kurz lesen. Ein JWT ist entweder ein JWS (ein signiertes Token) oder ein JWE (ein verschlüsseltes Token). Für beide gibt's einen Internet-Standard (RfC): RFC7515 für JWS und RFC7516 für JWE. Wenn Du signieren UND verschlüsseln willst, sollst Du erst signieren und das Ergebnis in ein JWE-Token verpacken. Sagt RFC7519. Aber ich glaube, Dir reicht das Signieren 😀

        JWS geht so: du baust zwei assoziative Arrays auf. Das eine, der Header, ist immer gleich, das andere, den Inhalt kannst Du frei gestalten, solange Du das JWS keinem übermitteln musst, der eine Norm einfordert. Aber Du kannst Dich ja an RFC7515 orientieren und iss (Issuer), sub (Subject), iat (Ausstellzeitpunkt) und exp (Expiration) belegen.

        $header = [ "typ" => "JWT", "alg" => "HS256" ];
        $payload = [ "iss" => "https://example.org",    // Deine URL
                     "sub" => "borisbaer",              // User-ID für Autologon
                     "iat" => 123456789,                // Unix-Timestamp der Ausstellung
                     "exp" => 234567890 ];              // Unix-Timestamp für Ablauf
        

        Die beiden Unix-Timestamps musst Du natürlich mit PHP korrekt ermitteln.

        Die beiden Arrays musst Du jetzt - einzeln - als JSON-String formatieren, base64-codieren und danach nochmal URL-codieren (wegen des potenziellen = am Ende eines base64-Strings). Das Ganze hängst Du durch einen Punkt getrennt zusammen:

        $code = urlencode(base64_encode(json_encode($header, JSON_FORCE_OBJECT)))
              . "." 
              . urlencode(base64_encode(json_encode($payload, JSON_FORCE_OBJECT)));
        

        Diesen Code musst Du jetzt signieren. Dazu verwendest Du, weil "HS256" als Algorithmus angegeben ist, die hash_hmac-Funktion im PHP Deines Vertrauens und sagst ihr, sie soll "sha256" verwenden. Die zulässigen JSW-Algorithmen definiert übrigens RFC7518 und ich habe keine Ahnung, ob PHP die alle kennt. HMAC geht jedenfalls, und wenn Du mehr Bits nehmen willst, verwende den entsprechenden Code und den entsprechenden Parameter für hash_hmac.

        Die Signatur codierst Du ebenfalls mit base64 und url_encode und hängst sie mit einem weiteren Punkt hintendran.

        $signature = hash_hmac("sha256",
                               $code,
                               $aktueller_schluessel, // den musst Du irgendwo sicher ablegen
                               true);
        $code .= "." . urlencode(base64_encode($signature));
        

        Fertig. Das Ding steckst Du in einen Cookie. Der muss secure und HttpOnly sein, und deine Seite natürlich über https laufen.

        Wenn Du das JWS nutzen willst, liest Du den Cookie-Inhalt aus und trennst alles in $header, $payload und $signature auf. Im Header schaust Du nach dem Signaturalgorithmus, dann signierst Du $header.".".$payload nochmal neu und vergleichst das mit der mitgelieferten Signatur. Wenn's passt, ist es dein Token und Du kannst die User-ID aus dem Inhaltsteil holen.

        $tokenParts = explode(".", $jwsToken, 4);
        if (count($tokenParts != 3) {
           // ungültiges Token
        }
        $header = json_decode(base64_decode(urldecode($tokenParts[0])), true);
        // Ggf Header prüfen: Ists ein Array, ist typ=="JWT" und "alg"=="HS256"
        
        $payload = json_decode(base64_decode(urldecode($tokenParts[1])), true); 
        // Ist's ein Array, stimmt iss, ist exp noch nicht erreicht
        
        // Jetzt den Schlüssel passend zum iat Zeitpunkt heraussuchen! Wenn Du exp ein
        // Jahr nach iat setzt und bspw. 4x im Jahr den Schlüssel wechselt, brauchst Du
        // eine Historie von 5 Schlüsseln. 
        
        $tokensignature = base64_decode(urldecode($tokenParts[2]));
        $realsignature = hash_hmac("sha256",
                                   $tokenParts[0].".".$tokenParts[1],
                                   $genutzter_schluessel,
                                   true);
        
        if (!hash_equals($tokensignature, $realsignature)) {
           // falsche Signatur
        }
        
        // Wenn der Schlüssel ein älterer, aber noch gültiger Schlüssel war, das
        // Token neu signieren, damit es künftig mit dem aktuellen Schlüssel kommt
        
        // Jetzt kann der Autologin erfolgen.
        User::AutoLogin($payload["sub"]);
        

        Das hat mir jetzt Spaß gemacht, das rauszusuchen. Ausprobiert habe ich es nicht, also viel Glück damit 😉. Und ich versichere Dir: damit gewinnst Du den Over-Engineering Orden dritter Klasse 🤣

        Bin gespannt, welche Backpfeifen ich jetzt für mein Amateur-Gestümpere ernte. Bis vor einer Stunde wusste ich auch noch nicht, wie das alles geht.

        Rolf

        --
        sumpsi - posui - obstruxi
        1. Hallo

          Bin gespannt, welche Backpfeifen ich jetzt für mein Amateur-Gestümpere ernte. Bis vor einer Stunde wusste ich auch noch nicht, wie das alles geht.

          Falls es Verbesserungspotential gibt, dass von anderen erkannt wird, gibt es hoffentlich Ergänzungen und Korrekturen zu deinen Ausführungen. Aber selbst, wenn dabei Fehler von dir offenbart werden, sind sie, zumindest für mich, eine Einführung in dieses, mir bislang unbekannt gewesene, Thema. Danke dafür.

          Tschö, Auge

          --
          200 ist das neue 35.
        2. Hallo Rolf,

          wow, das muss ich erst mal sacken lassen und ausprobieren. Sobald ich die Zeit finde, versuche ich mich mal an deinem Vorschlag und melde zurück, wie es funktioniert hat! 😉

          Grüße
          Boris

          P.S.: Entschuldige die späte Antwort!