Skip to content

How-to : Flux de réinitialisation de mot de passe

Référence FT : FT285 (NENE2-FT/resetlog) — Flux de réinitialisation de mot de passe : prévention de l'énumération d'utilisateurs (toujours 202), stockage du hash SHA-256 du token, TTL d'1 heure, token à usage unique (409 à la réutilisation), 410 Gone à l'expiration, nouveau hash Argon2id, 15 tests / 23 assertions PASS.

Évaluation VULN : V-01 à V-10 inclus à la fin de ce document.

Ce guide montre comment implémenter un flux de réinitialisation de mot de passe sécurisé — les utilisateurs demandent une réinitialisation, reçoivent un token (généralement par email), et l'utilisent pour définir un nouveau mot de passe.

Schéma

sql
CREATE TABLE users (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    email         TEXT    NOT NULL UNIQUE,
    name          TEXT    NOT NULL,
    password_hash TEXT    NOT NULL,
    created_at    TEXT    NOT NULL
);

CREATE TABLE password_resets (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id    INTEGER NOT NULL,
    token_hash TEXT    NOT NULL UNIQUE,
    used_at    TEXT,
    expires_at TEXT    NOT NULL,
    created_at TEXT    NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

token_hash TEXT UNIQUE — stocke le SHA-256 du token brut. Le token brut est envoyé au client et jamais stocké.

Endpoints

MéthodeCheminAuthDescription
POST/password-resetAucuneDemander une réinitialisation de mot de passe
GET/password-reset/{token}AucuneVérifier le statut du token
POST/password-reset/{token}AucuneCompléter la réinitialisation avec le nouveau mot de passe

Prévention de l'énumération d'utilisateurs

php
$user = $this->repo->findUserByEmail($email);

// Toujours retourner 202 pour prévenir l'énumération d'utilisateurs
if ($user === null) {
    return $this->json->create(['status' => 'pending'], 202);
}

// Utilisateur réel : créer le token et (en production) envoyer l'email
$rawToken = bin2hex(random_bytes(32));
// ...
return $this->json->create(['status' => 'pending', 'token' => $rawToken], 202);

Les emails valides et invalides retournent des réponses 202 identiques. Un attaquant ne peut pas déterminer quels emails sont enregistrés.

Note de production : Le token est retourné dans la réponse API ici pour la testabilité. En production, envoyer le token uniquement par email — ne jamais l'inclure dans la réponse API.

Stockage du token — SHA-256 uniquement

php
$rawToken  = bin2hex(random_bytes(32));  // 64 caractères hex = 256 bits d'entropie
$tokenHash = hash('sha256', $rawToken);

$this->repo->createReset($user->id, $tokenHash, $expiresAt, $now);

// Retourner le token brut au client (en production : par email, pas dans la réponse HTTP)
return $this->json->create(['status' => 'pending', 'token' => $rawToken], 202);

La base de données stocke uniquement le hash SHA-256. Le token brut est envoyé à l'utilisateur (par email en production) et jamais stocké. Une fuite DB révèle des hashes — inutilisables sans les tokens bruts.

Validation du token

php
$rawToken  = (string) ($params['token'] ?? '');
$tokenHash = hash('sha256', $rawToken);
$reset     = $this->repo->findByTokenHashOrNull($tokenHash);

Le token brut arrive dans le chemin de la requête. Le serveur le hache et interroge la DB. SHA-256 est déterministe — le même token brut produit toujours le même hash.

États du cycle de vie du token

pending → used (409 à la réutilisation)
pending → expired (410 Gone)
php
if ($reset->isExpired($now)) {
    return $this->problems->create($request, 'gone', 'Reset token has expired.', 410, '');
}

if ($reset->isUsed()) {
    return $this->problems->create($request, 'conflict', 'Reset token has already been used.', 409, '');
}
StatutHTTPQuand
Non trouvé404Le token n'existe pas en DB
Expiré410 Goneexpires_at est dans le passé
Déjà utilisé409 Conflictused_at est défini
Valide200 (GET) / 200 (POST)Actif, non utilisé, non expiré

410 Gone est sémantiquement plus correct que 404 pour les ressources expirées — le token existait mais n'est plus disponible.

Compléter la réinitialisation

php
$newHash = password_hash($newPassword, PASSWORD_ARGON2ID);
$this->repo->updatePasswordHash($reset->userId, $newHash);
$this->repo->markUsed($tokenHash, $now);  // définit used_at = $now

return $this->json->create(['status' => 'completed'], 200);

Les deux opérations devraient être dans une transaction en production. Si updatePasswordHash réussit mais que markUsed échoue, l'utilisateur est réinitialisé mais le token reste réutilisable.

Validation du mot de passe

php
if (strlen($newPassword) < 8) {
    return $this->problems->create($request, 'validation-failed', 'Validation Failed', 422, null, [
        'errors' => [['field' => 'password', 'code' => 'min-length', 'message' => 'password must be at least 8 characters.']],
    ]);
}

Minimum 8 caractères ; appliqué à la fois lors de l'inscription et de la réinitialisation. Le nouveau mot de passe est haché avec PASSWORD_ARGON2ID avant le stockage.


Évaluation VULN — Diagnostic de vulnérabilité

V-01 — Énumération d'utilisateurs via le timing/contenu de la réponse de réinitialisation 🛡️ SAFE

Menace : L'attaquant envoie des demandes de réinitialisation pour de nombreux emails pour identifier les utilisateurs enregistrés. Défense : Les emails enregistrés et non enregistrés retournent 202 { "status": "pending" } avec un corps de réponse et un code de statut identiques. Pas de différence de timing (pas de vérification de hash de mot de passe nécessaire pour la demande de réinitialisation). Résultat : SAFE — l'énumération est impossible depuis la réponse API.


V-02 — Brute force de token 🛡️ SAFE

Menace : L'attaquant devine des valeurs de token et les soumet pour réinitialiser n'importe quel compte. Défense : bin2hex(random_bytes(32)) génère 256 bits d'entropie (64 caractères hex). À 10 000 suppositions/seconde, le brute force prendrait ~10^65 ans. La comparaison de hash SHA-256 prévient l'extension de longueur et l'oracle de timing. Résultat : SAFE — l'entropie de 256 bits est impossible à deviner.


V-03 — Replay de token après utilisation 🛡️ SAFE

Menace : L'attaquant intercepte un token de réinitialisation et l'utilise après que l'utilisateur légitime a déjà réinitialisé son mot de passe. Défense : markUsed() définit used_at après la réinitialisation. Les tentatives suivantes vérifient isUsed() → 409 Conflict. Résultat : SAFE — l'application à usage unique prévient le replay.


V-04 — Token expiré accepté 🛡️ SAFE

Menace : L'attaquant sauvegarde un token, attend que l'utilisateur se connecte, puis utilise l'ancien token. Défense : isExpired($now) vérifie expires_at. Les tokens expirent après 1 heure → 410 Gone. Résultat : SAFE — les tokens à durée limitée préviennent les attaques différées.


V-05 — Injection SQL via le paramètre de chemin du token 🛡️ SAFE

Menace : Soumettre '; DROP TABLE password_resets; -- comme token. Défense : hash('sha256', $rawToken) produit une chaîne hex de 64 caractères quelle que soit l'entrée. Le hash est utilisé dans une requête paramétrée (WHERE token_hash = ?). L'injection SQL via le paramètre de chemin est impossible. Résultat : SAFE — le hachage + la requête paramétrée bloquent doublement l'injection.


V-06 — Token stocké en clair en DB 🛡️ SAFE

Menace : Une fuite DB expose tous les tokens de réinitialisation actifs ; l'attaquant réinitialise chaque compte. Défense : La DB stocke uniquement hash('sha256', $rawToken). Les tokens bruts sont retournés aux clients (ou envoyés par email). SHA-256 est à sens unique ; les hashes ne peuvent pas être inversés en tokens bruts sans brute force. Résultat : SAFE — le stockage de hash SHA-256 protège les tokens au repos.


V-07 — Nouveau mot de passe stocké en clair 🛡️ SAFE

Menace : Une fuite DB expose les nouveaux mots de passe définis lors de la réinitialisation. Défense : password_hash($newPassword, PASSWORD_ARGON2ID) hache le nouveau mot de passe avant le stockage. Le texte clair n'est jamais persisté. Résultat : SAFE — le hachage Argon2id protège les mots de passe au repos.


V-08 — Prise de contrôle de compte par création de token de réinitialisation dupliqué 🛡️ SAFE

Menace : L'attaquant prédit ou entre en collision avec le hash de token d'un autre utilisateur. Défense : token_hash TEXT UNIQUE — les hashes dupliqués sont rejetés par la DB. Avec 256 bits d'entropie, la probabilité de collision est négligeable (borne de l'anniversaire ~2^128 tentatives pour 50% de probabilité de collision). Résultat : SAFE — contrainte UNIQUE + entropie de 256 bits préviennent la collision.


