How-to : Hachage de mot de passe
Stocker et vérifier les mots de passe de manière sécurisée avec les fonctions natives PHP password_hash() / password_verify() avec NENE2.
Démarrage rapide
php
// Inscription — hacher avant de stocker
$hash = password_hash($password, PASSWORD_ARGON2ID);
$user = $this->repo->create($email, $hash);
// Connexion — vérification à temps constant
if (!password_verify($inputPassword, $user->passwordHash)) {
// retourner 401
}Algorithme : toujours utiliser PASSWORD_ARGON2ID
PASSWORD_DEFAULT est toujours bcrypt à partir de PHP 8.4. Argon2id est résistant à la mémoire et résiste aux attaques GPU/ASIC.
php
// ❌ PASSWORD_DEFAULT = bcrypt — plus vulnérable au brute force GPU
$hash = password_hash($password, PASSWORD_DEFAULT);
// ✅ Argon2id — résistant à la mémoire, recommandé pour les nouveaux projets
$hash = password_hash($password, PASSWORD_ARGON2ID);Argon2id nécessite PHP 7.3+. NENE2 nécessite PHP 8.4, donc il est toujours disponible.
Détection des violations UNIQUE : DatabaseConstraintException
PdoDatabaseQueryExecutor de NENE2 encapsule toutes les violations de contrainte (UNIQUE, FK, NOT NULL) dans DatabaseConstraintException avant de les relancer. Intercepter \PDOException directement ne fonctionne pas.
php
use Nene2\Database\DatabaseConstraintException;
// ❌ N'atteint jamais ici — PDOException est déjà encapsulée
catch (\PDOException $e) {
if (str_contains($e->getMessage(), 'UNIQUE constraint failed')) { ... }
}
// ✅ Intercepter l'encapsuleur NENE2
catch (DatabaseConstraintException) {
throw new DuplicateEmailException($email);
}DatabaseConstraintException fait partie de l'API publique stable (ADR 0009).
Pattern de repository complet :
php
use Nene2\Database\DatabaseConstraintException;
use Nene2\Database\DatabaseQueryExecutorInterface;
final class UserRepository
{
public function __construct(private readonly DatabaseQueryExecutorInterface $executor) {}
/** @throws DuplicateEmailException */
public function create(string $email, string $passwordHash): User
{
try {
$now = (new \DateTimeImmutable())->format('Y-m-d\TH:i:s\Z');
$id = $this->executor->insert(
'INSERT INTO users (email, password_hash, created_at) VALUES (?, ?, ?)',
[$email, $passwordHash, $now],
);
return new User(id: $id, email: $email, passwordHash: $passwordHash, createdAt: $now);
} catch (DatabaseConstraintException) {
throw new DuplicateEmailException($email);
}
}
}Prévention de l'énumération d'utilisateurs (attaque de timing)
Si vous retournez 401 immédiatement quand l'email n'est pas trouvé, une différence de timing révèle si l'email existe — les réponses "non trouvé" reviennent instantanément, tandis que les réponses "mauvais mot de passe" prennent tout le temps de calcul Argon2id.
php
// ❌ Fuite de timing — "non trouvé" est mesurably plus rapide
if ($user === null) {
return $this->problems->create($request, 'invalid-credentials', ...);
}
if (!password_verify($password, $user->passwordHash)) {
return $this->problems->create($request, 'invalid-credentials', ...);
}
// ✅ Toujours exécuter password_verify — temps constant que l'utilisateur existe ou non
$dummyHash = '$argon2id$v=19$m=65536,t=4,p=1$dummysaltdummysaltdummysalt$dummyhashvaluedummyhashvaluedummyh';
$hashToCheck = $user !== null ? $user->passwordHash : $dummyHash;
if (!password_verify($password, $hashToCheck) || $user === null) {
return $this->problems->create($request, 'invalid-credentials', 'Invalid Credentials', 401,
'The email or password is incorrect.');
}Le hash factice doit être une chaîne au format Argon2id valide commençant par $argon2id$. Si ce n'est pas le cas, password_verify() court-circuite et retourne false immédiatement, recréant la fuite de timing.
password_verify() est agnostique à l'algorithme
password_verify() lit le préfixe du hash pour déterminer l'algorithme. Vous n'avez pas besoin de modifier le code de vérification lors de la migration de bcrypt vers Argon2id.
php
// Fonctionne sur les hashes bcrypt et Argon2id
$result = password_verify($plaintext, $storedHash); // toujours correctUtiliser password_needs_rehash() à la connexion réussie pour mettre à niveau les hashes hérités de manière transparente :
php
if (password_verify($password, $user->passwordHash)) {
if (password_needs_rehash($user->passwordHash, PASSWORD_ARGON2ID)) {
$newHash = password_hash($password, PASSWORD_ARGON2ID);
$this->repo->updatePasswordHash($user->id, $newHash);
}
// continuer avec l'utilisateur authentifié
}Ne jamais inclure password_hash dans la réponse
toArray() ou des helpers similaires peuvent inclure chaque colonne. Lister explicitement uniquement les champs que vous avez l'intention de retourner.
php
// ❌ Peut exposer password_hash si $user a une méthode toArray()
return $this->json->create($user->toArray(), 201);
// ✅ Liste de champs explicite — password_hash n'est jamais présent
return $this->json->create([
'id' => $user->id,
'email' => $user->email,
'created_at' => $user->createdAt,
], 201);Conflit de nom RouteRegistrar::register()
Le contrat RouteRegistrar de NENE2 requiert une méthode publique register(Router $router). Ne pas nommer un gestionnaire de route register() — PHP rejettera le nom de méthode dupliqué.
php
// ❌ Erreur fatale : Impossible de redéclarer RouteRegistrar::register()
$router->post('/register', $this->register(...));
private function register(...) { ... }
// ✅ Utiliser un nom de gestionnaire distinct
$router->post('/register', $this->handleRegister(...));
private function handleRegister(...) { ... }Liste de vérification pour la revue de code
- [ ]
password_hash()avecPASSWORD_ARGON2IDest utilisé (pas MD5, SHA-1, bcrypt ouPASSWORD_DEFAULT) - [ ]
password_verify()est utilisé pour la comparaison (pas===,hash_equals()ou comparaison personnalisée) - [ ]
password_verify()s'exécute même quand l'utilisateur n'est pas trouvé (pattern hash factice) - [ ]
DatabaseConstraintExceptionest interceptée pour la détection d'email/nom d'utilisateur dupliqué - [ ] Les champs
password_hash/passwordsont exclus de toutes les réponses API - [ ] La connexion retourne 401 (pas 404) pour un email inconnu — ne jamais révéler si l'email existe
- [ ] Le mot de passe en clair n'est pas écrit dans les logs