Das Problem
Das Dashboard von Denia Print begann mit einem gemeinsamen Benutzernamen und einem Passwort. Für eine Person reicht das. Im Team nicht mehr: Man sieht nicht, wer einen Auftrag geändert hat, und man kann einer einzelnen Person den Zugang nicht entziehen.
Ich wollte für jedes Teammitglied ein eigenes Konto, mit Rollen und ganz ohne Passwörter. Das Dashboard läuft auf Cloudflare Pages Functions mit Workers KV als Speicher. Einen Session-Server, auf den man sich stützen könnte, gibt es also nicht.
Der Ansatz
Auf dashboard.deniaprint.es gibt es zwei Wege hinein: einen Passkey (Touch ID, Face ID, Windows Hello oder das Handy) oder einen Code per E-Mail, der 10 Minuten gültig ist.
Passkeys. Auf dem Server nutze ich @simplewebauthn/server. Die Schlüssel sind ohne Benutzernamen auffindbar und die Nutzerverifikation ist Pflicht. Der Browser bietet deshalb den passenden Passkey an, ohne vorher nach einer E-Mail-Adresse zu fragen. Die Nutzerkennung im Passkey ist ein Zufallswert, nicht die E-Mail-Adresse. Die Browser-Bibliothek liegt im Repository und wird vom eigenen Host des Dashboards ausgeliefert. Die Content Security Policy braucht dadurch keine zusätzliche Quelle.
Die Challenges für die Anmeldung sind zustandslos. Die Optionen, die ich an den Browser schicke, tragen ein signiertes Ablaufdatum und gelten 5 Minuten. Eine Challenge anzufordern kostet keinen Schreibvorgang in KV. Erst nach einer gültigen Antwort des Passkeys schreibe ich eine kurze Markierung “verwendet”, damit eine Challenge genau einmal funktioniert. Lässt sich die Markierung nicht schreiben, wird die Anmeldung abgelehnt.
Codes per E-Mail. Der Code hat 10 Zeichen aus einem Alphabet ohne verwechselbare Zeichen (kein 0, O, 1 oder I), etwa 50 Bit. Groß- und Kleinschreibung, Leerzeichen und Bindestriche spielen bei der Eingabe keine Rolle. Die Details, über die ich am längsten nachgedacht habe:
- Die Antwort ist immer dieselbe, auch bei einer unbekannten Adresse. Das Formular verrät also nicht, wer ein Konto hat. Gespeichert und gemailt wird erst nach der Antwort, und eine unbekannte Adresse kostet keinen Schreibvorgang in KV.
- Jeder Versuch wird vor dem Vergleich gezählt. Lässt sich der Zähler nicht schreiben, findet kein Vergleich statt. Ein Code erlaubt 5 Versuche und funktioniert einmal.
- Jede Anfrage wird zuerst im Speicher des Isolates gezählt (pro IP, pro Adresse, pro Code) und erreicht erst danach KV. Darüber liegt eine Rate-Regel in der Cloudflare WAF für beide Login-Pfade.
Sitzungen. Eine Sitzung ist ein signiertes Cookie mit der E-Mail-Adresse, einer Sitzungs-ID und einem Ablauf nach 30 Tagen. Die Liste der Sitzungen pro Konto liegt in KV. Mitglieder sehen ihre eigenen Passkeys und Sitzungen und können sich überall abmelden. Ein Owner kann die Sitzungen anderer beenden, Passkeys entfernen und ein Mitglied deaktivieren. Meldet sich jemand auf einem Gerät an, das das Konto bisher nicht genutzt hat, bekommt das Mitglied eine E-Mail.
Rollen. Es gibt drei: Owner, Admin und Staff. Eine Rolle ist eine Liste von Berechtigungen als Text, etwa orders.write oder costs.read. Endpunkte verlangen eine Berechtigung, nie einen Rollennamen. Eine eigene Rolle ist später also eine weitere Liste, ohne dass sich ein Endpunkt ändert. Ein Endpunkt, der in der Tabelle fehlt, verlangt die Team-Berechtigung, und ein Test prüft, dass jeder Admin-Endpunkt eingetragen ist. Staff sieht den Verkaufspreis eines Auftrags, aber keine Einkaufspreise oder Margen: Die API antwortet dort mit 403 und entfernt die Kostenfelder aus jeder JSON-Antwort.
Einladungen. Eine Einladung ist ein Link, der einmal funktioniert und 7 Tage gültig bleibt. Kann die E-Mail nicht verschickt werden, zeigt das Dashboard den Link an, damit der Owner ihn selbst weitergeben kann.
Die Abwägungen
- Konten im Speicher. Um KV-Lesezugriffe zu sparen, merkt sich jedes Isolate ein Konto und seine Sitzungen 60 Sekunden lang. Ein Mitglied zu deaktivieren beendet die Sitzungen sofort in dem Isolate, das die Anfrage bearbeitet hat, und innerhalb von 60 Sekunden in allen anderen.
- E-Mail als zweite Tür. Der Code per E-Mail bleibt neben Passkeys verfügbar, weil Menschen Handys verlieren und Laptops wechseln. Das Konto ist damit so sicher wie das Postfach dahinter.
- Zustandslose Challenges gibt es nicht umsonst. Sie sparen einen Schreibvorgang für jeden Besucher, der die Login-Seite öffnet, brauchen aber die Markierung “verwendet”, um einmalig zu bleiben.
- Eine Browser-Bibliothek im Repository. Kein neuer CSP-Host, dafür sind Updates Handarbeit.
- Das alte Passwort ablösen. Das alte Formular funktionierte, bis sich ein Owner einmal mit Passkey oder Code angemeldet hatte. Seitdem antwortet die alte Route mit 410 und das Formular ist weg. Der letzte Owner kann weder herabgestuft noch deaktiviert werden, damit sich das Team nicht selbst aussperrt.
Die Kundenkonten des Shops nutzen denselben Ablauf mit einem kürzeren Code aus 8 Zeichen. Den ganzen Case gibt es auf der Projektseite von Denia Print.