V-09 — Soumission de nouveau mot de passe faible (< 8 caractères) lors de la réinitialisation 🛡️ SAFE

Menace : L'attaquant réinitialise un compte à un mot de passe facilement devinable comme aa. Défense : strlen($newPassword) < 8 → erreur de validation 422 avant toute opération DB. Résultat : SAFE — longueur minimale appliquée sur le chemin de réinitialisation (même qu'à l'inscription).


V-10 — L'endpoint de token révèle quelle étape a échoué (énumération) 🛡️ SAFE

Menace : En comparant les réponses 404 vs 409 vs 410, l'attaquant cartographie l'état des tokens de réinitialisation. Défense : Les codes d'erreur révèlent l'état du cycle de vie du token (non trouvé/expiré/utilisé) mais pas les informations utilisateur. Savoir qu'un token est expiré ou utilisé n'identifie pas le titulaire du compte. La demande de réinitialisation retourne toujours 202 que l'email existe ou non. Résultat : SAFE — aucune information d'identité utilisateur n'est révélée par les réponses d'état du token.


Résumé VULN

IDMenaceRésultat
V-01Énumération d'utilisateurs via la réponse de réinitialisation🛡️ SAFE
V-02Brute force de token🛡️ SAFE
V-03Replay de token après utilisation🛡️ SAFE
V-04Token expiré accepté🛡️ SAFE
V-05Injection SQL via chemin de token🛡️ SAFE
V-06Token stocké en clair🛡️ SAFE
V-07Nouveau mot de passe stocké en clair🛡️ SAFE
V-08Collision de token dupliqué🛡️ SAFE
V-09Nouveau mot de passe faible accepté🛡️ SAFE
V-10État du token révèle les infos utilisateur🛡️ SAFE

