Zugriff auf privaten Content in AWS CloudFront authentifizieren

Eine statische Website aufsetzen, sie in einen S3-Bucket hochladen und diesen als Origin für eine CloudFront-Distribution einzustellen ist eine einfache und billige Lösung. Aber was tun, wenn der Zugriff auf die Inhalte beschränkt werden muss? HTTP-Basic-Authentifizierung scheint nicht mehr zu funktionieren und ist ohnehin umständlich und hässlich. Signierte Cookies bieten eine bessere Lösung, und sie sind einfach mit Serverless Computing zu realisieren.

Metalschloss auf Keyboard
Foto von Towfiqu barbhuiya auf Unsplash

Hinweis! Wer als Sprache für die AWS-Konsole Englisch eingestellt hat, sollte vielleicht besser die englische Version Authenticating Access to Private Content Hosted with AWS CloudFront lesen, um nicht ständig rätseln zu müssen, wie die englischen Bezeichnungen der Komponenten heißen.

Table Of Contents

Verfügbare Optionen zur Authentifizierung mit CloudFront

HTTP-Basic-Authentifizierung

Wer kennt sie nicht, diese ungestyleten Dialogboxen, die auf manchen Websiten aufpoppen und Username und Passwort abfragen? Vermutlich kommt dort HTTP-Basic-Authentifizierung zum Einsatz, ein relativ einfacher Ansatz, um Authentifizierung für eine Website zu implementieren. Im Internet kursieren verschieden Rezepte, die beschreiben, wie man Basic-Authentifzierung mit AWS Lambda beziehungsweise AWS Lambda@Edge realisieren kann.

Vor einiger Zeit hatte ich eine solche Site mit der Lambda-Umgebung Node 14.x aufgesetzt und sie funktioniert noch immer. Als ich jetzt versuchte, das Gleiche mit der Lambda-Umgebung Node 20.x zu bauen, klappte das allerdings nicht mehr. Das Problem ist, dass die Lambda-Funktion den Header Authorization des Requests auslesen muss, und AWS scheint diesen Header nicht mehr für Lambda- bzw. Lambda@Edge-Funktionen verfügbar zu machen.

Signierte Cookies und signierte URLs

Amazons empfiehlt für die Zugriffskontrolle auf CloudFront-Distributionen, den Viewer-Zugriff zu beschränken. Damit überprüft CloudFront bei jedem Request, dass entweder bestimmte URL-Query-Parameter übermittelt wurden, oder dass der Browser drei spezielle Cookies sendet, von denen eines die verschlüsselte Signatur der Zugriffs-Richtlinie (Policy) enthält. Kann die Signatur verifiziert werden, wird der Zugriff gewährt. Ansonsten wird ein 403-Fehler gesendet.

URLs zu signieren ist einfach, stellt aber nur einen gangbaren Weg dar, wenn der Zugriff auf einzelne Dateien kontrolliert werden soll. Um den Zugriff auf die ganze Site oder Teile davon zu kontrollieren, sind signierte Cookies das Mittel der Wahl.

Es ist auch nicht schwer, signierte Cookies zu generieren. Nur lassen sie sich nicht ohne weiteres in den Browser importieren. Das ist zwar möglich, aber bestimmt nicht benutzerfreundlich. Es muss also ein Weg gefunden werden, diese Cookies programmatisch zu setzen.

Während der Beschäftigung mit dem Thema stolperte ich über einem zweiteiligen Blog-Post Signed cookie-based authentication with Amazon CloudFront and AWS Lambda@Edge von zwei Amazon-Ingenieuren, in dem eine Technik zur Authentifizierung beschrieben wurde, die sogar ohne Passwort auskommt.

Die Bausteine der Lösung waren:

  1. Der Viewer-Zugriff auf die ganze Seite ist standardmäßig eingeschränkt.
  2. Nur die relativen URLs /login.html, /login und /assets sind öffentlich zugänglich.
  3. Die Seite /login.html ist die Fehlerseite für alle 403-Fehler.
  4. Das action-Attribut des Formulars auf /login.html ist der Endpunkt /login?email=EMAIL.
  5. Der Endpunkt /login triggert eine Lambda@Edge-Funktion, die überprüft, ob die angegebene E-Mail-Adresse akzeptabel ist.
  6. Ist die Überprüfung erfolgreich, wird ein signierter URL zum Endpunkt /auth generiert und an die angegebene E-Mail-Adresse geschickt.
  7. Die Benutzerin klickt den Link in der E-Mail und eine weitere, vom Endpunkt /auth getriggerte Lambda@Edge-Funktion, sendet einen Satz signierter Cookies.
  8. Die Benutzerin wird zur Startseite / weitergeleitet und kann jetzt durch die Cookies authentifiziert auf die komplette Site zugreifen.

Man kann selbstverständlich versuchen, der Anleitung der beiden Amazon-Ingenieure zu folgen. Ich hatte allerdings den Eindruck, dass sie nicht ganz aktuell sind und wollte auch ein paar Details ändern. Zum Beispiel wurde im Original-Betrag nur der Domain-Teil der Adressen geprüft, weil der Beitrag mehr auf eine Firma mit einer einheitlichen Domain zugeschnitten war. Ich dagegen wollte individuelle E-Mail-Adressen authorisieren.

Sicherheit

Auffällig ist, dass in diesem Protokoll kein Passwort vorkommt? Kannt das sicher sein? In der Tat ist das exakt so sicher wie der Vergessenes-Passwort-Flow: Man gibt seine E-Mail-Adresse an und erhält einen signierten Link zum Zurücksetzen des Passworts. Wer auch immer Zugriff auf die Mailbox hat, kann dann dem Link folgen und das Passwort zurücksetzen.

Die Sicherheit dieses Protokoll basiert alleine auf der Annahme, dass Leute ihre Mailbox schützen. Ein anderer Aspekt ist, dass die Sicherheit des Passworts völlig irrelevant ist, weil das Feature "Passwort vergessen" Angreifern erlaubt, es einfach zu ändern. Weshalb sollte man dann also überhaupt erst ein Passwort setzen?

Es sollte allerdings nicht unerwähnt bleiben, dass der Großteil aller E-Mails unverschlüsselt verschickt wird. Selbst wenn zwei Mail-Server direkt kommunizieren, geht der Verkehr dennoch über etliche Netzwerkknoten und sehr viele Menschen sind in der Lage, diesen Datenstrom mitzulesen und so signierte URLs abzufangen. Das ist allerdings nicht nur ein Problem des Authentifizierungs-Protokolls, dass wir hier beschreiben, sondern in noch höherem Maße ein Problem jeder Site, die das Zurücksetzen des Passworts via E-Mail anbietet.

Wer den Eindruck hat, dass die eigenen Sicherheitsanforderungen höher sind, darf gerne auf den E-Mail-Trick verzichten und stattdessen Logins und Passwort-Digests in AWS Secure Store oder Systems Manager speichern. Es wird dadurch sogar etwas einfacher. Ich persönlich würde aber eher über Zwei-Faktor-Authentifizierung nachdenken, wenn ich die Sicherheit verbessern will.

Voraussetzungen

AWS-Zugang

Zunächst einmal muss man natürlich bei AWS angemeldet sein. Das kann man bis zu 12 Monate lang mit dem AWS free tier sogar völlig kostenlos. Aber auch, wenn der freie Zugang aufgebraucht wurdde, kosten Experimente wie die hier beschriebenen schwerlich mehr als ein paar Cents.

Statische Website

Weiterhin benötigen wir eine statische Website. Eine populäre Option zur Verwaltung statischer Webseiten sind Static Site Generators. Ich empfehle Qgoda, weil ich der Autor bin. ;)

Für diese Schritt-für-Schritt-Anleitung erzeugen wir aber lieber ein paar Dateien von Hand. Wir streben die folgende Struktur an:

/
+- 404-not-found.html
+- assets/
|  +- styles.css
+- index.html
+- login.html
+- login-status.html
+- other/
   + index.html

Wir folgen hierbei der Annahme, dass das Verzeichnis /assets auf der obersten Ebene nur JavaScript, CSS, Fonts und andere Assets enthält, die alle nicht schützenswert sind. Wir wollen, dass auch nicht authentifizierte Besucher Zugriff auf diesen Content haben, so dass zum Beispiel die Login-Seite externe Stylesheets verwenden kann.

Wir erzeugen deshalb ein leeres Verzeichnis und darin die Startseite der Website index.html:

<link rel="stylesheet" href="/assets/styles.css" />
<h1>Du bist auf der Site!</h1>
<p>Du kannst auch <a href="/other/index.html">die andere Seite</a> besuchen.

Ja, das ist so richtig hässlicher HTML-Code aber Browser sind Kummer gewohnt und werden ihn ohne Murren akzeptieren. Wer will und kann, darf aber auch gerne vernünftiges HTML schreiben.

Als nächstes kommt das Stylesheet assets/styles.css:

body {
    font-family: sans-serif;
    background-color: lightblue;
}

Wir wollen nur einfach sehen, dass das Stylesheet geladen wurde, indem wir die Hintergrundfarbe auf Himmelblau setzen und einen serifenlosen Zeichensatz verwenden.

Jetzt kommt die Login-Seite login.html:

<link rel="stylesheet" href="/assets/styles.css" />
<form action="/login">
    <label for="email">E-Mail</label>
    <input type="text" id="email" name="email"
        placeholder="Gib bitte deine E-Mail-Adresse ein!">
    <input type="submit" value="Einloggen">
</form>

Das Attribut action des Formulars zeigt auf /login, den Endpunkt, der die Formulardaten verarbeitet.

Wir wollen auch eine Statusseite login-status.html haben, die nach Login-Versuchen gezeigt wird:

<link rel="stylesheet" href="/assets/styles.css" />
<p>
Falls du eine gültige E-Mail-Adresse angegeben hast und du eine authorisierte
Benutzerin dieser Site bist, bekommst du eine E-Mail mit einem Link, der für
die nächsten 30 Tage Zugriff gewährt!
</p>

Weil wir ja am Ende des Tages auf der Site "surfen" wollen, brauchen wir zumindest noch eine weitere Seite other/index.html:

<link rel="stylesheet" href="/assets/styles.css" />
<h1>Danke, dass du die andere Seite besucht hast!</h1>
<p>Du kannst auch die <a href="/">Startseite</a> besuchen.

Und dann gibt es natürlich defekte Links. Wir erzeugen also noch eine Seite 404-not-found.html:

<link rel="stylesheet" href="/assets/styles.css" />
<h1>404 Nix gefunden</h1>
<p>Gehe zur <a href="/">Startseite</a>!.

S3-Bucket

Amazon S3 ist Amazons Objektspeicher in der Cloud. Mit S3 ist man eigentlich nur einen Mausklick entfernt von einer statischen Website. Man muss lediglich das Hosten einer statischen Website aktivieren. Die erlaubt dann allerdings keine Authentifizierung.

