0%

Zustand und Identität · Übung

Führe eine Session auf dem Server

Ein lässt einen Client eine Erinnerung von einer Anfrage zur nächsten tragen. Diese Lektion gibt deinem Server etwas, woran es sich zu erinnern lohnt: eine Anmeldung. Sieh dir zuerst an, was der Server heute mit den Anfragen macht, die eine Anmeldung braucht. Starte das Szenario session des Hilfsprogramms:

python pl_http.py session

Es sendet vier Anfragen: GET /me, dann POST /login, dann noch einmal GET /me mit dem Cookie, das aus der Zeile set-cookie der Anmeldeantwort kopiert wurde, falls es eine gibt, und zuletzt GET /me mit cookie: session=not-a-real-id. Heute ist jede Antwort HTTP/1.0 404 Not Found mit {"error": "nothing is at this path"}, weil noch keiner der beiden Pfade existiert.

Sieh dir das POST /login an, das das Hilfsprogramm sendet:

client -> server  b'POST /login HTTP/1.1\r\nHost: books.test\r\nContent-Length: 0\r\n\r\n'  (61 bytes)

Es hat keinen Body und sagt trotzdem Content-Length: 0. Das ist die Regel aus Kapitel 5, Lektion 1: Dein read_body beantwortet jedes POST ohne Länge mit 411, bevor handle sich überhaupt den Pfad ansieht, also sagt ein POST, das nichts zu senden hat, genau das.

Behalte die Fakten, gib einen Schlüssel heraus

Der verlockende Entwurf steckt den Benutzer in das Cookie: set-cookie: user=guest. Das funktioniert, bis ein Client stattdessen cookie: user=ana sendet. Ein Cookie ist Text, den der Client speichert und zurücksendet, und der Client kann ihn nach Belieben ändern, also kann nichts darin als Tatsache gelten.

Deshalb bleiben die Fakten auf dem Server. Wenn sich jemand anmeldet, denkt sich der Server eine neue zufällige ID aus, speichert unter dieser ID in state["sessions"], wer sich angemeldet hat, und gibt dem Client in einem Cookie nur die ID. Wenn eine Anfrage mit diesem Cookie zurückkommt, schlägt der Server die ID nach:

client:  cookie: session=K7f...Qw
                         |
                         v
server:  state["sessions"]
           "K7f...Qw": {"user": "ana"}
           "p2Z...8c": {"user": "ben"}

Die ID zeigt auf eine Zeile, und die Zeile sagt, wer der Benutzer ist. Das Cookie zu ändern bringt einem Client nichts, außer er errät die ID einer anderen Zeile. Dieser Eintrag auf dem Server ist eine Session, und die ID ist ihre Session-ID. sample_state() gibt jetzt dafür ein leeres "sessions" zurück, neben den Benutzern und Tokens, die spätere Lektionen verwenden.

Wo hält dieser Server fest, wer angemeldet ist?

Eine ID, die niemand erraten kann

Solange eine Session lebt, funktioniert ihre ID wie ein Passwort: Jeder, der cookie: session=<that id> sendet, ist für den Server dieser Benutzer. Also muss die ID unmöglich zu erraten sein. Pythons secrets erzeugt genau dafür:

Try it

secrets.token_urlsafe(16) nimmt 16 zufällige Bytes vom und schreibt sie als 22 Zeichen aus Buchstaben, Ziffern, - und _. Sechzehn Bytes sind 128 zufällige Bits, viel zu viele Werte, um sie einzeln durchzuprobieren. Das Modul random, das du vielleicht von Spielen und Simulationen kennst, ist hier das falsche Werkzeug: Es ist darauf gebaut, schnell und wiederholbar zu sein, nicht unvorhersehbar. Verwende random für Simulationen und secrets für alles, was ein Angreifer nicht erraten darf.

Warum nummeriert man Sessions nicht 1, 2, 3?

start_session(state, user) erledigt diese Arbeit: ein Aufruf von secrets.token_urlsafe(16), über das Modul geschrieben als secrets.token_urlsafe, dann wird {"user": user} unter der neuen ID in state["sessions"] gespeichert und die ID zurückgegeben.

Das Cookie schreiben und lesen

session_cookie(session_id, *, max_age=SESSION_AGE) gibt den Wert des Headers set-cookie zurück: session=<id>; HttpOnly; Max-Age=<max_age>; Path=/. Du kannst ihn mit http.cookies.SimpleCookie bauen, wie in Lektion 1, indem du httponly, path und max-age setzt und OutputString() zurückgibst, oder mit einem . Die Reihenfolge der Attribute spielt keine Rolle. Eine ID aus token_urlsafe braucht keine Anführungszeichen, also schreiben beide Wege denselben Text. SESSION_AGE ist 3600, eine Stunde, und das Schlüsselwortargument lässt Lektion 7 eine 0 übergeben.

session_user(request, state) liest es zurück. request["headers"].get("cookie", []) ist eine der cookie-Werte der Anfrage, meistens einer. Für jeden Wert liefert http.cookies.SimpleCookie(value).get("session") das Cookie session oder None, wenn die Zeile keins hat. Schlag sein .value mit .get in state["sessions"] nach und gib den gespeicherten Benutzer zurück, oder None, wenn kein Wert eine lebende Session benennt.

Was gibt das aus?

import http.cookies

c = http.cookies.SimpleCookie("theme=dark; session=abc")
print(c["session"].value, len(c))

