Zum Hauptinhalt springen

Devlog · Phase 0 · 3/7

E-Mail-Adressen ohne lokale Tabelle sammeln

Anmeldungen speichert aubia.cloud. Die Website nutzt Sitzungen und einen Redis-Cache, signiert Anfragen und benötigt für das Formular eine Netzwerkverbindung.

Veröffentlicht am Aktualisiert am 7 Min. Lesezeit

  • architecture
  • security
  • waitlist
  • gdpr

aubia.dev nimmt Anmeldungen für die Warteliste entgegen, ohne sie in einer lokalen Tabelle zu speichern. Das Repository der öffentlichen Website enthält weder ein Eloquent-Modell noch eine Migration.

Die Speicherung der Anmeldungen übernimmt aubia.cloud. Die öffentliche Website validiert das Formular und leitet die E-Mail-Adresse anschließend per HTTP weiter. Die Cloud speichert die Anmeldung, versendet den Bestätigungslink und verarbeitet den eingehenden Klick.

Diese Trennung erspart mir die Pflege einer zweiten Anmeldedatenbank. Das Formular hängt dadurch vom Netzwerk ab, und ich muss die von der Website verarbeiteten Daten trotzdem schützen. Dazu gehören Sitzungen und der Redis-Cache.

Mit dem Code versionierte Inhalte

Die Texte der öffentlichen Website liegen in JSON-Dateien, ihre Artikel in Markdown-Dateien. Das Repository enthält alle sechs Sprachen, die die Website bereitstellt. Eine Textkorrektur wird mit demselben Deployment wie eine Codekorrektur veröffentlicht.

In Produktion liest der Blog einen lokalen Katalog, der beim Build kompiliert wird. Er enthält Titel, Daten und das von Markdown und Phiki erzeugte HTML, auch für geplante Artikel. Ein fehlender, ungültiger oder veralteter Katalog führt auf den davon abhängigen Routen zu einer 503-Antwort; während der Anfrage wird kein Phiki-Rendering als Ersatz ausgeführt. Parsing mit Redis-Cache bleibt lokal und in Tests verfügbar. Dessen Einträge laufen nach 24 Stunden ab.

Bei jeder Anfrage wird das Veröffentlichungsdatum nach dem Lesen des Katalogs geprüft. Ein bereits kompilierter geplanter Artikel wird daher an seinem vorgesehenen Datum ohne weiteres Deployment zugänglich. Das HTML im Katalog macht ihn nicht vor diesem Datum öffentlich.

Redis speichert außerdem Sitzungen und die Zähler, die der Rate Limiter zur Begrenzung der Anfragehäufigkeit verwendet. Die Anmeldedatenbank wird in aubia.cloud gehostet.

An die Cloud gesendete Felder

Der FormRequest validiert die Felder und erstellt ein DTO, ein typisiertes Objekt zur Datenübergabe. Der Controller übergibt dieses Objekt an die Action SubmitWaitlistEmailAction.

Der ausgehende Body enthält die E-Mail-Adresse, die Sprache, die UTM-Parameter der ursprünglichen Kampagne und das Land des Besuchers, gelesen aus dem Header, den Cloudflare seiner Anfrage hinzufügt. Der Zeitstempel für die Prüfung der Mindestwartezeit dient der lokalen Validierung und ist nicht im DTO enthalten.

Der von der Action zurückgegebene Status bestimmt die Nachricht, die nach der Weiterleitung angezeigt wird:

php
// Auszug aus WaitlistController.
$status = $action->execute($request->toData());

if ($status->isSuccess()) {
    return back()->with('waitlist_success', $status->messageKey());
}

return back()->with('waitlist_error', $status->messageKey());

Die Flash-Nachricht wird für die nächste Anfrage in der Sitzung gespeichert. Sie enthält einen Übersetzungsschlüssel für das Ergebnis, ohne die E-Mail-Adresse zu kopieren.