Dennoch wird S3 als "Origin" also Ursprung für unsere Site fungieren. Deshalb erzeugen wir einen neuen Speicher, der im S3-Jargon "Bucket", also Eimer genannt wird:

  1. An der S3-Konsole anmelden.
  2. Auf Bucket erstellen klicken. Die AWS-Region ist beliebig. Der Name muss innerhalb dieser Region eindeutig sein; "test" wird vermutlich also nicht funktionieren, aber "www.example.com" schon, sofern man "example.com" mit einer Domain ersetzt, die man besitzt. Ansonsten muss man sich einfach einen Namen ausdenken.
  3. Alle anderen Einstellungen sollte beibehalten werden, und der Bucket kann mit dem Button Bucket erstellen ganz unten erzeugt werden.

Es ist wichtig, den öffentlichen Zugriff auf den Bucket komplett zu blockieren. Der entsprechende Abschnitt sollte so aussehen:

S3-Bucket-Einstellungen

Man sollte nunmehr eine Liste aller eigenen S3-Buckets sehen. Ansonsten muss man Buckets im Menü links auf der Seite klicken. Ein Klick auf den Namen des Buckets produziert das Objekt-Listing. Weil der Bucket noch immer leer ist, klicken wir auf Hochladen, dann Dateien hinzufügen und ziehen entweder die Dateien und Verzeichnissse, die zuvor erzeugt wurden (index.html, login.html, ...), in den Upload-Bereich, oder klicken Dateien hinzufügen und wählen sie aus.

Wenn das Hochladen beendet ist, sollte das Objekt-Listing so aussehen:

S3-Bucket-Listing

CloudFront-Distribution

Amazon CloudFront ist Amazons Content-Delivery-Network. Man speichert den eigentlichen Website-Inhalt (Origin genannt) entweder in einem S3-Bucket oder auf einer anderen Website und CloudFront stellt ihn auf Servern, die über die ganze Welt verteilt sind, zur Verfügung, so dass Besucher einen Server zugewiesen bekommen, der in ihrer Nähe ist. Diese Server werden "Edges", also "Ränder" genannt.

Eine solche Content-Sammlung wird Distribution beziehungsweise Verteilung genannt, und hat eine Adresse in der Form https://abcde123.cloudfront.net, wobei der Hostname abcde123 ein eindeutig zugewiesener Name in der Domain cloudfront.net ist. Man kann aber optional auch einen eigenen Domainnamen als Alias verwenden, siehe weiter unten.

Eine Distribution wird folgendermaßen erzeugt:

  1. An der CloudFront-Konsole anmelden!
  2. Auf Create Distribution klicken, um eine neue Distribution zu erzeugen.
  3. Unter Origin | Origin domain den neu erzeugten S3-Bucket auswählen.
  4. Den Origin | Origin path leer lassen, es sei denn, man weiß, was man tut.
  5. Unter Default Cache Behavior | Viewer | Viewer protocol policy wählen wir Redirect HTTP to HTTPS aus (oder man weiß, was man tut).
  6. Default Cache Behavior | Viewer | Restrict Viewer Access noch nicht aktivieren!
  7. Unter Default Cache Behavior | Cache Key and origin requests die Option Cache policy and origin request policy (recommended) aktiviert lassen, aber für Cache policy den Eintrag CachingDisabled ausgewählt lassen, jedenfalls für den Moment.
  8. Unter Web Application Firewall (WAF) hat man die freie Entscheidung. Allerdings ist die Angriffsfläche einer statischen Website ziemlich klein, und benötigt eventuell keinen besonderen Schutz.
  9. Alle anderen Einstellungen belässt man so, wie sie sind und klickt Create Distribution, um den Prozess abzuschließen.

Das bringt einen zurück zur Übersicht über die Distributionen, in der die gerade erstellte Distribution auch auftauchen sollte. Als Status sollte sie Enabled haben, und unter Last modification date sollte noch kein Datum zu sehen sein, sondern Deploying .... Die Meta-Informationen der Distribution werden jetzt zu den verschiedenen Edges übertragen. Nach ein paar Minuten sollte der String Deploying ... durch Datum und Uhrzeit der letzten Änderung ersetzt werden.

Was spricht dagegen, den Viewer-Zugriff einzuschränken? Eigentlich nichts, aber wir wollen erst einmal ausprobieren, ob das Setup so funktioniert. Und den echten, schützenswerten Content hochzuladen, während man noch an der Authentifizierung bastelt, ist vielleicht ohnehin nicht so schlau.

Und weshalb sollte man das Caching deaktivieren? Keine Angst, das wird später noch geändert. Der Cache ist jedoch sehr strikt und speichert sogar Fehler, was auch so gewünschtes ist. Allerdings sind Fehler in dieser Phase fast unvermeidlich, und wir können uns das Leben einfacher machen, indem wir für den Augenblick gar nichts cachen. Wenn dann ein Fehler auftritt, kann er einfach behoben werden, und wir probieren es noch einmal.

Zeit zum Testen. Wir klicken auf den Namen der Distribution, dann auf das Kopier-Icon neben dem Hostnamen unter Details | Distribution domain name und fügen die Adresse in die Adressleiste des Browsers ein. Es sollte die HTML-Seite index.html vom S3-Bucket angezeigt werden, und auch der in die Seite eingebaute Link sollte funktionieren.

Falls irgendetwas bis hierhin nicht funktioniert, hilft Amazons Dokumentation Verwenden einer Amazon- CloudFront Verteilung zum Bereitstellen einer statischen Website mit aktuellen Informationen weiter.

Optional: Einen alternativen Hostnamen einrichten

Von Amazon zugewiesene Adressen wie https://abcde12345.cloudfront.net/ entsprechen vielleicht nicht immer den eigenen ästhetischen Ansprüchen. Man kann stattdessen jedoch eine Domain registrieren, dafür von Amazon ein kostenloses Wildcard-Zertifikat erhalten, und als alternativen Hostnamen für die CloudFront-Verteilung verwenden. Das Vorgehen dazu ist ebenfalls sehr detailliert in Verwenden einer Amazon- CloudFront Verteilung zum Bereitstellen einer statischen Website beschrieben.

Im Folgenden gehen wir davon aus, dass das passiert ist, und beziehen uns auf unsere statische Website mit der Adresse https://www.example.com/. Diese muss gegebenenfalls durch die von Amazon zugewiesene Adresse ersetzt werden, wenn keine eigene Domain verwendet wird.

Über signierte URLs und signierte Cookies

Wird der Viewer-Zugriff einer CloudFront-Distribution eingeschränkt, müssen Besucher entweder signierte URLs oder signierte Cookies verwenden. Wird keine Signatur bereitgestellt, wird der Zugriff verweigert.

Wie funktioniert das Signieren und Überprüfen einer Signatur im Allgemeinen? Signierte URLs sind einfacher zu verstehen. Fangen wir daher damit an.

Amazon verwendet Richtlinien (Policies) um Zugriff zu kontrollieren. Eine typische Richtlinie für CloudFront Viewer-Zugriff sieht so aus:

{
    "Statement":
        [
            {
                "Resource": "https://www.example.com/search?q=fun",
                "Condition":
                    {
                        "DateLessThan":
                            {
                                "AWS:EpochTime": 1706860856
                            }
                    }
            }
        ]
}

Das bedeutet: Der Zugriff auf die Ressource https://www.example.com/search?q=fun wird bis zu 1706860856 Sekunden nach dem 1. Januar 1970 00:00:00 GMT gewährt.

Um eine Signatur für diese Richtlinie zu erzeugen, wird diese zuerst normalisiert, indem Leerraum (Leerzeichen, Tabulatoren, Zeilenumbrüche) entfernt werden, so dass sie nur noch aus einer einzigen Zeile ohne Leerraum besteht. Das ist noch immer valides JSON.

Allerdings ist es so, dass das obige JSON mit zwei äquivalenten Versionen dargestellt werden kann. Die Schlüssel Resource und Condition könnten auch vertauscht werden, denn die Schlüssel eines JSON-Objektes müssen keine bestimmte Reihenfolge haben. Für deterministisches JSON gibt es die Konvention "kanonisches" JSON, bei der alle Schlüssel eines Objektes in alphanumerischer Reihenfolge erscheinen. Das verlangsamt die Kodierung von JSON, ist aber ein probates Mittel, um JSON deterministisch und round-trip-safe zu machen.

Es wird das Geheimnis der AWS-Entwicklerinnen bleiben, weshalb Amazon dieser Konvention hier nicht folgt, und eine Reihenfolge verwendet, die nicht alphanumerisch ist. Das verlangsamt nicht nur die Kodierung der JSON-Richtlinie, sondern verhindert auch die Verwendung jedweder Bibliothek zur Generierung von JSON für diesen Fall. Das ist unschön, wir müssen aber damit leben.

Nachdem die Richtlinie "normalisiert" wurde, wird von ihr ein SHA-1-Digest gebildet und dann symmetrisch verschlüsselt. Weil die verschlüsselten Daten binär sind, werden sie mit Base64 kodiert, und weil die Zeichen +, / und = in URLs eine spezielle Bedeutung haben, werden sie jeweils durch -, ~ und _ ersetzt.

Eine Richtlinie, die den Zugriff auf exakt einen URL bis zu einem bestimmten Zeitpunkt erlaubt, wird von Amazon eine vordefinierte Richtlinie (canned policy) genannt, und es ist entscheidend, dass sie exakt wie gerade beschrieben kodiert wird, also einschließlich der Reihenfolge der Objektschlüssel.

Der Browser sendet damit einen Request, bei dem alle Informationen, die in der verschlüsselten Richtlinie enthalten sind, auch im Klartext übermittelt werden. Das sieht in in etwa folgendermaßen aus (aus Gründen der Lesbarkeit auf mehrere Zeilen verteilt):

https://www.example.com/search?q=fun\
    &Expires=1706860856\
    &Key-Pair-Id=ABCDEFG0123456\
    &Signature=SIGNATURE-AS-URL-SAFE-BASE64

Wenn wir weiter oben noch einmal nachschauen, sehen wir, dass die vordefinierte Richtlinie nur zwei Informationen enthält, nämlich die Ressource, also die Basis-Adresse und das Verfallsdatum in Sekunden seit der Epoche. Beide sind auch in signierten URLs enthalten: Der Basis-URL besteht aus Origin, Pfad und Query-String ohne die drei Parameter Expires, Key-Pair-Id undSignature, die durch den Signatur-Prozess hinzugefügt wurden.

Das Verfallsdatum wird einfach als Query-Parameter Expires übergeben.

CloudFront kann damit die Richtlinie reproduzieren und damit auch die Signatur überprüfen, vorausgesetzt, der zum privaten Schlüssel zugehörige öffentliche Schlüssel ist bekannt. Deshalb muss der öffentliche Schlüssel in AWS CloudFront hochgeladen werden, und kann dann über die Key-Pair-Id zugeordnet werden.

