Quelqu'un veut montrer un projet à un client qui n'a pas de compte. La solution évidente est un code court : six chiffres, expire en cinq minutes, l'invité le saisit et entre. Je l'ai implémenté comme ça dans un outil de notes et de fichiers partagés, et ça a marché dès le premier jour. Le problème n'a pas été que ça ne marchait pas. C'est que trois mois plus tard, j'ai dû le refaire deux fois.
Trois itérations, et la troisième est celle qui aurait dû être la première.
Ce que fait un code court, et ce qu'il ne fait pas
La première table était littéralement ceci :
CREATE TABLE guest_access_codes (
id integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
user_id text NOT NULL,
code char(6) NOT NULL,
permission permission NOT NULL,
session_duration_minutes integer NOT NULL,
code_expires_at timestamptz NOT NULL,
is_used boolean DEFAULT false NOT NULL,
used_at timestamptz,
session_expires_at timestamptz
);
Deux expirations distinctes, et ça, c'était bien vu dès le départ : code_expires_at, c'est le temps que met le code à mourir si personne ne l'utilise (cinq minutes), et session_duration_minutes, c'est la durée de la visite une fois entré. Ce sont deux choses différentes et les mélanger est une erreur classique. Un code qui vit huit heures sur WhatsApp est un trou ; une session qui se coupe au bout de cinq minutes est inutilisable.
Avec ça, tu as déjà des permissions (read ou read_write), une table de jointure pour décider à quels projets le code donne accès, et une expiration. Ça a l'air complet. Ça ne l'est pas : le code n'identifie personne. Il identifie l'invitation, pas l'invité.
Première tentative : passer l'id de la ligne
La première correction a été d'arrêter d'envoyer le code à six chiffres à chaque requête et d'envoyer l'id de la ligne. Le client gardait un codeId, le backend le résolvait, et les requêtes partaient avec Authorization: Bearer <codeId>.
Ça règle une chose réelle : le code à six chiffres ne circule plus. Il n'est plus ni dans un log d'accès ni dans une URL. Mais ça introduit pire. Le codeId est un entier auto-incrémenté. Il est devinable. Il est énumérable. N'importe qui avec un id valide peut essayer celui d'à côté et, si cette session est toujours vivante, il entre. J'ai échangé un secret court contre un identifiant qui n'est pas secret du tout.
Et ça ne résout toujours pas le problème d'origine, qui était celui-ci : si deux personnes utilisent la même invitation, elles partagent le même codeId. Tu ne peux pas en virer une sans virer l'autre. Tu ne peux pas savoir laquelle des deux a supprimé le fichier.
Ce que le token de session résout
La troisième version sépare les deux choses qui étaient collées ensemble depuis trois mois. Le code à six chiffres est un échange : il sert une fois, sur l'endpoint de connexion, et plus jamais. Ce qui en sort est un token de session :
const suffix = randomBytes(4).toString("hex");
const sessionToken = `${code}-${suffix}`;
await db
.update(guestAccessCodes)
.set({ isUsed: true, usedAt: now, sessionExpiresAt, sessionToken })
.where(eq(guestAccessCodes.id, record.id));
La réponse ne renvoie plus codeId, elle renvoie sessionToken. À partir de là, tout le frontend le trimballe : là où il y avait guestCodeId il y a désormais guestSessionToken, et le !!guestCodeId qui décidait si quelqu'un était invité est devenu !!guestSessionToken.
Ce qui change vraiment :
- Révoquer. Mettre le
sessionTokenà null sur une ligne vire cette session et seulement celle-là. Avec le code, tu n'avais personne à viser. - Auditer. Le token est dans la ligne, donc chaque action peut être attribuée à un échange précis et à une heure précise.
- Non devinable. Ce n'est plus un entier corrélatif. Quatre octets aléatoires en hexadécimal ne s'énumèrent pas.
- Un seul endroit où ça expire. Le
sessionExpiresAts'écrit dans le même update que celui qui émet le token. Il n'y a pas deux horloges qui peuvent se désynchroniser.
Ce que l'audit a trouvé, et pas moi
Des mois plus tard, j'ai passé un audit sur le code. Il en est sorti de tout — une route d'export en PDF sans aucun garde d'authentification est le pire de la liste — mais celle qui m'a le plus fait réfléchir, je l'avais notée moi-même dans le TODO : même si le code à six chiffres est consommé, ce qu'il renvoie est un token de six plus quatre, et c'est celui-là qui authentifie.
Voilà la faille. Le code court est protégé par sa durée de vie de cinq minutes et par le fait qu'il ne peut servir qu'une fois. Le token de session n'a aucune de ces deux protections : il dure des heures et il sert à chaque requête. Passer du code au token retire un vecteur et en ouvre un autre, et tu ne t'en rends compte que si tu le regardes après coup, à froid. Quatre octets suffisent contre l'énumération occasionnelle, pas contre quelqu'un qui a huit heures et une boucle.
L'autre chose qui est sortie, et qui est de manuel, c'est que l'endpoint de connexion n'enveloppait rien dans une transaction. Entre lire que le code n'est pas utilisé et le marquer comme utilisé, il y a une fenêtre. Deux requêtes simultanées avec le même code passaient toutes les deux.
Bannir par IP est plus fragile qu'il n'y paraît
Le compteur de tentatives échouées a besoin de savoir de quelle IP vient la requête, et c'est là que presque tout le monde se trompe de la même façon. Dans un autre projet, j'avais un bannissement permanent par IP et je prenais la première valeur de x-forwarded-for. Cet en-tête est une liste client, proxy1, proxy2 : chaque proxy ajoute à droite, mais personne ne nettoie ce qui était déjà écrit. Prendre le premier, c'est prendre quelque chose que le client peut inventer.
Je l'ai changé pour compter depuis la fin selon le nombre de proxies de confiance en amont :
const TRUSTED_PROXY_HOPS = Number(process.env.TRUSTED_PROXY_HOPS || 1);
const list = String(xff).split(",").map(s => s.trim()).filter(Boolean);
const idx = list.length - TRUSTED_PROXY_HOPS;
return list[idx >= 0 ? idx : 0];
Avec un proxy en amont, la dernière valeur est celle qu'écrit le proxy et que le client ne peut pas toucher. Si tu prends la première, n'importe qui contourne son bannissement en envoyant n'importe quoi, ou pire, fait bannir quelqu'un d'autre.
Le code à six chiffres n'était pas la partie difficile. La partie difficile, c'était d'accepter qu'une invitation et une session sont deux objets distincts, et que les mélanger te laisse sans pouvoir révoquer, sans pouvoir auditer, et avec un identifiant corrélatif en guise de justificatif d'accès.