SimpleCookie hat eine scharfe Kante. Wenn ein Stück einer Zeile Cookie fehlerhaft ist, gibt es überhaupt keine Cookies zurück, ohne Meldung: session=abc; bad cookie here; x=1 ergibt ein leeres Ergebnis. Ein kaputtes Cookie aus einem anderen Teil einer Website würde dann die Session verdecken. Ein selbst geschriebener Parser könnte nachsichtiger sein; diese Lektion verlangt keinen.

Was /me antwortet

GET /me antwortet immer mit 200: {"user": "guest"} bei einer lebenden Session, {"user": null} ohne Cookie, bei einer unbekannten ID oder einer beendeten Session. json.dumps schreibt Pythons None als null. Manche APIs antworten hier stattdessen mit 401. Eine 401 muss aber in einem Header WWW-Authenticate einen Anmeldeweg nennen, den der Client unter dieser verwenden kann, und eine Cookie-Session hat kein solches Verfahren, das sie nennen könnte. Also sagt dieser Server „niemand“ mit einer 200. Lektion 5 bringt die erste 401, auf /login, wo es ein Verfahren gibt.

Deine Aufgabe

In reading_server.py:

  1. Schreib start_session(state, user).

  2. Schreib session_cookie(session_id, *, max_age=SESSION_AGE).

  3. Schreib session_user(request, state).

  4. Füge in handle einen Zweig für den Pfad /login hinzu: Ein POST startet eine Session für "guest" und antwortet mit 200, {"user": "guest"} und einem Header set-cookie aus session_cookie; jede andere bekommt not_allowed("POST"). Füge einen Zweig für /me hinzu: GET und HEAD antworten mit 200 und {"user": ...} aus session_user; jede andere Methode bekommt not_allowed("GET, HEAD").

Bis Lektion 5 ist jede Anmeldung guest, egal was sie sendet: Es gibt noch keine Zugangsdaten zu prüfen.

Starte das Szenario noch einmal:

python pl_http.py session

Das erste GET /me antwortet jetzt {"user": null}. Die Anmeldung antwortet mit 200, und unter ihrer Antwort gibt das Hilfsprogramm die Zeile set-cookie aus: session=, dann 22 Zeichen, die sich bei jedem Lauf ändern, dann HttpOnly; Max-Age=3600; Path=/. Die dritte Anfrage trägt dieses Cookie und bekommt {"user": "guest"}; die ausgedachte ID bekommt {"user": null}.

Die Sessions leben im Zustands-Dictionary, also halten sie so lange wie ein Lauf des Hilfsprogramms. Max-Age bittet den Client, das Cookie nach einer Stunde zu vergessen, aber nichts auf diesem Server beendet eine Session nach Zeit: Er liest keine Uhr. Lektion 7 beendet Sessions auf Anfrage.

Drück dann Check my work. Der Server gibt jetzt ein Cookie heraus und erkennt es wieder, aber das Hilfsprogramm musste das Cookie von Hand in die nächste Anfrage kopieren. In der nächsten Lektion tut ein Client das selbst.

Aufgabe

Führe in reading_server.py eine Anmelde-Session auf dem Server.

  • start_session(state, user) erzeugt eine neue ID mit secrets.token_urlsafe(16), über das geschrieben (import secrets steht bereits oben; secrets.token_hex(16) wird auch akzeptiert, ebenso jede Größe ab 16 Bytes), speichert {"user": user} darunter in state["sessions"] und gibt die ID zurück.

  • session_cookie(session_id, *, max_age=SESSION_AGE) gibt den set-cookie- session=<id>; HttpOnly; Max-Age=<max_age>; Path=/ zurück, mit den Attributen in beliebiger Reihenfolge und ohne Secure, wie Lektion 1 erklärt hat. Bau ihn mit http.cookies.SimpleCookie oder einem .

  • session_user(request, state) liest das session aus dem Header cookie der Anfrage mit http.cookies.SimpleCookie und gibt den Benutzer dieser Session zurück, oder None, wenn es kein Cookie, kein Cookie session oder keine Session unter seinem Wert gibt.

  • In handle: POST /login startet eine Session für "guest" und antwortet mit 200, {"user": "guest"} und einem Header set-cookie aus session_cookie; jede andere auf /login bekommt 405 mit allow: POST. GET /me und HEAD /me antworten mit 200 und {"user": name}, oder {"user": null} ohne lebende Session; jede andere Methode bekommt 405 mit allow: GET, HEAD. Jede andere Route behält ihre Antwort.

  • Die Session-ID erscheint nie in einem Antwort-Body.

Führ python pl_http.py session im aus und lies die Zeile set-cookie und die drei /me-Antworten.

Drück dann Check my work. Mit reading_server.secrets, ersetzt durch einen Ersatz, der die angefragte Größe aufzeichnet und IDs zurückgibt, die du noch nicht gesehen hast, prüft es, dass start_session die Session unter genau dieser ID speichert und dass das Cookie und handle sie verwenden. Es parst dein Cookie mit http.cookies.SimpleCookie und prüft den Wert, HttpOnly, Path=/ und Max-Age, einschließlich max_age=0. Es ruft handle mit einem Zustand auf, den du noch nicht gesehen hast: eine Anmeldung, /me mit dem Cookie allein, neben einem anderen Cookie in beiden Reihenfolgen, mit einer unbekannten ID, ohne Cookie und mit einem Cookie namens sessionx, HEAD /me und die 405er. Zuletzt startet es mit dem echten secrets 40 Sessions auf einem Zustand und auf frischen Zuständen und prüft, dass jede ID verschieden und mindestens 22 Zeichen lang ist. Nichts aus deinem Terminal wird bewertet.