Weshalb eigentlich Schlüsselpaar? Schlüssel existieren immer in Paaren aus einem privaten und einem öffentlichen Schlüssel. Letzterer kann aus dem privaten Schlüssel gewonnen werden. Der öffentliche Schlüssel ist für die Überprüfung der Signatur ausreichend, weshalb der private Schlüssel normalerweise nicht in AWS hochgeladen wird. Für unseren Anwendungsfall müssen wir ihn doch hochladen, aber das ist eine andere Geschichte, auf die wir in Kürze zu sprechen kommen.

Jedenfalls ist die Key-Pair-Id eher eine Public-Key-Id. Amazon hat sich aber für eine eigene Benamung entschieden.

Kurz gesagt, funktioniert das Protokoll folgendermaßen:

  1. Man erzeugt ein Schlüsselpaar, und lädt den öffentlichen Schlüssel in CloudFront hoch, wo er eine ID (Key-Pair-Id) zugewiesen bekommt.
  2. Man signiert einen URL mit dem privaten Schlüssel, um Zugriff bis zu einem bestimmten Zeitpunkt in der Zukunft zu gewähren.
  3. Diesen URL teilt man, mit wem man möchte.
  4. Berechtigte Personen senden einen Request zum Basis-URL und behaupten dabei, dass sie Zugriffsrechte bis zu einem bestimmten Zeitpunkt in der Zukunft haben.
  5. Amazon gewährt den Zugriff nur, wenn die Signatur, die Teil des signierten URLs ist, zu den Informationen, die im Klartext übergegeben wurden, passt.

Signierte Cookies funktionieren auf exakt die gleiche Art und Weise, nur dass die Informationen in Cookies und nicht in URL-Parametern übermittelt werden.

Ein weiterer Unterschied zwischen den Prozessen für signierte URLs und signierte Cookies ist, dass signierte Cookies - in der Regel - benutzerdefinierte Richtlinien (custom polices) verwenden, während für signierte URLs - in der Regel - vordefinierte Richtlinien (canned policies) zum Einsatz kommen. Benutzerdefinierte Richtlinien sind völlig flexibel und können beliebige Regeln enthalten. Die Signatur, und der Verweis auf den öffentlichen Schlüssel reichen aus, um eine Manipulation der Richtlinie zu verhindern.

Setup für die Einschränkung des Viewer-Zugriffs

Als nächstes sehen wir uns an, wie die Benutzung signierter URLs und Cookies vorbereitet wird.

Erzeugung eines Schlüsselpaars

Obwohl man auch ssh-keygen verwenden könnte, beschränke ich mich hier auf openssl, um alles zu erzeugen, was für die Einschränkung des Viewer-Zugriffs für CloudFront benötigt wird.

Amazon ist bei den Details der kryptographischen Schlüssel ziemlich unflexibel, siehe deren Dokumentation:

  1. Es muss ein SSH-2-RSA-Schlüsselpaar sein.
  2. Es muss im base64-kodierten PEM-Format vorliegen.
  3. Es muss 2048 Bits lang sein.

Mit dem folgende Befehlt generiert man ein solches Schlüsselpaar und speichert es in einer Datei private_key.pem:

$ openssl genrsa -out private_key.pem 2048

Der öffentliche Schlüssel lässt sich daraus folgendermaßen gewinnen:

$ openssl rsa -pubout -in private_key.pem -out public_key.pem

Mit der Ausgabedatei public_key.pem ist Amazon in der Lage zu verifizieren, dass eine Signatur mit dem gerade erzeugten privaten Schlüssel generiert wurde, obwohl Amazon normalerweise keinen Zugriff auf diesen privaten Schlüssel hat.

Hochladen des öffentlichen Schlüssels

Es ist Zeit, Amazon den öffentlichen Schlüssel zugänglich zu machen, um die Signaturen, die wir in der Folge produzieren, zu verifizieren. Dazu loggen wir uns in die Amazon Cloudfront-Konsole ein, gehen zu Key Management | Public Keys und klicken den orangen Button Create public key. Okay, wir wissen bereits, dass man keinen öffentlichen Schlüssel erzeugen, sondern nur aus einem privaten Schlüssel extrahieren kann, aber sei's drum.

Im folgenden Formular muss neben dem eigentlichen Schlüssel auch ein Name angegeben werden. Ich schlage einen Namen vor, der das aktuelle Datum enthält, zum Beispiel photos-02-2024, denn es ist ratsam, die Schlüssel von Zeit zu Zeit auszutauschen.

Der öffentliche Schlüssel ist im Moment in einer Datei public_key.pem gespeichert. Diese Datei öffnet man mit einem beliebigen Editor, kopiert den Inhalt und fügt ihn in das Feld Key des Formulars ein.

öffentlichen Schlüssel hochladen

Der Schlüssel wird mit einem Klick auf Create public key gespeichert. In der Übersicht über die öffentlichen Schlüssel ist ersichtlich, dass AWS dem Schlüssel eine ID wie K88DNWP2VR6KE zugewiesen hat. Die ID ist innerhalb der CloudFront-Verteilung eindeutig.

Erzeugen einer Schlüsselgruppe

Was ist eine Schlüsselgruppe?

Wie oben erwähnt, ist es ratsam, Schlüssel von Zeit zu Zeit auszutauschen, um zu verhindern, dass sie kompromittiert werden. AWS fördert diese Praxis durch das Konzept der Schlüsselgruppen. Beim Zugriffsschutz für Content in CloudFront wird deshalb nie ein bestimmter Schlüssel sondern vielmehr eine Schlüsselgruppe angegeben. In regelmäßigen Abständen sollte ein neues Schlüsselpaar generiert und der öffentliche Schlüssel wie oben beschrieben in die Schlüsselgruppe hochgeladen werden. URLs und Cookies sollten immer mit dem zuletzt generierten Schlüssel signiert werden, aber jeder Schlüssel aus der angegebenen Schlüsselgruppe ist ausreichend, um signierte URLs oder Cookies zu verifizieren. Wenn die Übergangsphase beendet ist, kann der alte Schlüssel entfernt werden, und es kommt nur noch der neue zum Einsatz.

Aus diesem Grunde kann nur eine vertrauenswürdige Schlüsselgruppe (trusted key group), aber keine bestimmte Schlüssel-ID für die Zugriffskontrolle einer CloudFront-Distribution angegeben werden. Dazu geht man folgendermaßen vor:

Wir loggen uns in die Amazon CloudFront-Konsole ein, gehen zu Key management | Key groups, wählen Create key group, geben der Schlüsselgruppe einen Namen und wählen die öffentliche Schlüssel aus, die zur Verifizierung von Signaturen verwendet werden sollen. Ich empfehle hier einen generischen Namen ohne Datum wie photos, denn diese Schlüsselgruppe dient als langfristiger Container für vertrauenswürdige Schlüssel für diese Verteilung. Für den Augenblick reicht es natürlich, den einen gerade erzeugten Schlüssel zuzufügen.

Einschränkung des Viewer-Zugriffs.

Jetzt, wo wir einen Schlüssel haben, können wir den Zugriff auf unseren Content einschränken. Dazu klicken wir auf Distributions im Menü der CloudFront-Konsole, dann auf den Namen der Verteilung und wechseln zum Tab Behaviors (Verhalten).

Im Moment gibt es nur ein Verhalten, nämlich Default (*). Dieses wählen wir aus, klicken Edit, gehen zum Bereich Viewer | Restrict viewer access und ändern die Einstellung auf Yes. Als Trusted authorization type wählen wir Trusted key groups und fügen den im letzten Schritt erzeugten Key zu. Nach dem Speichern der Änderungen dauert es ein paar Minuten bis der Status der Distribution von Deploying ... zum Datum der letzten Änderung wechselt.

Wenn die Distribution jetzt im Browser geöffnet wurde, sollte es einen 403-Fehler nebst einer XML-Fehlermeldung geben, was zeigt, dass der Zugriff jetzt verweigert wird.

Verhalten (Behaviors)

Ein CloudFront-Behavior ist eine Regelsatz für einen bestimmten Teil einer Distribution. Das Äquivalent für nginx oder Apache wäre ein Konfigurationsabschnitt location. Wir werden jetzt eine Reihe solcher Behaviors erzeugen. Vorher gehen wir aber noch einmal den Authentifizierungsablauf durch, um festzustellen, welche Behaviors wir benötigen:

Der Startpunkt ist /login.html, wo die Besucherinnen ihre E-Mail-Adresse eingeben. Diese Seite muss öffentlich zugänglich sein, und deshalb brauchen wir ein zweites Verhalten /login*.html. Wir benutzen ein Wildcard-Muster, damit wir auch die Bestätigungsseite /login-status.html einschließen.

Das Attribut action des Login-Formulars zeigt auf /login und wir erzeugen dafür deshalb ein drittes Verhalten, wiederum öffentlich. Für dieses Behavior wird kein Content hinterlegt. Es dient lediglich als Trigger für eine Lambda-Funktion, welche die angegebene E-Mail-Adresse überprüft, und im Erfolgsfall einen signierten Bestätigungs-URL an diese Adresse verschickt.

Dieser Bestätigungs-URL zeigt auf /auth und muss zugriffsbeschränkt sein. Hier wird ebenfalls kein Content hinterlegt, sondern nur eine zweite Lambda-Funktion getriggert, die drei signierte Cookies setzt und zur Startseite / weiterleitet. Von hier aus kann die Benutzerin die Seite ohne Einschränkungen nutzen.

Wir können auch ein Verhalten für /logout zufügen, das eine dritte Lambda-Funktion triggert, die alle Cookies entfernt und zur Login-Seite weiterleitet.