10 SAFE, 0 EXPOSED La prévention de l'énumération d'utilisateurs, l'entropie de token de 256 bits, le stockage de hash SHA-256, le hachage Argon2id des mots de passe et l'application à usage unique préviennent tous les vecteurs de vulnérabilité testés.


À ne pas faire

Anti-patternRisque
Retourner 404 pour email non enregistré, 202 pour enregistréÉnumération d'utilisateurs — l'attaquant cartographie les comptes enregistrés
Stocker le token brut en DBUne fuite DB expose tous les tokens de réinitialisation actifs ; prise de contrôle de compte massive
Envoyer le token dans le corps de la réponse HTTP (production)Token intercepté par les logs du navigateur, proxies ou JS ; envoyer uniquement par email
Pas d'expiration sur les tokens de réinitialisationLes anciens tokens restent valides indéfiniment ; les tokens volés utilisables des mois plus tard
Permettre la réutilisation du token après la réinitialisation du mot de passeAttaque par replay de token après interception d'email
Pas de longueur minimale de mot de passeLes utilisateurs définissent aa comme nouveau mot de passe
Retourner 200 pour GET /password-reset/{token} sur token utiliséLe client ne peut pas distinguer valide de déjà utilisé
Utiliser MD5/SHA-1 pour le hash de tokenDes tables arc-en-ciel précalculées existent ; utiliser SHA-256 ou mieux
Pas de transaction pour updatePasswordHash + markUsedCondition de course : mot de passe mis à jour mais token reste réutilisable

Publié sous licence MIT.