0%

Zustand und Identität · Übung

Basic-Authentifizierung

Deine Anmeldung startet noch für jeden eine Session. Starte das Szenario basic des Hilfsprogramms, um zu sehen, wie weit das geht:

python pl_http.py basic

Es sendet drei eigene Anmeldungen, jede mit Content-Length: 0: eine ohne Zugangsdaten, eine für ana mit falschem Passwort und eine mit ihrem richtigen Passwort, beide aus sample_state() genommen. Dann ruft es dein login_and_me mit den richtigen auf. So geht das falsche Passwort hinaus:

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

Heute antworten alle vier Anmeldungen mit HTTP/1.0 200 OK und einem frischen set-cookie, das falsche Passwort eingeschlossen, und dein Client liest me: {'user': 'guest'}. Diese Lektion bringt die Anmeldung dazu, zu prüfen, wer fragt.

Der Header

Diese Zeile Authorization ist das Basic-Verfahren von (RFC 7617). Ihr ist der Name des Verfahrens, Basic, ein Leerzeichen und dann der -Bytes von user:password. Dekodiere YW5hOm5vdCB0aGUgcGFzc3dvcmQ=, und du bekommst ana:not the password. Beim Namen des Verfahrens spielt Groß- und Kleinschreibung keine Rolle, also ist basic dasselbe Verfahren.

Der Doppelpunkt trennt die beiden Teile, und die Regeln machen einen Benutzernamen mit Doppelpunkt unmöglich. Ein Passwort kann aber Doppelpunkte enthalten: a:b:c ist ein gültiges. Teile also nur am ersten Doppelpunkt. str.partition(":") tut genau das und gibt den Teil vor dem ersten Doppelpunkt, den Doppelpunkt und alles danach zurück. Wenn es keinen Doppelpunkt gibt, ist der mittlere Teil "".

Was gibt das aus?

print("ana:pa:ss".partition(":"))

Ablehnen mit einer Challenge

Eine Anmeldung mit fehlenden oder falschen Zugangsdaten bekommt 401 Unauthorized. Der Name ist alt und irreführend: 401 bedeutet „ich weiß nicht, wer du bist“, nicht „du darfst nicht“. Eine 401 muss dem Client außerdem in einem Header WWW-Authenticate sagen, wie er beweisen kann, wer er ist (RFC 9110, Abschnitt 15.5.2). Für diesen Server ist die Challenge die Konstante REALM, die bereits in reading_server.py steht:

www-authenticate: Basic realm="reading list", charset="UTF-8"

Basic nennt das Verfahren. Der Realm ist ein Name für den geschützten Bereich, den ein Browser anzeigen kann, wenn er nach einem Passwort fragt, und charset="UTF-8" sagt, dass der Server die Zugangsdaten als UTF-8 dekodiert. unauthorized(challenge) baut diese Antwort: einen -Fehler mit Status 401 und dem Header www-authenticate, auf challenge gesetzt.

POST /login kommt mit einem bekannten Benutzer und einem falschen Passwort an. Welche Antwort?

Streng dekodieren, dann vergleichen

basic_credentials(request) macht aus dem Header {"user": ..., "password": ...} oder gibt None zurück, wenn es nichts Brauchbares gibt:

  1. Nimm request["headers"].get("authorization", []). Alles außer genau einem Wert ergibt None.

  2. Entferne die Leerzeichen am Rand des Werts und teile ihn mit partition am ersten Leerzeichen. Der erste Teil muss kleingeschrieben basic sein. Entferne die Leerzeichen am Rand des Rests, damit zusätzliche Leerzeichen nach dem Verfahren nicht schaden.

  3. Dekodiere den Rest mit base64.b64decode(..., validate=True) und dann .decode("utf-8"), innerhalb von try. Lektion 4 hat gezeigt, dass ein schlechtes Zeichen binascii.Error auslöst und Bytes, die kein UTF-8 sind, UnicodeDecodeError; beides sind Arten von , also deckt ein einziges except ValueError:, das None zurückgibt, beide ab, wie bei body_error aus Kapitel 5.

  4. Ohne Doppelpunkt im Text gib None zurück. Andernfalls teile mit partition am ersten Doppelpunkt.

Der Zweig für /login vergleicht dann: Wenn basic_credentials None zurückgibt oder state["users"].get(user) nicht das Passwort ist, antworte mit unauthorized(REALM), ohne und ohne Session. Andernfalls starte eine Session für diesen Benutzer und antworte mit 200, {"user": user} und dem Cookie.

Dieser Server bewahrt Passwörter als einfache auf und vergleicht sie mit ==, damit die Lektion bei HTTP bleibt. Ein echter Dienst speichert von jedem Passwort nur einen langsamen, gesalzenen Hash und vergleicht Geheimnisse mit hmac.compare_digest, damit die Zeit, die ein Vergleich dauert, nichts darüber verrät, wie viel von einem Rateversuch richtig war. Das Speichern von Passwörtern liegt außerhalb dieses Kurses.

Sag, was fehlgeschlagen ist, nicht das Geheimnis

Jeder Fehlschlag bekommt denselben Body, etwa {"error": "credentials are missing or wrong"}. Zwei Gewohnheiten zählen. Eine Antwort wiederholt nie Zugangsdaten, weil Antworten in Logs und auf Bildschirmen landen. Und die Meldung sagt nicht, ob der Benutzername existiert, weil „falsches Passwort“ für einen Namen und „unbekannter Benutzer“ für einen anderen einem Fremden verrät, welche Namen er ausprobieren soll.

Welcher 401-Body ist sicher?