Alle Seiten können CSS, JavaScript, Fonts und so weiter referenzieren, die alle unterhalb von /assets/* liegen. Dieser Bereich sollte öffentlich zugänglich sein.

Alles in allem benötigen wir also neben dem Standard-Behavior * noch fünf weitere.

Default-Verhalten *

Bevor wir aber damit anfangen, neue Behaviors zu erzeugen, sollten wir uns noch einmal kurz mit dem Default-Behavior befassen. Eingangs hatten wir gesagt, dass man Redirect HTTP to HTTPS als Viewer-Protokoll-Richtlinie wählen sollte. Das dient der Bequemlichkeit, damit URLs ohne Protokoll eingegeben werden können. Außerdem ist es so, dass wenn Content-Management-Systeme URLs ohne Protokoll wie www.example.com automatisch konvertieren, dabei fast immer HTTP- und nicht HTTPS-Links herauskommen.

Das birgt allerdings ein kleines Sicherheitsmanko. Nehmen wir an, Requests für /login würden automatisch von HTTP auf HTTPS hochgehoben. Dann könnte man ein Formular mit action-Attribut http://www.example.com/login erzeugen, bei dem die Login-Daten im Klartext verschickt werden, und das wird gemeinhin als unsicher erachtet.

Was ist die empfohlene Einstellung? Ganz eindeutig HTTPS only, also nur HTTPS, besonders wenn keine alternative, "sprechende" Domain konfiguriert wurde. Denn würde irgendjemand eine Adresse wie k4k20dk0pmn3jh.cloudfront.com von Hand in den Browser eintippen? Nein. Solche URLs werden über andere Kanäle geteilt und man klickt einfach darauf.

Für normale Zwecke ist es aber hinreichend sicher, ein Upgrade von HTTP auf HTTPS für alle öffentlichen Bereiche, die nicht am Authentifizierungs-Prozess beteiligt sind, zu machen. Alles andere sollte nur über HTTPS zugänglich sein.

Und während wir dabei sind, sollten wir auch gleich HTTP Strict Transport Security aktivieren und dankenswerterweise hat CloudFront eine vordefinierte Richtlinie für dieses Protokoll. Um diese Richtlinie zu aktivieren, muss das Default-Verhalten * selektiert werden. Dann scrollen wir nach unten zu Cache key and origin requests | Response headers policy, selektieren SecurityHeadersPolicyund speichern. Diese Einstellung wird den Header Strict-Transport-Security mit einer maximalen Laufzeit von einem Jahr, sowie eine Reihe anderer der Sicherheit förderlichen Header zur Response zufügen.

Verhalten für /assets/*

Als nächstes erzeugen wir ein Behavior für /assets/*, oder wo auch immer unkritische Ressourcen wie CSS, JavaScript, Zeichensätze und so weiter vorgehalten werden. Wieder wird unser S3-Bucket als Origin ausgewählt, für die Viewer-Protokoll-Policy ist HTTPS only okay. Weil der Zugriff öffentlich sein muss, bleibt Restrict viewer access auf No. Und wie bei allen weiteren Behaviors sollte die SecurityHeadersPolicy ausgewählt werden.

Falls die Site mehr Bereiche für öffentlich zugängliche Ressourcen hat, müssen diese auf gleiche Weise konfiguriert werden.

Verhalten für /login*.html

Dafür folgen wir exakt den Anweisungen für /assets/*.

Verhalten für /login

Das ist der Endpunkt, zu dem Login-Requests geschickt werden. Es sollte jetzt klar sein, dass hier HTTPS only selektiert werden muss, aber der Viewer-Access nicht eingeschränkt werden darf. Ansonsten könnte niemand Login-Credentials übermitteln.

Verhalten für /auth and /logout

Diese Behaviors konfigurieren wir exact wie /login.

Beim Testen der neuen Behaviors muss beachtet werden, dass die Antworten aus dem Cache kommen könnten. Im Zweifelsfall sollte man eine Invalidierung (Invalidation) für /* oder spezifischere URLs erzeugen, und warten, dass sie zu allen Edges propagiert wurde.

Ein Klick auf den Tab Behaviors sollte jetzt ungefähr so etwas liefern:

Behaviors

Der Screenshot zeigt ein zweites Verzeichnis für öffentlich zugängliche Assets /static, das aber nicht unbedingt benötigt wird.

Die Lambda-Funktion für das Login

Jetzt können wir mit dem Coden anfangen. Wir benutzen die derzeit aktuelle Laufzeitumgebung für Node.js 20.x, aber es sollte leicht sein, die gleiche Funktionalität auf jede andere unterstützte Laufzeitumgebung zu portieren.

Login-Quelltext

Für die Erstellung des Quelltextes, gehen wir zur AWS-Lambda-Konsole und klicken den orangen Button Funktion erstellen. Es ist unerlässlich, die Funktion in der Region us-east-1 Nord-Virginia zu erstellen. Die Funktion muss auf den Edges laufen, und eine der Einschränkungen für Lambda@Edge-Funktionen ist, dass sie in der Region us-east-1 erstellt werden müssen.

Wir lassen Ohne Vorgabe erstellen selektiert, vergeben einen Namen wie meinLogin, wählen die neuestes Laufzeitumgebung für Node.js und klicken Funktion erstellen.

Die folgende Ansicht ist unterteilt in einen Bereich Funktionsübersicht oben und eine sehr elementare IDE unten. Im Moment wollen wir dort den Tab Code selektieren. Im linken Bereich sehen wir die Dateien, die zur Lambda-Funktion gehören. Das ist im Moment nur eine, nämlich index.mjs. Der Quelltext dazu ist im rechten Bereich sichtbar.

Weshalb index.mjs und nicht index.js? Mittlerweile sind die JavaScript-Lambda-Funktionen Module und benutzen ES6-Syntax. Alte Quelltext-Beispiele, die CommonJS verwenden, wie aus dem weiter oben erwähnten Blog-Post der beiden Amazon-Ingenieure, funktionieren in einem Handler index.mjs nicht mehr.

Der folgende Code kann kopiert und in den Editor einfügt werden, um damit das Hallo-Welt-Beispiel zu überschreiben:

import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2';
import { SSMClient, GetParameterCommand } from '@aws-sdk/client-ssm';
import { getSignedUrl } from '@aws-sdk/cloudfront-signer';

const emailSender = 'you@example.com';
const hostname = 'www.example.com';
const prefix = 'photos';
const region = 'us-east-1';

const authUrl = `https://${hostname}/auth`;
const loginUrl = `https://${hostname}/login.html`;
const statusUrl = `https://${hostname}/login-status.html`;

const response = {
    status: '302',
    statusDescription: 'Found',
    headers: {
        location: [{ key: 'Location', value: statusUrl }],
        'cache-control': [{ key: 'Cache-Control', value: 'max-age=100' }],
    },
};

const cache = {}

const ses = new SESv2Client({ region });
const ssm = new SSMClient({ region });

export const handler = async (event, context) => {
    const request = event.Records[0].cf.request;

    const parameters = new URLSearchParams(request.querystring);
    if (!parameters.has('email') || parameters.get('email') === '') {
        return {
            status: '302',
            statusDescription: 'Found',
            headers: {
                location: [{ key: 'Location', value: loginUrl }],
                'cache-control': [{ key: 'Cache-Control', value: 'max-age=100' }],
            },
        };
    }

    if (typeof cache.registeredEmails === 'undefined') {
        const registeredEmails = await getParameter('registeredEmails', true);
        cache.registeredEmails = registeredEmails.split(/[ \t\n]*,[ \t\n]*/);
    }
    cache.keyPairId ??= await getParameter('keyPairId');
    cache.privateKey ??= await getParameter('privateKey', true);

    const email = parameters.get('email');
    if (cache.registeredEmails.includes(email.toLowerCase())) {
        await sendEmail(cache.keyPairId, cache.privateKey, email);
    }

    return response;
};

const getParameter = async (relname, WithDecryption = false) => {
    const Name = `/${prefix}/${relname}`
    const command = new GetParameterCommand({Name, WithDecryption});

    try {
        const response = await ssm.send(command);
        return response.Parameter.Value;
    } catch (error) {
        console.error(`Error retrieving parameter '${Name}': `, error);
        throw error;
    }
};

const sendEmail = async(publicKey, privateKey, email) => {
    const expires = new Date(new Date().getTime() + 60 * 10 * 1000);

    const signedUrl = getSignedUrl({
        url: authUrl,
        keyPairId: publicKey,
        dateLessThan: expires,
        privateKey: privateKey,
    });

    const command = new SendEmailCommand({
        Destination: {
            ToAddresses: [
                email
            ]
        },
        Content: {
            Simple: {
                Body: {
                    Text: {
                        Data: `Bitte hier einloggen: ${signedUrl}!`,
                        Charset: 'UTF-8'
                    }
                },
                Subject: {
                    Data: 'Photo-Archive-Login für ' + email,
                    Charset: 'UTF-8'
                }
            },
        },
        FromEmailAddress: emailSender,
    });
    
    try {
        await ses.send(command);
    } catch (error) {
        console.error(`Error sending email to '${email}': `, error);
        throw error;
    }
};

Eine Menge Quelltext, und ich würde die Funktionalität persönlich sogar etwas anders realisieren, aber ich wollte die Implementierung so kurz wie möglich halten.

Normalerweise sollte es reichen, nur vier Änderungen vorzunehmen:

  • Zeile 5: Hier sollte die Absenderin der Mail mit dem Bestätigungs-Link eingetragen werden.
  • Zeile 6: Hier wird der Hostname der Site angegeben. Entweder ist das der CloudFront-Hostname, oder die alternative Domain.
  • Zeile 7: Hier muss ein eindeutiges Präfix für weitere Konfigurationsvariablen angegeben werden.
  • Zeile 8: Und hier muss eine Region für andere benötigte AWS-Services ausgewählt werden, wie Amazon Systems Manager und Simple Email Service SES.

In den Zeilen 10-12 kann man auch noch weitere URLs konfigurieren, aber die Vorgaben sind okay, wenn man den Anweisungen bis hierhin gefolgt ist.

Für den Rest des Codes werde ich nicht jedes Detail erklären, sondern mich auf die wichtigsten Aspekte beschränken.