Ein Validierungsfehler erfordert eine weitere Vorsichtsmaßnahme. Laravel speichert die vorherigen Formularwerte normalerweise in der Sitzung, damit sie erneut angezeigt werden können. Die öffentliche Website schließt die E-Mail-Adresse ausdrücklich von diesem Mechanismus aus:

php
// bootstrap/app.php, Konfiguration der Exceptions.
$exceptions->dontFlash(['email']);

Die Signatur des exakten Bodys

Die Action serialisiert die Felder als JSON und berechnet anschließend mit einem gemeinsam genutzten Schlüssel eine HMAC-SHA-256-Signatur. Damit kann die Cloud den empfangenen Body mit demselben Schlüssel verifizieren.

Die Namen des Konfigurationsschlüssels und des Signatur-Headers sind in diesem Auszug beispielhaft:

php
$rawBody = json_encode($data->toCloudPayload(), JSON_THROW_ON_ERROR);
$secret = (string) config('services.cloud.signing_key');

$headers = [
    'Content-Type' => 'application/json',
    'Accept' => 'application/json',
    'Origin' => 'https://aubia.dev',
    'X-Signature' => 'sha256=' . hash_hmac('sha256', $rawBody, $secret),
];

Der HTTP-Client sendet genau den Body, der signiert wurde. Der Schlüssel selbst wird bei diesem Aufruf nicht mitgesendet. Der Origin-Header gibt den laut Vertrag erwarteten Origin an; den Wert gibt der Absender selbst an.

HTTPS schützt die Übertragung. HMAC prüft die Integrität des Bodys und bestätigt, dass der Absender den Schlüssel kennt. Das Formular hinter einer signierten Anfrage kann von einem Programm ebenso ausgefüllt worden sein wie von einer Person, und die Anfrage kann später wiederholt werden.

Der Honeypot und die Mindestwartezeit filtern einige automatisierte Formularübermittlungen heraus. Ein Bot kann sie dennoch umgehen, indem er die Felder anpasst oder vor dem Senden wartet. Die Signatur und diese Filter behandeln unterschiedliche Probleme.

Im Produktivbetrieb verweigert die öffentliche Website den Versand, wenn die Signierung deaktiviert oder der Schlüssel nicht vorhanden ist. Die Einstellung, die lokal das Senden ohne Signatur erlaubt, gilt nur für die öffentliche Website: Sie deaktiviert die Verifizierung auf der Cloud-Seite nicht.

Ungewissheit nach einem Netzwerkfehler

Der CloudApiClient behandelt diese beiden Aufrufe unterschiedlich. Der GET-Aufruf für den Zähler kann nach einem Netzwerkfehler oder einer 5xx-Antwort wiederholt werden. Das erneute Lesen der Gesamtzahl führt nicht zu einer Anmeldung.

4xx-Antworten lösen keinen erneuten Versuch aus, einschließlich einer 429-Antwort wegen des Rate-Limits. Eine wegen Überlastung des Dienstes abgewiesene Anfrage sofort zu wiederholen, würde die Last erhöhen.

Beim POST erlaubt nur eine ConnectionException einen erneuten Versuch. Sobald eine HTTP-Antwort eingegangen ist, auch bei einem Serverfehler, gibt der Client die Antwort an die Action zurück, ohne den Aufruf zu wiederholen.

Wenn keine Antwort eintrifft, bleibt das Ergebnis trotzdem ungewiss. Ein Timeout kann auftreten, nachdem die Adresse in der Cloud gespeichert wurde, aber bevor die Antwort die öffentliche Website erreicht.

Die Cloud erzwingt die Eindeutigkeit der E-Mail-Adresse in der Datenbank und koordiniert gleichzeitige Übermittlungen mit Transaktionen und Sperren. Wenn zwei Erstellungsvorgänge mit dieser Eindeutigkeitsbedingung kollidieren, liest die Verarbeitung die bereits vorhandene Anmeldung erneut aus.

