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.
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:
- Der Viewer-Zugriff auf die ganze Seite ist standardmäßig eingeschränkt.
- Nur die relativen URLs
/login.html
,/login
und/assets
sind öffentlich zugänglich. - Die Seite
/login.html
ist die Fehlerseite für alle 403-Fehler. - Das
action
-Attribut des Formulars auf/login.html
ist der Endpunkt/login?email=EMAIL
. - Der Endpunkt
/login
triggert eine Lambda@Edge-Funktion, die überprüft, ob die angegebene E-Mail-Adresse akzeptabel ist. - Ist die Überprüfung erfolgreich, wird ein signierter URL zum Endpunkt
/auth
generiert und an die angegebene E-Mail-Adresse geschickt. - Die Benutzerin klickt den Link in der E-Mail und eine weitere, vom Endpunkt
/auth
getriggerte Lambda@Edge-Funktion, sendet einen Satz signierter Cookies. - 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:
- An der S3-Konsole anmelden.
- 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. - 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:
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:
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:
- An der CloudFront-Konsole anmelden!
- Auf
Create Distribution
klicken, um eine neue Distribution zu erzeugen. - Unter
Origin | Origin domain
den neu erzeugten S3-Bucket auswählen. - Den
Origin | Origin path
leer lassen, es sei denn, man weiß, was man tut. - Unter
Default Cache Behavior | Viewer | Viewer protocol policy
wählen wirRedirect HTTP to HTTPS
aus (oder man weiß, was man tut). Default Cache Behavior | Viewer | Restrict Viewer Access
noch nicht aktivieren!- Unter
Default Cache Behavior | Cache Key and origin requests
die OptionCache policy and origin request policy (recommended)
aktiviert lassen, aber fürCache policy
den EintragCachingDisabled
ausgewählt lassen, jedenfalls für den Moment. - 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. - 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:
- Man erzeugt ein Schlüsselpaar, und lädt den öffentlichen Schlüssel in CloudFront hoch, wo er eine ID (Key-Pair-Id) zugewiesen bekommt.
- Man signiert einen URL mit dem privaten Schlüssel, um Zugriff bis zu einem bestimmten Zeitpunkt in der Zukunft zu gewähren.
- Diesen URL teilt man, mit wem man möchte.
- Berechtigte Personen senden einen Request zum Basis-URL und behaupten dabei, dass sie Zugriffsrechte bis zu einem bestimmten Zeitpunkt in der Zukunft haben.
- 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:
- Es muss ein SSH-2-RSA-Schlüsselpaar sein.
- Es muss im base64-kodierten PEM-Format vorliegen.
- 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.
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 SecurityHeadersPolicy
und 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:
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:
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?
- Weil es keine
SecureStringList
gibt. - 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:
- Die Funktion mit dem Button
Deploy
ausspielen. - Eine Version der Funktion veröffentlichen.
- Die Standard-Ausführungsrolle der Funktion benötigt Zugriff auf die Parameter im Systems Manager.
- Der Service-Principal
edgelambda.amazonaws.com
muss den Vertrauensbeziehungen der Standard-Ausführungsrolle zugefügt werden. - 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:
- Den Code ändern.
- Eine neue Version veröffentlichen.
- Den Funktions-ARN in der Funktions-Verknüpfung des Verhaltens ändern.
- 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