Der eigentliche Handler ist ab Zeile 28 definiert. Er wird mit zwei Argumenten event und context aufgerufen. Die Struktur von CloudFront-Request-Events, genauer gesagt `Lambda@Edge-Events, ist hier dokumentiert: https://docs.aws.amazon.com/de_de/AmazonCloudFront/latest/DeveloperGuide/lambda-event-structure.html. Das zweite Argument, der Kontext, stellt alle möglichen Meta-Informationen über die ausgeführte Funktion zur Verfügung, siehe https://docs.aws.amazon.com/lambda/latest/dg/nodejs-context.html. Für unserem Anwendungsfall brauchen wir davon nichts .

In Zeile 32 wird überprüft, ob eine gültige E-Mail-Adresse angegeben wurde. Falls nicht, wird einfach zur Login-Seite weitergeleitet. Eine mögliche Verbesserung bestünde darin, ein paar Konsistenzprüfungen zu machen, oder Whitespace von der Eingabe zu entfernen.

Die Funktion benötigt eine Liste der registrierten E-Mail-Adressen, die Schlüsselpaar-ID und den privaten Schlüssel, damit der URL signiert werden kann. Diese Parameter werden in den Zeilen 43-50 aus dem Amazon Systems Manager geholt und während des Lebenszyklus der Lambda-Funktion zwischengespeichert. Für einfache Anwendungsfälle könnte man diese Werte auch einfach hartkodieren, was aber etwas unflexibel ist. Außerdem ist es nicht gerade empfehlenswert, private Schlüssel im Quelltext hartzukodieren. Aber das ist letztendlich Geschmackssache.

In Zeile 51 wird schließlich überprüft, ob die angegebene E-Mail-Adresse eine der konfigurierten Adressen ist. Das Original-Konzept der beiden Amazon-Ingenieure sah hier einen anderen Ansatz vor, und schaltete ganze Domains statt individueller E-Mail-Adressen frei. Das ergibt Sinn für Organisationen mit eigener E-Mail-Domain aber definitiv nicht für solche, die Adressen wie @gmail.com benutzen.

Wenn die Adresse gültig ist, wird ein signierter URL generiert und via E-Mail an die angegebene Adresse verschickt. Man mag sich jetzt fragen, weshalb es kein else gibt. Das bleibt jedem selbst überlassen. Man könnte hier auch zu einer Fehlerseite weiterleiten und informieren, dass die angegebene E-Mail-Adresse nicht registriert ist. Aber weshalb sollte man solche Informationen preisgeben? Ich persönlich bevorzuge neutralen Text wie "Falls du eine gültige E-Mail-Adresse angegeben hast bekommst du in Kürze eine E-Mail", so dass niemand überprüfen kann, ob eine E-Mail-Adresse registriert ist oder nicht.

Die Funktion testen

Die AWS-Lambda-Konsole bietet die Möglichkeit zu End-to-End-Tests, aber die Funktion muss dafür erst ausgespielt (deployt) werden. Sobald man Änderungen am Code vornimmt, wird darüber "Changes not deployed" angezeigt und ein Button Deploy aktiviert. Diesen Button klicken wir jetzt, und warten, bis das Deployment abgeschlossen ist.

Um die Funktion zu testen, müssen noch Test-Ereignisse erzeugt werden. Diese Ereignisse sind jeweils das erste Argument für die Handler-Funktion und werden in JSON definiert. Wir fangen mit einem Event an, dass keine E-Mail-Adresse, oder genauer gesagt eine leere E-Mail-Adresse enthält. Dazu klicken wir auf den Tab "Test" (nicht den Button neben "Deploy"), wo eigentlich kein Test gestartet wird, sondern Test-Events verwaltet werden.

Wir wählen Neues Ereignis erstellen, nennen es fehlendeEMailAdresse und überschreibe das vorgegebene JSON hiermit:

{
    "Records":
        [
            "cf": {
                "request": {
                    "querystring": "email="
                }
            }
        ]
}

Mit einem Klick auf Speichern wird das Ereignis abgespeichert. Der Button ist ein bisschen versteckt oben auf der Seite.

Zurück im Code-Tab, stellen wir sicher, dass der Code ausgespielt wurde, und klicken Test. Die Ausgabe ist in vier Abschnitte "Test Event Name", "Reponse", "Function Logs" und "Request ID" unterteilt. Jetzt sollte die oben erzeugte Response, also eine Weiterleitung zur Login-Seite sichtbar sein. So weit, so gut.

Übrigens, wer sich jetzt wundert, wohin der Code verschwunden ist, sollte genau auf die Tabs oberhalb des Editors achten:

Editor-Tabs

Um weiter zu editieren, muss man den Tab ganz links, index.mjs klicken, weil die Ausführung des Tests automatisch zum Tab Execution result rechts wechselt.

Wir gehen jetzt zurück zum Test-Tab und erzeugen ein zweites Event wrongEmail:

{
    "Records":
        [
            "cf": {
                "request": {
                    "querystring": "email=eve@example.com"
                }
            }
        ]
}

Nicht vergessen zu speichern! Dann gehen wir zurück zum Code-Tab und wählen im Dropdown rechts des Test-Buttons das neue Event "wrongEmail' und klicken auf Test. Dieses Mal gibt es eine AccessDeniedException mit einer Fehlermeldung wie "User: arn:aws:sts::12345:assumed-role/photoLogin-role-7kt5upfa/photoLogin is not authorized to perform: ssm:GetParameter on resource: arn:aws:ssm:us-east-1:56789:* because no identity-based policy allows the ssm:GetParameter action". Das bedeutet einfach, dass die Lambda-Funktion keine Rechte hat, Parameter aus dem AWS System Manager zu holen.

Parameter für AWS System Manager erzeugen

Als die Lambda-Funktion erzeugt wurde, hat AWS automatisch eine Ausführungs-Rolle für sie erzeugt. Das ist die Identität der Funktion während der Ausführungszeit. Dieser Rolle muss nun das Recht gegeben werden, Parameter aus dem Systems Manager zu holen. Die Fehlermeldung schlägt vor, der Rolle Rechte für alle Parameter zu geben. Ich empfehle das nicht, sondern würde den Lesezugriff auf die Parameter beschränken, die tatsächlich benötigt werden.

Man könnte diese Parameter auch im AWS Secrets Store speichern, was aber teurer und komplizierter ist. Wenn die eigenen Anforderungen abweichen, kann man den Code aber leicht anpassen.

Wir melden uns jetzt aber erst einmal an der Konsole des AWS Systems Manager und navigieren zu Anwendungsverwaltung | Parameterspeicher. Dort erzeugen wir drei Parameter.

Der erste muss /photos/keyPairId heißen. Die Querstriche (Slashes) dienen dazu, eine Hierarchie zu schaffen. Als Stufe wählen wir Standard, als Typ Zeichenfolge, und als Wert tragen wir die ID des öffentlichen Schlüssels ein. Diese findet man in der CloudFront-Verteilung unter Key management | Public keys.

Nicht vergessen, den Wert zu speichern! Wer lieber SecureString als Typ benutzen möchte, muss den Code der Lambda-Funktion ändern und beim Aufruf der Funktion getParameter() ein zweites Argument true übergeben.

Wir erzeugen nun einen zweiten Parameter /photos/privateKey, dieses Mal mit dem Typen SecureString, kopieren den Inhalt der Datei public_key.pem und fügen ihn als Wert ein.

Der dritte Parameter /photos/registeredEmails muss eine durch Kommas separierte Liste von E-Mail-Adressen speichern, die Zugriff auf die Site haben sollen. Bitte nicht irgendwelche Adressen erfinden oder Platzhalter-Domains wie @example.com verwenden, weil der Test echte E-Mails verschickt, und man will sicher keine signierten URLs im Internet herumschicken. Diese Variable sollte auch vom Typ SecureString sein. Andernfalls muss der Code entsprechend angepasst werden.

Weshalb sollte man als Typ nicht StringList verwenden?

  1. Weil es keine SecureStringList gibt.
  2. Eine String-Liste ist einfach nur ein String mit durch Kommas separierten Werten.

Auf jeden Fall sollte eine E-Mail-Adresse enthalten sein, auf die man Zugriff hat, damit die Funktionalität getestet werden kann.

Zugriff auf den Parameterspeicher gewähren

Nachdem wir die Parameter erzeugt haben, haben wir auch deren ARNs (Amazon Resource Names) und können den Zugriff der Funktionsrolle auf diese drei Parameter beschränken.

Wir gehen zur AWS IAM Konsole, Zugriffsverwaltung | Rollen und suchen nach photosLogin. Alternativ kann den Test noch einmal ausführen, und den genauen Namen der Rolle dem Text der Ausnahme entnehmen. Wir klicken auf den Namen der gefundenen Rolle, dann im Tab Berechtigungen den Button Berechtigungen hinzufügen und wählen Inline-Richtlinie erstellen aus.

Auf der nächsten Seite lassen sich die Berechtigungen angeben.

Als Service, wählen wir Systems Manager aus.

Unter Aktionen zugelassen die Gruppe Lesen ausklappen, und GetParameter wählen.

Unter Ressourcen muss die Auswahl Spezifisch getroffen sein. Dann klicken wir auf den Link ARNs hinzufügen.

Im nächsten Dialog wählen wir die verwendete Ressourcenregion aus, geben photos/* als Parameternamen an, und speichern mit ARNs hinzufügen. Dann klicken wir Weiter, geben einen Richtliniennamen wie "photos-email-parameter-lesen" ein, und speichern mit Richtlinie erstellen.

Ein Klick auf den Richtliniennamen sollte folgendes JSON zeigen:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": "ssm:GetParameter",
            "Resource": "arn:aws:ssm:us-east-1:ACCOUNT:parameter/photos/*"
        }
    ]
}

Zurück bei der Lambda-Funktion wählen wir jetzt das Event wrongEmail zum Testen aus, und sollten eine Weiterleitung zu /login-status.html als Resultat sehen. Es wurde aber noch keine Mail verschickt, weil die angegebene Adresse nicht im Systems Manager hinterlegt ist.

Setup für den E-Mail-Versand

Aber jetzt wollen wir auch endlich eine E-Mail in unserer Inbox sehen. Ein passendes Test-Event dafür sieht ungefähr so aus:

{
    "Records":
        [
            "cf": {
                "request": {
                    "querystring": "email=you@example.com"
                }
            }
        ]
}

Die Schlaueren haben sich vielleicht schon gedacht, dass sie "you@example.com" mit der eigenen E-Mail-Adresse, die im letzten Schritt konfiguriert wurde, ersetzen müssen.

Wir speichern das Event und lassen Test laufen. Bumm! Die nächste AccessDeniedException wartet schon auf uns und sieht in etwa so au:

  "errorMessage": "User: arn:aws:sts::12345:assumed-role/photoLogin-role-7kt5upfa/photoLogin is not authorized to perform `ses:SendEmail' on resource `arn:aws:ses:us-east-1:403172062108:identity/you@example.com'",

Okay, wir wissen schon, was zu tun ist. Wir müssen der Lambda-Ausführungsrolle das Recht geben, Mails mit dem AWS Simple Email Service zu verschicken. Dazu folgen wir prinzipiell den Schritten weiter oben, wählen aber SES v2 (Nicht SES!) als Service aus. Unter Aktionen zugelassen wählen wir SendEmail von der Zugriffsebene Schreiben aus, und unter Ressourcen klicken wir dieses Mal Alle, nicht Spezifisch.

In Abhängigkeit vom Anwendungsfall kann man die Rechte auch enger fassen. Aber im Allgemeinen wollen wir der Rolle das Recht geben, Mails an beliebige Adressen zu verschicken. Man kann auch ein bestimmtes Template angeben, aber das würde den Rahmen dieser Darstellung sprengen.

Wir klicken jetzt Weiter, vergeben einen sinnvollen Namen wie photos-mails-schicken und klicken dann Richtlinie erstellen.

Wenn man den Test jetzt wieder startet kann es gut sein, dass das nächste Problem in Form einer MessageRejected-Ausnahme auftaucht: "Email address is not verified. The following identities failed the check in region US-EAST-1: you@examplen.com". Der Grund ist, dass Amazon verlangt, E-Mail-Adressen zu verifizieren, bevor sie als Absender-Adresse verwendet werden können.

E-Mail-Adresse verifizieren

Dazu öffnen wir die Amazon Simple Email Service- bzw. SES-Konsole und klicken auf Konfiguration | Identitäten und dann auf Identität erstellen. Als Identitätstyp wählen wir E-Mail-Adresse, geben die Adresse ein und klicken Identität erstellen.

