← cd ../
// notities

Inloggen zonder wachtwoord met passkeys en codes per mail op Cloudflare Pages

Hoe het dashboard van Denia Print een team laat inloggen met een passkey of een code per mail, op Cloudflare Pages Functions en Workers KV, zonder één wachtwoord.

gepubliceerd
auteur
Nicky Mitnick

Het probleem

Het dashboard van Denia Print begon met één gedeelde gebruikersnaam en één wachtwoord. Voor één persoon werkt dat. Met een team niet meer: je ziet niet wie een order heeft aangepast en je kunt de toegang van één persoon niet intrekken.

Ik wilde dat elk teamlid een eigen account kreeg, met rollen, en helemaal zonder wachtwoorden. Het dashboard draait op Cloudflare Pages Functions met Workers KV als opslag. Er is dus geen sessieserver om op te leunen.

De aanpak

Op dashboard.deniaprint.es kun je op twee manieren inloggen: met een passkey (Touch ID, Face ID, Windows Hello of je telefoon) of met een code per mail die 10 minuten geldig is.

Passkeys. Aan de serverkant gebruik ik @simplewebauthn/server. De sleutels zijn vindbaar zonder gebruikersnaam en verificatie van de gebruiker is verplicht, dus de browser biedt de juiste passkey aan zonder eerst om een e-mailadres te vragen. De gebruikers-id in de passkey is een willekeurige waarde, niet het e-mailadres. De browserbibliotheek staat in de repository en wordt vanaf de eigen host van het dashboard geserveerd. De Content Security Policy heeft daardoor geen extra bron nodig.

De challenges bij het inloggen zijn stateless. De opties die ik naar de browser stuur bevatten een vervaltijd en een handtekening, 5 minuten geldig. Iedereen kan een challenge opvragen zonder dat het een schrijfactie in KV kost. Pas na een geldig antwoord van een passkey schrijf ik een kort merkje “gebruikt”, zodat een challenge één keer werkt. Lukt dat merkje niet, dan wordt de inlog geweigerd.

Codes per mail. De code heeft 10 tekens uit een alfabet zonder tekens die op elkaar lijken (geen 0, O, 1 of I), ongeveer 50 bits. Hoofdletters, kleine letters, spaties en streepjes maken bij het intypen niet uit. De details waar het meeste denkwerk in zat:

  • Het antwoord is altijd hetzelfde, ook voor een onbekend adres. Het formulier verraadt dus niet wie een account heeft. Opslaan en mailen gebeurt pas nadat het antwoord de deur uit is, en een onbekend adres kost geen schrijfactie in KV.
  • Elke poging wordt geteld vóór de vergelijking. Lukt het tellen niet, dan is er geen vergelijking. Een code staat 5 pogingen toe en werkt één keer.
  • Elk verzoek wordt eerst in het geheugen van de isolate geteld (per IP, per adres, per code) en komt pas daarna bij KV. Daarbovenop staat een rate-regel in de Cloudflare WAF op beide loginpaden.

Sessies. Een sessie is een ondertekend cookie met het e-mailadres, een sessie-id en een vervaldatum van 30 dagen. De lijst met sessies per account staat in KV. Leden zien hun eigen passkeys en sessies en kunnen overal uitloggen. Een owner kan de sessies van iemand anders beëindigen, passkeys verwijderen en een lid uitschakelen. Logt iemand in op een apparaat dat het account nog niet eerder gebruikte, dan krijgt dat lid een mail.

Rollen. Er zijn er drie: owner, admin en staff. Een rol is een lijst met rechten als tekst, zoals orders.write of costs.read. Endpoints vragen om een recht, nooit om een rolnaam. Een eigen rol is later dus één extra lijst, zonder dat een endpoint verandert. Een endpoint dat in de tabel ontbreekt, vereist het teamrecht, en een test controleert dat elk admin-endpoint in de tabel staat. Staff ziet de verkoopprijs van een order, maar geen inkoopprijzen of marges: de API antwoordt daar met 403 en haalt de kostvelden uit elk JSON-antwoord.

Uitnodigen. Een uitnodiging is een link die één keer werkt en 7 dagen geldig blijft. Kan de mail niet weg, dan toont het dashboard de link, zodat de owner hem zelf kan doorsturen.

De afwegingen

  • Accounts in het geheugen. Om KV-reads te sparen onthoudt elke isolate een account met zijn sessies 60 seconden. Een lid uitschakelen stopt de sessies meteen in de isolate die het verzoek afhandelde, en binnen 60 seconden op alle andere.
  • Mail als tweede deur. De code per mail blijft bestaan naast passkeys, want mensen raken telefoons kwijt en wisselen van laptop. Het account is daarmee zo veilig als de mailbox erachter.
  • Stateless challenges zijn niet gratis. Ze besparen een schrijfactie voor elke bezoeker die de loginpagina opent, maar hebben het merkje “gebruikt” nodig om eenmalig te blijven.
  • Een browserbibliotheek in de repository. Geen nieuwe CSP-host, maar bijwerken is handwerk.
  • Het oude wachtwoord uitfaseren. Het oude formulier bleef werken tot een owner één keer met een passkey of een code had ingelogd. Daarna antwoordt de oude route met 410 en is het formulier weg. De laatste owner kan niet gedegradeerd of uitgeschakeld worden, zodat het team zichzelf niet kan buitensluiten.

De klantaccounts van de winkel gebruiken dezelfde codeflow met een kortere code van 8 tekens. De hele case staat op de projectpagina van Denia Print.