Nur innerhalb von HTTPS

Lektion 4 hat gezeigt, dass Base64 nichts verbirgt. Ein Basic-Header ist das Passwort, lesbar für jeden, der die Anfrage unterwegs sieht. Basic-Zugangsdaten sind nur innerhalb von HTTPS akzeptabel, wo die ganze Anfrage verschlüsselt, Header eingeschlossen; dasselbe gilt für Bearer-Tokens und Session-Cookies. Dieser Server spricht einfaches HTTP auf der dieses Rechners, die sonst niemand erreichen kann, und das ist die Ausnahme zum Lernen in diesem Kurs. Die letzte Lektion von Kapitel 9 zeigt die TLS-Hülle.

Die Client-Hälfte

In wire.py gibt basic_header(user, password) den Headerwert zurück: "Basic ", gefolgt von Base64 von f"{user}:{password}", als UTF-8 kodiert und mit .decode("ascii") wieder in Text verwandelt. Dann sendet login_and_me ihn: Wenn user nicht None ist, übergib headers={"Authorization": basic_header(user, password)} an das urllib.request.Request der Anmeldung. Sende ihn nur bei der Anmeldung. Danach sagt das Cookie, wer der Client ist.

Kapitel 6 hat eine Frage zu Zugangsdaten und offengelassen. Wenn der Opener von urllib einer Weiterleitung folgt, kopiert er jeden Header der Anfrage auf die nächste, außer content-length und content-type, also geht ein Basic-Header Authorization an jeden Host mit, den die Weiterleitung nennt. Der Cookie-Speicher ist vorsichtiger: Er entscheidet die Zeile Cookie bei jeder Anfrage neu und sendet nur die Cookies, die zum neuen Host und Pfad passen. Dieser Server leitet nur auf sich selbst weiter, also gelangt hier nichts nach außen, aber ein Client, der Weiterleitungen zu jedem Host folgt und dabei einen Basic-Header sendet, übergibt das Passwort an wen auch immer die Weiterleitung nennt.

Schreib in reading_server.py unauthorized und basic_credentials und schreib den Zweig für /login wie oben um. Schreib in wire.py basic_header und sende es aus login_and_me. Starte dann das Szenario noch einmal:

python pl_http.py basic

Die ersten beiden Anmeldungen antworten jetzt mit HTTP/1.0 401 Unauthorized, und unter jeder gibt das Hilfsprogramm aus:

  www-authenticate: Basic realm="reading list", charset="UTF-8"

Das richtige Passwort bekommt 200 und eine Zeile set-cookie, und dein Client liest me: {'user': 'ana'}. Das Szenario session aus Lektion 2, das keine Zugangsdaten sendet, bekommt für seine Anmeldung jetzt ebenfalls eine 401.

Drück dann Check my work. Anmeldungen bedeuten jetzt etwas, aber POST, PUT und DELETE auf den Büchern nehmen noch jeden an. Die nächste Lektion schützt sie.

Aufgabe

Prüf in reading_server.py bei der Anmeldung Basic-Zugangsdaten und sende sie aus wire.py.

In reading_server.py:

  • unauthorized(challenge) gibt einen 401--Fehler zurück, dessen Header www-authenticate challenge ist.

  • basic_credentials(request) gibt {"user": ..., "password": ...} aus einem Header authorization zurück, oder None. Gib None zurück, außer es gibt genau einen authorization-, dessen Verfahren basic in beliebiger Schreibweise ist. Entferne die Leerzeichen am Rand des Texts nach dem Verfahren, dekodiere ihn mit base64.b64decode(..., validate=True) und dann als , und gib None zurück, wenn einer der Schritte auslöst oder der Text keinen Doppelpunkt hat. Teile nur am ersten Doppelpunkt: Das Passwort kann weitere enthalten.

  • POST /login: Wenn basic_credentials None zurückgibt oder das Passwort nicht das Passwort aus state["users"] für diesen Benutzer ist, antworte mit unauthorized(REALM), ohne set-cookie und ohne neue Session. Andernfalls starte eine Session für diesen Benutzer und antworte mit 200, {"user": user} und dem aus Lektion 2.

  • Keine Antwort wiederholt ein Passwort oder -Zugangsdaten. Formuliere den Fehler, wie du willst.

In wire.py:

  • basic_header(user, password) gibt "Basic " zurück, gefolgt vom Base64 von f"{user}:{password}", als UTF-8 kodiert, als Text.

  • login_and_me sendet bei der Anmeldeanfrage Authorization: basic_header(user, password), wenn user nicht None ist, und sonst keinen Header Authorization. Die Anfrage an /me trägt keinen.

Führ python pl_http.py basic im aus: zwei abgelehnte Anmeldungen mit ihrer Zeile www-authenticate, dann eine Session für ana.

Drück dann Check my work. Es ruft basic_credentials mit Headern auf, die es für Benutzer und Passwörter baut, die du noch nicht gesehen hast, darunter welche außerhalb von ASCII, ein Passwort mit Doppelpunkten und fehlerhafte Header, und ruft handle mit einem eigenen Zustand auf: richtige und falsche Passwörter, unbekannte Benutzer und fehlende oder fehlerhafte Header. Es prüft jeden Status, Verfahren und Realm in www-authenticate, das Cookie, die Sessions und dass keine Antwort Zugangsdaten wiederholt. Es prüft basic_header gegen einen im Voraus berechneten Wert und führt login_and_me mit Ersatzobjekten für urllib aus, wie in Lektion 3, mit richtigen und falschen Zugangsdaten. Nichts aus deinem Terminal wird bewertet.