Man sollte jetzt eine Mail von Amazon Web Services in der Inbox haben. Nach einem Klick auf den Bestätigungslink, und zurück in der SES-Konsole, sollte die E-Mail-Adresse jetzt als Verifiziert gelistet sein.

Wird der Test der Lambda-Funktion erneut ausgeführt, sollte jetzt endlich eine Mail mit einem sehr langen Bestätigungslink angekommen sein. Dem Link zu folgen, wird allerdings noch immer nicht funktionieren, weil er auf /auth zeigt, und dieser Endpunkt noch nicht konfiguriert wurde.

Wer plant, Absenderadressen wie do-not-reply@example.com zu verwenden, muss eine Domäne anstatt einer Email-Adresse verifizieren, weil diese Adresse ja offensichtlich nicht für den Empfang von Mails konfiguriert ist. Wie eine Domäne verifiziert wird, kann der SES-Doku zu entnommen werden.

Aus der SES-Sandbox herausgehen

Während der Entwicklung der Site ist es relativ wahrscheinlich, dass Sender- und Empfängeradresse identisch sind. Dann reicht es aus, lediglich diese Adresse zu verifizieren.

Fügt man jedoch weitere Empfängeradressen hinzu, stößt man eventuell noch einmal auf das alte Problem mit der MessageRejected-Ausnahme: "Email address is not verified. The following identities failed the check in region US-EAST-1: my.friend@example.com".

Beginnt man SES in einer Region zu nutzen, befindet man sich automatisch in einer Sandbox. Das lässt sich von der SES-Konsole aus unter Konto-Dashboard überprüfen. Befindet man sich in der Sandbox, sieht man eine Infobox mit Anweisungen dafür, wie man die Sandbox verlässt.

Eine der Einschränkungen der Sandbox ist, dass auch Empfängeradressen, und nicht nur Absenderadressen verifiziert werden müssen. Das ist natürlich kein Zustand für unsere Applikation. Wir klicken daher auf Erste Einrichtung-Seite ansehen, und folgen den Anweisungen. Amazon verarbeitet die Anfrage manuell, weshalb eine Antwort bis zu 24 Stunden auf sich warten lässt. Weitere Einzelheiten lassen sich der SES-Dokmentation von Amazon entnehmen.

Bis das Konto die Sandbox verlassen hat, muss die verifizierte E-Mail-Adresse sowohl als Absender- als auch als Empfängeradresse herhalten.

Assoziieren der Lambda@Edge-Funktion

Wir haben jetzt eine Lambda-Funktion, die einen signierten URL erzeugt, aber sie wird nicht getriggert. Das muss erst im Verhalten der Cloudfront-Verteilung konfiguriert werden. Diese Einstellungen finden sich unten auf der Seite im Abschnitt Functions association gesehen.

Viewer- oder Origin-Requests/Responses?

In diesem Abschnitt gibt es vier Einstiegspunkte, für Viewer-Requests, Viewer-Responses, Origin-Reguests und Origin-Responses. Was ist damit jeweils gemeint?

Die Besucherinnen der Site werden Viewer genannt, und die von ihnen an die Edge-Server geschickten Requests werden Viewer-Requests genannt. Der Edge-Server hat entweder eine Antwort im Cache oder leitet den Request zum konfigurierten Ursprung (Origin) weiter. Der Ursprung ist dabei entweder ein S3-Bucket oder eine andere Website. Dies wird Origin-Request genannt.

Die Origin-Response ist die Antwort des Ursprungs an den Edge-Server und die Viewer-Response die Antwort des Edge-Servers an die Besucherin bzw. den Viewer.

Theoretisch könnte man den Login-Handler mit dem Origin-Request assoziieren, aber es ist effizienter, dafür den Viewer-Request zu wählen, damit der Edge-Server nicht den Ursprung kontaktieren muss, um die Anfrage zu bearbeiten.

Eine neue Version veröffentlichen

Jede Lambda-Funktion hat einen allgemeinen ARN (den eindeutigen Bezeichner) und einen für jede veröffentlichte Version. Diese ARNs unterscheiden sich lediglich durch einen abschließenden Doppelpunkt, gefolgt von einer ganzzahligen Versionsnummer.

Aber zunächst einmal muss eine Version veröffentlicht werden. Dafür kehren wir zur Lambda-Funktion zurück, stellen sicher, dass der Button Deploy ausgegraut ist, und klicken Neue Version veröffentlichen.

Die Anzeige, die jetzt zu sehen ist, sieht fast aus, wie die gewohnte, aber mit einem subtilen Unterschied. Die Tabs Versionen und Aliasse sind verschwunden, und in der Quelltext-Vorschau gibt es eine Infobox, die uns mitteilt: "Sie können nur Ihren Funktionscode bearbeiten oder eine neue .zip- oder .jar-Datei von der unveröffentlichten Funktionsseite hochladen".

In der Funktionsübersicht sollte der Funktionen-ARN dieser spezifischen Version zu sehen sein. Wir kopieren den ARN ins Clipboard, weil wir ihn für den nächsten Schritt brauchen. Um den Code der Funktion zu bearbeiten, muss man zur Brotkrumen-Navigation oben auf der Seite scrollen, und den Namen der Funktion klicken.

Die Lambda-Funktion mit den Viewer-Requests assoziieren

Zurück auf der Konfiguration für das Verhalten /login, müssen wir ans Seitenende zu Function associations scrollen. Als Funktionstyp muss Lambda@Edge ausgewählt sein, dann der Funktions-ARN auf der rechten Seite eingetragen, und mit Save changes gespeichert werden.

Wahrscheinlich kommt jetzt die nächste Fehlermeldung "The function execution role must be assumable with edgelambda.amazonaws.com as well as lambda.amazonaws.com principals. Update the IAM role and try again. Role: arn:aws:iam::ACCOUNTID:role/service-role/photosLogin-role-123xyz".

Um das zu lösen, müssen wir wieder wie weiter oben beschrieben die Rolle suchen. Dann wechseln wir vom Tab Berechtigungen zu Vertrauensbeziehungen und editieren die Richtlinie so ab, dass sie folgendermaßen aussieht:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": [
                    "lambda.amazonaws.com",
                    "edgelambda.amazonaws.com"
                ]
            },
            "Action": "sts:AssumeRole"
        }
    ]
}

AWS hatte diese Richtlinie automatisch bei der Erstellung der Ausführungsrolle der Funktion erzeugt. Aber um sie als Lambda@Edge-Service auszuführen muss als Principal Service edgelambda.amazonaws.com zugefügt werden, siehe Zeile 9. Ursprünglich ist Service eine Zeichenkette und muss jetzt in ein Array umgewandelt werden. Man kann die Richtlinie aber auch einfach von hier kopieren.

Jetzt können wir zurück zur Konfiguration des Verhaltens für /login gehen und speichern. Das sollte dieses Mal klappen. Gibt man allerdings keine spezifische Version der Funktion an, kommt es zu einer Fehlermeldung "The function ARN must reference a specific function version. (The ARN must end with the version number.) ARN: arn:aws:lambda:us-east-1:ACCOUNTID:function:photosLogin". Kopiert man den ARN einer Version in das Eingabefeld, sollte der Fehler verschwinden.

Zeit, auszuprobieren. Wir öffnen /login.html der Site im Browser (das Attribut action des Formulars sollte auf /login ohne .html zeigen), geben eine gültige E-Mail-ADresse ein, und schicken das Formular ab. Wir sollten jetzt zur Seite/login-status.html weitergeleitet werden und einen signierten URL in unseren Mails erhalten.

Die Authentifizierungs-Lambda-Funktion

Wir brauchen noch mindestens einen weiteren Endpunkt, weil der signifizierte Authentifizierungs-URL zur Zeit noch nach Lala-Land zeigt.

Authentifizierungs-Code

In der AWS-Lambda-Konsole erzeugen wir eine neue Funktion mit einem Namen wie photosAuth. Man könnte sich ein wenig Ärger sparen, und für die Standard-Ausführungsrolle Verwenden einer vorhandenen Rolle auswählen, und die Standard-Ausführungsrolle der Login-Funktion wählen. Das ist Geschmackssache.

Die Dummy-Implementierung muss jetzt überschrieben werden:

import { SSMClient, GetParameterCommand } from '@aws-sdk/client-ssm';
import { getSignedCookies } from '@aws-sdk/cloudfront-signer';

const hostname = 'www.example.com';
const prefix = 'photos';
const region = 'us-east-1';
const cookieTTL = 30 * 24 * 60 * 60 * 1000;
const useSessionCookies = false;

const baseUrl = `https://${hostname}`;

const ssm = new SSMClient({ region });

const cache = {};

export const handler = async (event) => {
    cache.keyPairId ??= await getParameter('keyPairId');
    cache.privateKey ??= await getParameter('privateKey', true);

    const { keyPairId, privateKey } = cache;

    const expires = new Date(new Date().getTime() + cookieTTL);
    const expiresUTC = expires.toUTCString();

    const policy = JSON.stringify({
        Statement: [
            {
                Resource: `${baseUrl}/*`,
                Condition: {
                    DateLessThan: {
                        'AWS:EpochTime': Math.floor(expires.getTime() / 1000),  
                    },          
                },
            },
        ],
    });

    const signedCookie = await getSignedCookies({ keyPairId, privateKey, policy });
    
    const cookies = [];
    Object.keys(signedCookie).forEach((key) => {
       cookies.push(getCookieHeader(key, signedCookie[key], expiresUTC)); 
    });

    return {
        status: '302',
        statusDescription: 'Found',
        headers: {
            location: [{
                key: 'Location',
                value: `${baseUrl}/`,
            }],
            'cache-control': [{
                key: "Cache-Control",
                value: "no-cache, no-store, must-revalidate"
            }],
            'set-cookie': cookies,
        },
    };
};

const getParameter = async (relname, WithDecryption = false) => {
  const Name = `/${prefix}/${relname}`;
    const command = new GetParameterCommand({Name, WithDecryption});

    try {
        const response = await ssm.send(command);
        return response.Parameter.Value;
    } catch (error) {
        console.error(`Error retrieving parameter '${Name}': `, error);
        throw error;
    }
};

const getCookieHeader = (key, value, expiresUTC) => {
    const fields = [
            `${key}=${value}`,
            'Path=/',
            'Secure',
            'HttpOnly',
            'SameSite=Lax',
    ];

    if (expiresUTC) fields.push(`Expires=${expiresUTC}`);

    return {
        key: 'Set-Cookie',
        value: fields.join(';'),
    }
}

Lediglich die Zeilen 4-8 müssen an die eigenen Gegebenheiten angepasst werden.

Als Hostnamen wählen wir entweder den CloudFront-Namen wie abc123.cloudfront.com oder die alternative Domäne aus, sofern die konfiguriert wurde.

Das Präfix in Zeile 5 muss dasselbe sein, das für die Login-Funktion gewählt wurde.

