← cd ../
// notes

Connexion sans mot de passe avec des passkeys et des codes par e-mail sur Cloudflare Pages

Comment le tableau de bord de Denia Print permet à une équipe de se connecter avec une passkey ou un code reçu par e-mail, sur Cloudflare Pages Functions et Workers KV, sans un seul mot de passe.

publié
auteur
Nicky Mitnick

Le problème

Le tableau de bord de Denia Print a commencé avec un seul identifiant partagé et un seul mot de passe. Pour une personne, cela fonctionne. Avec une équipe, plus du tout : on ne voit pas qui a modifié une commande et on ne peut pas retirer l’accès d’une seule personne.

Je voulais que chaque membre de l’équipe ait son propre compte, avec des rôles, et sans aucun mot de passe. Le tableau de bord tourne sur Cloudflare Pages Functions avec Workers KV comme stockage. Il n’y a donc pas de serveur de sessions sur lequel s’appuyer.

L’approche

Sur dashboard.deniaprint.es, on peut se connecter de deux façons : avec une passkey (Touch ID, Face ID, Windows Hello ou son téléphone) ou avec un code reçu par e-mail, valable 10 minutes.

Passkeys. Côté serveur, j’utilise @simplewebauthn/server. Les clés sont découvrables sans identifiant et la vérification de l’utilisateur est obligatoire : le navigateur propose donc la bonne passkey sans demander d’abord une adresse e-mail. L’identifiant utilisateur stocké dans la passkey est une valeur aléatoire, pas l’adresse e-mail. La bibliothèque côté navigateur se trouve dans le dépôt et est servie depuis l’hôte du tableau de bord lui-même. La Content Security Policy n’a ainsi besoin d’aucune source supplémentaire.

Les challenges de connexion sont stateless. Les options que j’envoie au navigateur contiennent une date d’expiration et une signature, valables 5 minutes. N’importe qui peut demander un challenge sans que cela coûte une écriture dans KV. Ce n’est qu’après une réponse valide d’une passkey que j’écris un court marqueur « utilisé », afin qu’un challenge ne fonctionne qu’une fois. Si ce marqueur ne peut pas être écrit, la connexion est refusée.

Codes par e-mail. Le code compte 10 caractères tirés d’un alphabet sans caractères qui se ressemblent (pas de 0, O, 1 ni I), soit environ 50 bits. Les majuscules, les minuscules, les espaces et les tirets n’ont pas d’importance à la saisie. Les détails qui ont demandé le plus de réflexion :

  • La réponse est toujours la même, y compris pour une adresse inconnue. Le formulaire ne révèle donc pas qui possède un compte. L’enregistrement et l’envoi de l’e-mail n’ont lieu qu’une fois la réponse partie, et une adresse inconnue ne coûte aucune écriture dans KV.
  • Chaque tentative est comptée avant la comparaison. Si le comptage échoue, il n’y a pas de comparaison. Un code autorise 5 tentatives et ne fonctionne qu’une fois.
  • Chaque requête est d’abord comptée dans la mémoire de l’isolate (par IP, par adresse, par code) et n’atteint KV qu’ensuite. S’y ajoute une règle de limitation de débit dans le WAF de Cloudflare sur les deux chemins de connexion.

Sessions. Une session est un cookie signé contenant l’adresse e-mail, un identifiant de session et une date d’expiration à 30 jours. La liste des sessions par compte est stockée dans KV. Les membres voient leurs propres passkeys et sessions et peuvent se déconnecter partout. Un owner peut mettre fin aux sessions de quelqu’un d’autre, supprimer des passkeys et désactiver un membre. Si quelqu’un se connecte sur un appareil que le compte n’avait encore jamais utilisé, ce membre reçoit un e-mail.

Rôles. Il y en a trois : owner, admin et staff. Un rôle est une liste de droits sous forme de texte, comme orders.write ou costs.read. Les endpoints demandent un droit, jamais un nom de rôle. Un rôle personnalisé ne sera donc plus tard qu’une liste supplémentaire, sans qu’aucun endpoint ne change. Un endpoint absent du tableau exige le droit d’équipe, et un test vérifie que chaque endpoint d’administration figure dans le tableau. Le rôle staff voit le prix de vente d’une commande, mais ni les prix d’achat ni les marges : l’API répond alors par un 403 et retire les champs de coût de chaque réponse JSON.

Invitations. Une invitation est un lien qui fonctionne une seule fois et reste valable 7 jours. Si l’e-mail ne peut pas partir, le tableau de bord affiche le lien, pour que l’owner puisse le transmettre lui-même.

Les compromis

  • Des comptes en mémoire. Pour économiser des lectures KV, chaque isolate garde en mémoire un compte et ses sessions pendant 60 secondes. Désactiver un membre arrête ses sessions immédiatement dans l’isolate qui a traité la requête, et en moins de 60 secondes dans tous les autres.
  • L’e-mail comme seconde porte. Le code par e-mail subsiste à côté des passkeys, car les gens perdent leur téléphone et changent d’ordinateur portable. Le compte est donc aussi sûr que la boîte mail qui se trouve derrière.
  • Les challenges stateless ne sont pas gratuits. Ils évitent une écriture pour chaque visiteur qui ouvre la page de connexion, mais ont besoin du marqueur « utilisé » pour rester à usage unique.
  • Une bibliothèque navigateur dans le dépôt. Pas de nouvel hôte dans la CSP, mais la mise à jour se fait à la main.
  • Retirer progressivement l’ancien mot de passe. L’ancien formulaire a continué de fonctionner jusqu’à ce qu’un owner se soit connecté une fois avec une passkey ou un code. Depuis, l’ancienne route répond par un 410 et le formulaire a disparu. Le dernier owner ne peut être ni rétrogradé ni désactivé, pour que l’équipe ne puisse pas s’enfermer dehors elle-même.

Les comptes clients de la boutique utilisent le même parcours par code, avec un code plus court de 8 caractères. Le projet complet est présenté sur la page projet de Denia Print.