Diese Schutzmaßnahmen verhindern eine zweite Zeile für dieselbe Adresse. Die übrigen Verarbeitungsschritte können mehrfach ausgeführt werden: Ein erneuter Versuch des Versandjobs kann die E-Mail erneut versenden.

Die Cloud erzeugt den Token und sendet einen Link zu einer Seite auf aubia.dev. Der Link enthält den Token im Query-String, sodass der Webserver ihn beim ersten Laden empfängt.

Sobald React aktiv ist, liest die Seite den Token im Browser aus und ruft die Cloud direkt auf, um die Anmeldung zu bestätigen. Die Abmeldung folgt demselben Prinzip. Diese Aufrufe laufen nicht über den Proxy, der für die Übermittlung der E-Mail-Adresse verwendet wird.

Der erste Besuch hinterlässt außerdem eine technische Spur auf der Website. Laravel speichert die vollständige URL als vorherige URL in der Sitzung, und Inertia nimmt sie in die Seitendaten auf.

Der Link kann eine ausstehende Anmeldung 48 Stunden lang bestätigen. Die Cloud lässt täglich Adressen löschen, die nach 30 Tagen unbestätigt bleiben, wie in der Datenschutzerklärung beschrieben. Nach einer neuen Übermittlung kann sie eine Bestätigungs-E-Mail erneut senden; Willkommens- und Launch-E-Mails werden erst nach der Bestätigung versendet.

Der Ablauf der Wartelisten-Anmeldung beschreibt das Formular, die Bestätigung und den Zähler im Detail.

Sitzungen und Besucherpräferenzen

Der Rate Limiter zählt Anfragen anhand der IP-Adresse und eines E-Mail-Fingerabdrucks, zusätzlich gilt ein Gesamtlimit. Die gespeicherten Schlüssel werden gehasht und laufen mit dem Zeitfenster des Rate-Limits ab. Diese Fingerabdrücke reichen nicht aus, um die Daten zu anonymisieren.

Der redaktionelle Inhalt einer lokalisierten Seite ist für alle Besucher gleich. Die vollständige Antwort variiert je nach Flash-Nachrichten, Formularfehlern und anfragespezifischen Daten.

Die Root-Route / wählt die Sprache anhand eines Präferenz-Cookies und anschließend anhand des Accept-Language-Headers. Das Sitzungs-Cookie und das Cookie für den CSRF-Schutz werden zusammen mit diesem Sprach-Cookie gesendet. Die Theme-Präferenz wird getrennt in localStorage gespeichert und nicht automatisch an den Server gesendet.

Auch Diagnosedaten brauchen eine kontrollierte Aufbewahrungsfrist. Backups und die Löschung von Wartelistendaten werden in der Cloud verwaltet.

Eine vom Netzwerk abhängige Anmeldung

Wenn die Cloud nicht antwortet, meldet die öffentliche Website einen Fehler. Sie speichert keine Anmeldung lokal und stellt sie nicht in eine Warteschlange für eine spätere Übermittlung. Nach einem Timeout kann die Adresse bereits in der Cloud gespeichert sein; eine neue Übermittlung durchläuft dann die Logik zur Behandlung von Duplikaten.

Für den Zähler gibt es einen anderen Fallback. Die öffentliche Website bewahrt den zuletzt bekannten Wert für begrenzte Zeit auf und kann ihn während eines Ausfalls anzeigen. Wenn kein Wert verfügbar ist, wird die Zahl weggelassen.

Ich muss den gemeinsamen Schlüssel auf beiden Seiten rotieren und die API verfügbar halten. Eine Anwendung, die Anmeldungen offline annehmen muss, bräuchte ein anderes Design. Auf aubia.dev übernimmt aubia.cloud die Speicherung, Bestätigung und Löschung der Anmeldungen.

Die Beta von Aubia ist kostenlos und zeitlich unbegrenzt. Melden Sie sich an, um Ihre Einladung zu erhalten.

Auf die Warteliste

Zurück zum Blog