Die Region kann frei gewählt werden.

Die Variable cookieTTL definiert die Lebenszeit der Cookies in Mikrosekunden, in diesem Falle 30 Tage. Nach diesem Zeitpunkt verfällt das Cookie. Man könnte allerdings auf die Idee kommen, das Verfallsdatum des Cookies manuell im Browser zu ändern, aber das Verfallsdatum ist auch Teil der signierten Richtlinie. Es kann zwar nicht verhindert werden, dass solche manipulierten Cookies verschickt werden. Aber CloudFront würde die Signatur dann einfach nicht verifizieren und die Anfrage zurückweisen.

In Zeile 8 schließlich können noch Session-Cookies aktiviert werden, indem der Wert für useSessionCookies von false auf true geändert wird. In diesem Falle verfällt das Cookie noch immer nach cookieTTL Mikrosekunden aber der Browser ist angehalten, das Cookie zu löschen, sobald das Browserfenster geschlossen wird. Mit anderen Worten ist das Cookie gültig, bis der Ablaufzeitpunkt erreicht wurde oder das Browserfenster geschlossen wurde, was auch immer zuerst geschieht.

Konfiguration der Authentifizierungsfunktion

Einige der Einstellungen, die für die Login-Funktion vorgenommen werden mussten, werden auch für die Authentifizierungs-Funktion benötigt. Wir listen die entsprechenden Punkte hier nur auf. Was genau zu tun ist, kann man der Beschreibung der Die Login Lambda-Funktion entnehmen.

Zu tun ist:

  1. Die Funktion mit dem Button Deploy ausspielen.
  2. Eine Version der Funktion veröffentlichen.
  3. Die Standard-Ausführungsrolle der Funktion benötigt Zugriff auf die Parameter im Systems Manager.
  4. Der Service-Principal edgelambda.amazonaws.com muss den Vertrauensbeziehungen der Standard-Ausführungsrolle zugefügt werden.
  5. Die Funktion muss mit den Viewer-Requests des Verhaltens /auth der CloudFront-Distribution assoziiert werden.

Wir starten den nächsten Login-Versuch, warten auf die Mail und klicken den Link. Das sollte ausreichen, um die Site für 30 Tage zu benutzen.

Test

Schaut man sich den Code der Authentifizierungsfunktion näher an, bemerkt man, dass sie das Event überhaupt nicht beachtet. Man kann deshalb zum Testen einfach die Vorgabe oder ein eigenes, leeres Event {} verwenden.

Auf jeden Fall sollte jetzt ein Redirect zur Startseite mit drei set-cookie-Headern CloudFront-Policy, CloudFront-Key-Pair-Id und CloudFront-Signature zurückgeliefert werden.

Fehlerbehebung

Es ist mehr als wahrscheinlich, dass unerwartete Fehler auftreten, die hier nicht beschrieben sind. Hinterlasst in diesem Fall einfach einen Kommentar, aber es schadet auch nichts, wenn man in der Lage ist, das Problem selber zu analysieren.

CloudWatch-Logs

Im "IDE"-Abschnitt der Lambda-Funktion gibt es ein Tab Überwachen, das bis jetzt nicht erwähnt wurde. Hier findet man alle möglichen Metriken für die Funktion, aber auch einen Butten CloudWatch-Protokolle anzeigen, der einen zum AWS-Monitoring-Service CloudWatch bringt.

Wann immer ein Test der Funktion ausgeführt wird, taucht ein Eintrag in der Region us-east-1 von CloudWatch auf. Man sieht die Meldungen aber auch im Tab Execution result nachdem der Test terminiert hat.

Was aber, wenn eine getriggerte Ausführung nicht tut, was sie soll? Wenn man Glück hat, sieht man dafür einen Log-Eintrag. Man muss ihn aber erst einmal finden. Wenn man einen Request an die Site schickt, wird der von einem Edge-Server in der Nähe verarbeitet, und die Events liegen in CloudWatch für die Region, zu der der Edge-Server gehört.

Aber selbst, wenn man die richtige Region weiß oder rät, sieht man wahrscheinlich noch immer keine Logs. Der Grund dafür ist etwas versteckt im Abschnitt Lamba@Edge-Protokolle der CloudFront Entwickler-Dokumentation:

CloudFront stellt Protokolle für Edge-Funktionen auf einer Best-Effort-Basis bereit. Der Protokolleintrag für eine bestimmte Anfrage wird möglicherweise viel später übermittelt, als die Anfrage tatsächlich verarbeitet wurde; in seltenen Fällen kann es auch sein, dass ein Protokolleintrag gar nicht übermittelt wird.

Hm, meiner Erfahrung nach ist der seltene Fall nicht die Ausnahme, sondern die Regel. Vielleicht habe ich aber auch einfach nicht lange genug gesucht oder gewartet. Auf jeden Fall sollte man nicht erwarten, nach der Ausführung der Lambda-Funktion irgendwelche Log-Einträge zu finden.

Test-Events erzeugen

Eine bessere Strategie ist die Erzeugung von Test-Events, die der Situation entsprechen, in welcher das Problem auftritt. Damit erhält man eine unmittelbare Rückmeldung, die bei der Lösung des Problems helfen könnte. Ein typisches Beispiel ist ein Tippfehler bei den hinterlegten E-Mail-Adressen.

curl benutzen!

Das Tool curl ist extrem hilfreich beim Debugging der Lambda@Edge-Funktionen. Es ist für alle Betriebssysteme, und für Microsoft "Windows" verfügbar, wo es auch als Teil von Git Bash für "Windows" ausgeliefert wird.

Ein Test des Endpunkts /login könnte beispielsweise so aussehen:

