Sécuriser l’authentification Node.js : 2FA par e-mail, « remember me » et brute force
Un mot de passe, un code à usage unique et un token de session ne se protègent pas de la même manière. À partir du service d’authentification de la DXP Goodbizz et des recommandations de l’OWASP et du NIST, voici comment traiter chaque secret, et ce que recouvre vraiment la case « rester connecté ».
Dans Goodbizz, une DXP (Digital Experience Platform, une plateforme de gestion de sites et de contenus) multi-tenants développée chez Ikomobi, un même microservice Node.js et Express connecte deux populations : les administrateurs du back-office et les utilisateurs des sites clients. J’y ai repris le module d’authentification, et j’ai réalisé la double authentification par code ainsi que les sessions « remember me ». Une connexion manipule plusieurs secrets, de nature très différente, et la bonne protection de l’un serait inutile ou insuffisante pour l’autre. L’article les prend un par un, en distinguant ce que fait le projet de ce que recommandent l’OWASP et le NIST.
Pourquoi tous les secrets ne se stockent-ils pas de la même façon ?
Un mot de passe est choisi par un humain, donc souvent prévisible. Si la base fuite, l’attaquant teste des millions de candidats contre chaque empreinte, et un hachage rapide l’avantage. L’OWASP juge pour cette raison SHA-256 inadapté aux mots de passe et recommande un algorithme lent, dans cet ordre : Argon2id, scrypt, puis bcrypt, réservé aux systèmes existants où les deux premiers ne sont pas disponibles.
Goodbizz utilise bcrypt 6 avec un facteur de coût de 10, la valeur par défaut de la librairie et le minimum fixé par l’OWASP. Avec bcrypt, deux détails méritent attention. Seuls les 72 premiers octets d’un mot de passe sont pris en compte, et il s’agit bien d’octets, si bien qu’une phrase remplie d’emojis atteint la limite plus vite qu’on ne le croit. Le calcul, lui, sollicite fortement le CPU : les variantes synchrones bloquent l’event loop, quand les variantes asynchrones passent par le thread pool.
import bcrypt from "bcrypt";
const COST = 10; // OWASP minimum: raise it as far as the server allows
export const hashPassword = (password) => bcrypt.hash(password, COST);
export const verifyPassword = (password, hash) => bcrypt.compare(password, hash);Le token « remember me » demande le raisonnement inverse. Il compte 48 octets tirés au hasard, soit 384 bits, très au-delà des 64 bits que l’OWASP demande pour un identifiant de session. Aucune liste de candidats ne permet de le deviner, et un hachage lent n’apporterait qu’un coût supplémentaire à chaque requête. Une empreinte SHA-256 suffit, ce que l’OWASP propose d’ailleurs quand on redoute une fuite de la base en lecture.
import { createHash, randomBytes } from "node:crypto";
const generateToken = () => randomBytes(48).toString("base64url");
const hashToken = (token) => createHash("sha256").update(token).digest("hex");Le token brut ne vit que dans le cookie du navigateur, et la base ne conserve que son empreinte. Quelqu’un qui lirait cette table n’obtiendrait donc aucun cookie utilisable. SHA-256 a un autre avantage pratique : le résultat étant déterministe, on retrouve l’entrée directement par un index unique, ce qu’un hash bcrypt salé ne permettrait pas.
Comment envoyer un code de connexion par e-mail sans l’affaiblir ?
Sur Goodbizz, la connexion se fait en deux temps. Un mot de passe correct n’ouvre aucune session ; il déclenche l’envoi d’un code à 8 chiffres, enregistré sur le compte avec sa date de création. La session n’existe qu’une fois ce code validé, et il est alors effacé pour ne servir qu’une seule fois.
Un code court reste un secret faible. Huit chiffres représentent 90 millions de valeurs, environ 26 bits, et c’est tout le contexte autour qui le rend acceptable. Le NIST exclut l’e-mail de ce cadre, comme on le verra plus bas, mais les règles qu’il fixe pour les codes envoyés par un autre canal restent un bon repère, et l’OWASP va dans le même sens :
- une génération par un générateur cryptographique, demandée par le NIST. En Node.js, crypto.randomInt s’en charge et évite le biais de modulo, alors que Math.random n’offre aucune garantie de ce type ;
- une durée de vie courte, 10 minutes au maximum pour le NIST, l’OWASP parlant simplement d’un TTL court ;
- un usage unique, le code étant invalidé dès qu’il a été accepté ;
- un nombre d’essais strictement limité. Le NIST l’impose sous 64 bits d’entropie et fixe un plafond de 100 échecs, qu’on a tout intérêt à abaisser nettement.
import { randomInt } from "node:crypto";
const CODE_TTL_MS = 10 * 60 * 1000;
const MAX_ATTEMPTS = 5;
export const generateCode = () => String(randomInt(10_000_000, 100_000_000));
export function isCodeUsable(challenge, now = Date.now()) {
return (
challenge.attempts < MAX_ATTEMPTS &&
now - challenge.createdAt.getTime() < CODE_TTL_MS
);
}Le bouton « renvoyer le code » demande un soin particulier. S’il remet le compteur d’essais à zéro, il offre à un attaquant autant de nouvelles tentatives qu’il le souhaite ; à mon sens, ce compteur doit donc survivre au renvoi. Enfin, quand la session s’ouvre, l’OWASP recommande d’en renouveler l’identifiant, puisque le niveau de privilège vient de changer. Avec express-session, c’est le rôle de req.session.regenerate.
Que coche vraiment l’utilisateur avec « rester connecté » ?
Le service gère deux durées. La session serveur vit 8 heures par défaut, et le token « remember me », stocké dans un cookie httpOnly et secure en production, 30 jours par défaut. Quand une requête arrive sans session valide, le middleware d’authentification hache le contenu du cookie, cherche l’entrée correspondante et rouvre la session.
Chaque restauration consomme le token : l’entrée est supprimée et un nouveau token est émis. Un cookie copié cesse donc de fonctionner dès que son propriétaire rouvre une session. Si l’attaquant passe le premier, c’est en revanche l’utilisateur légitime qui perd la sienne. L’expiration est, elle, contrôlée à deux endroits. Un index TTL de MongoDB purge les entrées anciennes, mais sa tâche de fond ne tourne que toutes les 60 secondes et la suppression peut prendre plus de temps encore, d’où une vérification de l’âge dans le code à chaque lecture.
Deux décisions derrière une seule case
Rouvrir une session et faire confiance à un appareil sont deux sujets différents. Le même token sert à deux usages, rouvrir une session sans intervention de l’utilisateur et lui éviter un code à la prochaine connexion. Un champ loginOnly permet de retirer le premier en conservant le second.
// Silent restore: never for a suspended token
const canRestoreSession = (record) =>
Boolean(record) && !record.loginOnly && !isExpired(record);
// After a correct password: a suspended token still counts
const canSkipCode = (record, userId) =>
Boolean(record) && record.userId === userId && !isExpired(record);À la déconnexion, le token n’est pas supprimé mais suspendu. Il ne rouvre plus aucune session à lui seul ; en revanche, à la prochaine connexion depuis ce navigateur, le mot de passe suffit et aucun code n’est redemandé. L’utilisateur ne reçoit pas un e-mail à chaque retour sur un appareil déjà validé, au prix d’une protection réduite, sur ce navigateur, au seul mot de passe pendant la durée de vie du token. Sur un navigateur partagé, il vaut mieux décocher « rester connecté » au moment de saisir le code, ce qui supprime le token et le cookie. Une application plus sensible le supprimerait à chaque déconnexion.
En multi-tenant, le token d’un utilisateur des fronts mémorise son tenant, ici un canal du client (site, application, blog), et la session s’ouvre sans lui si le compte n’y a plus accès. Le filtrage par tenant est détaillé dans l’article sur la conception de la DXP.
Comment freiner le brute force sans bloquer les vrais utilisateurs ?
La première précaution consiste à ne rien révéler. Sur Goodbizz, la connexion renvoie le même message pour un compte inconnu et pour un mot de passe erroné. L’OWASP invite aussi à surveiller le temps de réponse : un compte inconnu qui échappe au calcul bcrypt répond plus vite qu’un compte existant, et cet écart peut suffire à vérifier une liste d’adresses.
Pour le verrouillage, l’OWASP recommande d’associer le compteur d’échecs au compte. Un attaquant qui répartit ses essais sur des centaines d’adresses IP échappe en effet à un compteur par IP. Trois réglages définissent le mécanisme : le seuil, la fenêtre d’observation et la durée du blocage, qui peut croître à chaque récidive. Une limite par IP garde sa place en complément, car elle freine une même machine qui essaie un mot de passe courant sur de nombreux comptes.
Le verrouillage permet aussi de bloquer le compte d’un autre en échouant volontairement. Le mécanisme peut ainsi servir à un déni de service, que l’OWASP propose d’atténuer en laissant fonctionner le parcours « mot de passe oublié » sur un compte verrouillé. Mieux vaut donc un blocage court et progressif.
Le code par e-mail est-il un vrai second facteur ?
La version 4 de la norme NIST SP 800-63B, qui s’impose aux systèmes fédéraux américains et souvent reprise comme référence ailleurs, est explicite : « Email SHALL NOT be used for out-of-band authentication ». L’OWASP mentionne l’e-mail pour mémoire et note que son statut de facteur est débattu, car tout repose sur la sécurité d’une boîte mail qui n’a souvent pas de MFA et dont le mot de passe est parfois le même que celui de l’application.
Le code par e-mail garde pourtant une utilité concrète. Il oblige un attaquant qui a obtenu un mot de passe à accéder aussi à la boîte de la victime, et il ne demande à l’utilisateur ni application ni appareil supplémentaire, ce qui compte sur des sites ouverts à un large public. Il faut simplement le présenter pour ce qu’il est, une vérification supplémentaire, et non une protection équivalente à un facteur matériel.
Pour des comptes à privilèges, l’étape suivante serait une application TOTP, ou mieux des passkeys, conçues pour résister au phishing. L’OWASP conseille aussi de redemander un facteur avant les actions sensibles, comme un changement de mot de passe ou d’adresse e-mail.