La version précédente de la réponse acceptée (md5(uniqid(mt_rand(), true))
) est peu sécurisée et n'offre que environ 2^60 sorties possibles - bien dans la plage d'une recherche par force brute en environ une semaine pour un attaquant à petit budget :
Étant donné qu'une clé DES de 56 bits peut être brute-forcée en environ 24 heures, et un cas moyen aurait environ 59 bits d'entropie, nous pouvons calculer 2^59 / 2^56 = environ 8 jours. Selon la façon dont cette vérification de jeton est implémentée, il pourrait être possible de divulguer des informations de timing et d'inférer les premiers N octets d'un jeton de réinitialisation valide.
Étant donné que la question concerne les "meilleures pratiques" et commence par...
Je veux générer un identifiant pour le mot de passe oublié
...nous pouvons en déduire que ce jeton a des exigences de sécurité implicites. Et lorsque vous ajoutez des exigences de sécurité à un générateur de nombres aléatoires, la meilleure pratique est d'utiliser toujours un générateur de nombres pseudo-aléatoires cryptographiquement sûr (abrégé CSPRNG).
Utilisation d'un CSPRNG
En PHP 7, vous pouvez utiliser bin2hex(random_bytes($n))
(où $n
est un entier supérieur à 15).
En PHP 5, vous pouvez utiliser random_compat
pour exposer la même API.
Alternativement, bin2hex(mcrypt_create_iv($n, MCRYPT_DEV_URANDOM))
si vous avez ext/mcrypt
installé. Une autre bonne ligne de commande est bin2hex(openssl_random_pseudo_bytes($n))
.
Séparation de la Recherche du Valideur
Puisant dans mon travail précédent sur les cookies "se souvenir de moi" sécurisés en PHP, la seule façon efficace de pallier la fuite de timing susmentionnée (généralement introduite par la requête à la base de données) est de séparer la recherche de la validation.
Si votre table ressemble à ceci (MySQL)...
CREATE TABLE account_recovery (
id INTEGER(11) UNSIGNED NOT NULL AUTO_INCREMENT
userid INTEGER(11) UNSIGNED NOT NULL,
token CHAR(64),
expires DATETIME,
PRIMARY KEY(id)
);
...vous devez ajouter une colonne supplémentaire, selector
, comme ceci :
CREATE TABLE account_recovery (
id INTEGER(11) UNSIGNED NOT NULL AUTO_INCREMENT
userid INTEGER(11) UNSIGNED NOT NULL,
selector CHAR(16),
token CHAR(64),
expires DATETIME,
PRIMARY KEY(id),
KEY(selector)
);
Utilisez un CSPRNG : lorsqu'un jeton de réinitialisation de mot de passe est émis, envoyez les deux valeurs à l'utilisateur, stockez le sélecteur et un hachage SHA-256 du jeton aléatoire dans la base de données. Utilisez le sélecteur pour récupérer le hachage et l'identifiant utilisateur, calculez le hachage SHA-256 du jeton fourni par l'utilisateur avec celui stocké dans la base de données en utilisant hash_equals()
.
Exemple de Code
Générant un jeton de réinitialisation en PHP 7 (ou 5.6 avec random_compat) avec PDO :
$selector = bin2hex(random_bytes(8));
$token = random_bytes(32);
$urlToEmail = 'http://exemple.com/reset.php?'.http_build_query([
'selector' => $selector,
'validator' => bin2hex($token)
]);
$expires = new DateTime('NOW');
$expires->add(new DateInterval('PT01H')); // 1 heure
$stmt = $pdo->prepare("INSERT INTO account_recovery (userid, selector, token, expires) VALUES (:userid, :selector, :token, :expires);");
$stmt->execute([
'userid' => $userId, // définir cela ailleurs !
'selector' => $selector,
'token' => hash('sha256', $token),
'expires' => $expires->format('Y-m-d\TH:i:s')
]);
Vérifiant le jeton de réinitialisation fourni par l'utilisateur :
$stmt = $pdo->prepare("SELECT * FROM account_recovery WHERE selector = ? AND expires >= NOW()");
$stmt->execute([$selector]);
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (!empty($results)) {
$calc = hash('sha256', hex2bin($validator));
if (hash_equals($calc, $results[0]['token'])) {
// Le jeton de réinitialisation est valide. Authentifier l'utilisateur.
}
// Supprimez le jeton de la base de données indépendamment du succès ou de l'échec.
}
Ces extraits de code ne sont pas des solutions complètes (j'ai évité la validation des entrées et les intégrations de framework), mais ils devraient servir d'exemple de ce qu'il faut faire.