$ curl -v https://www.example.com/login?email=you@example.com
*   Trying N.N.N.N:443...
* Connected to www.example.com (N.N.N.N) port 443
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
*  CAfile: /path/to/share/curl/curl-ca-bundle.crt
*  CApath: none
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256
* ALPN: server accepted h2
* Server certificate:
*  subject: CN=*.example.com
*  start date: Nov 22 00:00:00 2023 GMT
*  expire date: Dec 21 23:59:59 2024 GMT
*  subjectAltName: host "www.example.com" matched cert's "*.example.com"
*  issuer: C=US; O=Amazon; CN=Amazon RSA 2048 M03
*  SSL certificate verify ok.
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://www.example.com/login?email=you@example.com
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: www.example.com]
* [HTTP/2] [1] [:path: /login?email=you@example.com]
* [HTTP/2] [1] [user-agent: curl/8.4.0]
* [HTTP/2] [1] [accept: */*]
> GET /login?email=you@example.com HTTP/2
> Host: www.example.com
> User-Agent: curl/8.4.0
> Accept: */*
> 
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
< HTTP/2 302 
< content-length: 0
< location: https://www.example.com/login-status.html
< server: CloudFront
< date: Wed, 07 Feb 2024 16:37:59 GMT
< cache-control: max-age=100
< x-cache: LambdaGeneratedResponse from cloudfront
< via: 1.1 4793c904d4c404e9b797f8328aa848d0.cloudfront.net (CloudFront)
< x-amz-cf-pop: SOG30-C1
< x-amz-cf-id: BCfk5fQb2bayCi00__yXUazQmrZCE18sUQmWetDnEqZujOATa4MaNA==
< x-xss-protection: 1; mode=block
< x-frame-options: SAMEORIGIN
< referrer-policy: strict-origin-when-cross-origin
< x-content-type-options: nosniff
< strict-transport-security: max-age=31536000
< 
* Connection #0 to host www.example.com left intact

Nach der TLS-Abwicklung (die Zeilen, die mit * anfangen), sieht man den Request (die Zeilen, dit mit < anfangen) und auch den interessanteren Teil, nämlich die Response (die Zeilen, die mit > anfangen). Die Antwort ist in diesem Fall eine Weiterleitung zu /login-status.html, was unseren Erwartungen entspricht.

Eine Sache, auf die man ein Auge haben sollte, sind age:-Header in der Antwort, die anzeigen, dass die Antwort aus dem Cache des Edge-Servers kommt. Wir haben das Caching im Moment noch ausgeschaltet, um genau diesen Fall zu verhindern, aber es wird natürlich später im Produktiv-Modus passieren.

Debug-Ausgabe in den Body oder in eigene Header

Was aber, wenn etwas anderes nicht funktioniert? Zum Beispiel, wenn wir keine E-Mail erhalten? Auf CloudWatch zu warten, ist vermutlich keine Option, weshalb Debug-Anweisungen mit console.log() nicht viel helfen.

Besonderes während der Entwicklung der Lösung, bevor die URLs veröffentlicht sind, gibt es aber eine simple Alternative. Man kann die Debug-Ausgabe einfach in den Body der Response packen. Betrachten wir dazu zum Beispiel die Return-Anweisung dieser Handler-Funktion:

return {
        status: '200',
        statusDescription: 'Okay',
        headers: {
            'content-type': [{
                key: 'Content-Type',
                value: 'text/html'
            }]
        },
        body: 'E-Mail-Adresse nicht gefunden!',
    };

Damit kann man die Ausgabe sogar im Browser sehen.

Die Benutzerinnen mit Debugging-Ausgaben zuzuspammen, ist vermutlich nicht die beste Lösung, aber zum Beispiel bei Weiterleitungen sind diese Ausgaben noch nicht einmal sichtbar, während man selbst sie aber noch mit curl sieht. Aber eine bessere Option sind vermutlich Custom-Header wie x-debug-message.

Aber selbst mit diesen Tricks macht das Debugging von Lambda@Edge-Funktionen noch immer nicht richtig Spaß, denn man muss immer:

  1. Den Code ändern.
  2. Eine neue Version veröffentlichen.
  3. Den Funktions-ARN in der Funktions-Verknüpfung des Verhaltens ändern.
  4. Warten, dass das neue Verhalten zu den Edges ausgerollt wurde.

Optional: Eine Logout-Funktion

Unsere Lösung hat derzeit noch eine Macke: Man kann sich nicht ausloggen, was problematisch ist, wenn ein öffentlich zugänglicher Computer benutzt wird. Aber die Implementierung einer Logout-Funktion sollte jetzt keine Schwierigkeit mehr darstellen:

Logout-Funktions-Code

Dazu erzeugen wir eine Funktion photosLogout (oder so ähnlich):

const loginUrl = 'https://www.example.com/login.html';

export const handler = async (event) => {
    const cookies = [];
    ['CloudFront-Policy', 'CloudFront-Key-Pair-Id', 'Cloud-Front-Signature']
    .forEach((key) => {
       cookies.push(getCookieHeader(key)); 
    });

    return {
        status: '302',
        statusDescription: 'Found',
        headers: {
            location: [{
                key: 'Location',
                value: loginUrl,
            }],
            'cache-control': [{
                key: "Cache-Control",
                value: "no-cache, no-store, must-revalidate"
            }],
            'set-cookie': cookies,
        },
    };
};

const getCookieHeader = (key) => {
    return {
        key: 'Set-Cookie',
        value: [
            `${key}=`,
            'Path=/',
            'Expires=Thu, 01 Jan 1970 00:00:00 GMT',
            'Secure',
            'HttpOnly',
            'SameSite=Lax',
        ].join(';'),
    }
}

Das sollte mittlerweile weitestgehend selbsterklärend sein. Der Wert der drei CloudFront-Cookies CloudFront-Policy, CloudFront-Key-Pair-Id, Cloud-Front-Signature wird auf einen leeren Wert gesetzt und das Ablaufdatum im Feld Expires auf einen Zeitpunkt in der Vergangenheit. So werden konventionellerweise Cookies im Browser "gelöscht".

Sicherer wäre es eigentlich, über alles Cookies, und nicht nur über bekannte Cookies zu iterieren, und sie alle zu "löschen".

Konfiguration der Logout-Funktion

Wenn die Funktion mit dem Verhalten /logout assoziiert wird, stößt man einmal mehr auf das Problem "function execution role must be assumable with edgelambda.amazonaws.com as well as lambda.amazonaws.com principals". Also öffnen wir die Standard-Ausführungsrolle der Lambda-Funktion im IAM, und fügen edgelambda.amazonaws.com den Vertrauensbeziehungen zu. Nach ein paar Sekunden Warten, damit die Änderung zu CloudFront propagiert wurde, kann das Verhalten dann gespeichert werden.

Danach ist noch einmal Warten angesagt, damit die Konfiguration ausgerollt wird. Wenn das Deployment abgeschlossen ist, sollte der "Logout"-Link unten auf den HTML-Seiten zur Login-Seite weiterleiten. Alternative kann man auch einfach https://www.example.com/logout in die Adresszeile des Browsers eingeben.

Schlussbetrachtungen und Aufräumen

Jetzt läuft prinzipiell alles, aber es sollten noch einige kleinere Änderungen vorgenommen werden, bevor der URL der Site an die Benutzer weitergegeben wird.

Fehlerseite für 403 Forbidden einrichten

Besuchen Benutzerinnen die Site, ohne authentifiziert zu sein, sollten sie zur Login-Seite weitergeleitet werden. Das ist einfach zu erreichen.

Dazu klickt man auf die Liste der Verteilungen in CloudFront, dann auf Error pages und Create custom error response. Wir selektieren den Fehlercode 403: Forbidden und setzen Customize error message auf "yes".

Im Feld Response page path muss /login.html eingetragen werden, so dass Benutzer zum Login aufgefordert werden, statt eine Fehlermeldung zu sehen. Der HTTP-Fehlercode wird auf 403 gesetzt und dann mit einem Klick auf Create custom error response gespeichert.

Sobald die Änderung ausgerollt wurde, sollten alle nicht authentifizierten Benutzer zur Login-Seite weitergeleitet werden.

Fehlerseite für 404 Not Found einrichten

Das aktuelle Setup hat noch immer einen kleinen Fehler. Falls Benutzerinnen eine ungültige Adresse eingeben oder einem defekten Link folgen, bekommen sie einen Fehler 403 und werden zur Login-Seite weitergeleitet, obwohl sie authentifiziert sind. Das hört sich etwas unlogisch an, aber Amazon macht es nun einmal so.

Aber dafür haben wir am Anfang die HTML-Seite /404-not-found.html angelegt. Wir erzeugen also eine zweite Fehlerseite für den Fehlercode 404 und tragen einfach diese Seite ein.

Das Problem mit /index.html

Beim Anlegen einer CloudFront-Verteilung, wird nach einem "Default root object" gefragt, und normalerweise wird hier index.html eingegeben. Der Effekt ist, dass für einen Request https://www.example.com/ die Datei index.html auf der obersten Ebene zurückgeliefert wird.

Normalerweise würde man erwarten, dass analog für einen Request https://www.example.com/about/ der Inhalt der Seite /about/index.html zurückgeliefert wird, aber dem ist leider nicht so. Aus irgendeinem Grund macht Amazon das nur für die Startseite.

Prinzipiell hat man zwei Optionen. Entweder muss man dafür sorgen, dass im HTML alle Links auf die tatsächlich vorhandene Datei /subdir/index.html statt einfach auf /subdir zweiten. Ich benutze den Static Site Generator Qgoda mit dem das sehr einfach zu konfigurieren ist: Man ändert einfach den Wert der Konfigurationsvariablen permalink von der Standardeinstellung {significant-path} auf /{location}, und alle generierten Links zeigen jetzt auf den vollen Pfad. Bei anderen Content-Management-Systemen existiert eventuell eine ähnliche Einstellung.

Die andere Möglichkeit besteht darin, ein neues Verhalten für das Pfadmuster */ anzulegen, das eine Weiterleitung auf index.html an dieser Stelle zurückliefert. Wie das geht, sollte nunmehr klar sein.

Hoffentlich wird Amazon diese Einschränkung irgendwann aufheben, aber für den Augenblick sind das die beiden Optionen.

Das Problem hat eigentlich nichts mit Authentifizierung zu tun, aber während der Implementierung der Authentifizierung könnte man darauf stoßen, ohne es zu bemerken, nämlich dann, wenn der Viewer-Access für den Content bereits eingeschränkt wurde, aber noch keine 404-Fehlerseite eingerichtet wurde. In diesem Fall wird auf den Viewer-Request mit 403 geantwortet, obwohl die signierten Cookies mitgeschickt wurden.

Caching einschalten

Anfangs hatten wir Caching für alle Verhalten abgeschaltet. Die Idee war, diese Fehlerursache während der Zeit der Implementierung aus dem Weg zu haben. Jetzt ist es an der Zeit, diese Einstellung zu ändern.

Dazu gehen wir zum Verhalten Default (*) der CloudFront-Verteilung, scrollen nach unten zu Cache key and origin requests und ändern es von CachingDisabled auf CachingOptimized. Man kann sich die dazugehörige Richtlinie mit einem Klick auf View policy auch genauer ansehen.

Als Voreinstellung ist die minimale Lebenszeit TTL des Caches auf eine Sekunde gesetzt, die maximale Lebenszeit ist ein Jahr. Die Voreinstellungen für den Cache-Key sind none, also keine, weder für Header, Cookies noch Query-Strings.

Ich muss gestehen, dass ich nicht völlig verstehe, ob das einen Einfluss auf die Sicherheit der hier gezeigten Lösung hat. Der Original-Blog-Post der beiden AWS-Entwickler erwähnt, dass die TTL-Einstellung des Caches der TTL-Einstellung des Cookies entsprechen sollte, ohne allerdings darauf einzugehen, ob sich das auf die maximale oder Default-Lebenszeit bezieht.

Diese Empfehlung entspricht jedenfalls nicht meinen Beobachtungen. Mir scheint es vielmehr so zu sein, dass die Cookie-Signaturen (und die relevanten Query-Parameter im Falle von signierten URLs) immer geprüft werden, und zwar bevor ermittelt wird, ob die Antwort aus dem Cache geholt oder ein neuer Request zum Ursprung geschickt wird. Aber im Zweifelsfall probiert das bitte selber aus.

Das Caching für die anderen Verhalten kann jetzt auf die gleiche Weise wie oben beschrieben eingeschaltet werden.

Benutzer entfernen

Wie können Benutzerinnen entfernt werden? Im Augenblick gar nicht, es sei denn man ändert den Schlüssel, so dass bereits signierte Cookies ungültig werden. Alternativ muss man warten, bis die Cookies verfallen.

Will man die Zurücknahme von Berechtigungen aber implementieren, müssen zwei weitere Cookies gesetzt werden. In einem wird die Email-Adresse gespeichert und im zweiten eine Signatur dieser Adresse, ziemlich genau so, wie Amazon das mit der Signatur der Policy macht. Es wäre natürlich einfacher, wenn man die Email-Adresse einfach der signierten Richtlinie zufügen könnte, aber mir ist kein Weg bewusst, wie das gehen sollte.

Man muss dann eine weitere Lambda-Funktion dem Standard-Verhalten Default (*) zufügen, welche diese Signatur verifiziert, und überprüft, ob die angegebenen E-Mail-Adresse noch immer in der Liste der registrierten Adresse enthalten ist. Das kostet allerdings ein bisschen Performance und auch Geld, weil der Code bei jedem Seitenaufruf ausgeführt wird.

Benutzerinnen eingeloggt lassen

Mit dem aktuellen Setup können Leute die Site für n Tage benutzen und werden dann ohne Vorwarnung herausgekickt. Es kann also passieren, dass sie eine Seite erfolgreich anfordern, und im nächsten Augenblick zur Login-Seite weitergeleitet werden.

Die offensichtliche Lösung für dieses Problem besteht darin, eine Lambda-Funktion mit dem Default-Verhalten zu verknüpfen, die überprüft, ob die Cookies in nächster Zeit verfallen und gegebenenfalls neue generiert. Das verursacht das gleiche Problem wie gerade beschrieben: Die Sache wird etwas langsamer und kostet Geld.

Wenn man weiß, dass alle Leute immer über die Startseite / zur Site navigieren, kann man lediglich ein Verhalten für / mit dieser Funktion assoziieren. Das ist keine perfekte Lösung, wird aber in vielen Fällen funktionieren.

Skalieren

Wir erinnern uns, dass wir die registrierten E-Mail-Adresse in einer durch Kommas separierten Liste speichern, was weder skaliert noch einfach zu pflegen ist. Wenn die Anzahl Benutzer steigt, sollte man erwägen, sie in einer Datenbank oder verteilt über einige S3-Buckets zu speichern. Genauso kann man die Integration von Amazon Cognito oder ähnlicher Services erwägen.

Schlussfolgerung

Die hier vorgestellte Lösung ist ein sicherer, effizienter und benutzerfreundlicher Ansatz, um den Zugriff auf eine private Website ohne Passwörter auf einen überschaubaren Personenkreis einzuschränken. Sollen doch Passwörter verwendet werden, ist das auf ähnliche, und sogar einfachere Art möglich.

Leave a comment
Diese Website verwendet Cookies und ähnliche Technologien, um gewisse Funktionalität zu ermöglichen, die Benutzbarkeit zu erhöhen und Inhalt entsprechend ihren Interessen zu liefern. Über die technisch notwendigen Cookies hinaus können abhängig von ihrem Zweck Analyse- und Marketing-Cookies zum Einsatz kommen. Sie können ihre Zustimmung zu den vorher erwähnten Cookies erklären, indem sie auf "Zustimmen und weiter" klicken. Hier können sie Detaileinstellungen vornehmen oder ihre Zustimmung - auch teilweise - mit Wirkung für die Zukunft zurücknehmen. Für weitere Informationen lesen sie bitte unsere Datenschutzerklärung.