Le reset de mot de passe

Sommaire
« J'ai oublié mon mot de passe »… C'est LA fonctionnalité présente sur tous les sites du monde. Dans ce TP, nous allons la coder nous-même dans notre projet TODO List : formulaire « mot de passe oublié », génération d'un token, envoi d'un email, et choix d'un nouveau mot de passe.
Encore à la main ?
Oui ! Comme pour le TP Comprendre l'authentification, l'objectif est de comprendre le mécanisme. Laravel sait générer tout ça automatiquement (nous le verrons avec Breeze), mais un reset de mot de passe mal compris est une faille de sécurité classique. Une fois codé à la main, vous saurez exactement ce que fait l'outil automatique.
Dans ce TP, je vous invite à avoir en parallèle :
Les slides
Avant de coder, prenons un moment pour comprendre le mécanisme et surtout pourquoi il est conçu ainsi :
Prérequis
Nous continuons sur le projet TODO List avec l'authentification codée à la main. Avant de commencer, vérifiez que vous avez :
- Le modèle
Utilisateur(name, email, password hashé), voir le TP Comprendre l'authentification. - L'inscription et la connexion fonctionnelles (
/login,/register).
Vous n'avez pas le projet ?
Reprenez le TP Comprendre l'authentification, ce TP s'appuie directement dessus (même modèle, même logique de session).
Objectifs
À la fin de ce TP vous saurez :
- Expliquer pourquoi un site ne peut jamais vous renvoyer votre mot de passe.
- Générer et stocker un token de réinitialisation avec une expiration.
- Envoyer un email depuis Laravel (et le lire en développement sans serveur mail).
- Mettre en place le parcours complet : demande, email, nouveau mot de passe.
- Identifier les pièges de sécurité : énumération de comptes, token éternel, token réutilisable.
Le mécanisme
Vous l'avez vu dans les slides, le parcours complet est le suivant :
Quatre routes, deux formulaires, un email : gardez ce schéma sous les yeux, chaque étape du TP correspond à une case.
Question :
- Lors du traitement de la demande de réinitialisation, pourquoi la réponse doit-elle être identique que l'email existe ou non en base ? (la réponse est dans les slides)
La migration
Notre table utilisateurs a besoin de deux nouvelles colonnes : le token, et sa date d'expiration. Créez une migration :
php artisan make:migration add_reset_token_to_utilisateursComplétez-la ainsi :
public function up(): void
{
Schema::table('utilisateurs', function (Blueprint $table) {
$table->string('reset_token', 64)->nullable();
$table->dateTime('reset_token_expires_at')->nullable();
});
}
public function down(): void
{
Schema::table('utilisateurs', function (Blueprint $table) {
$table->dropColumn(['reset_token', 'reset_token_expires_at']);
});
}Puis appliquez la migration :
php artisan migrateQuestions :
- Pourquoi les deux colonnes sont-elles
nullable()? Que contiennent-elles pour un utilisateur qui n'a jamais demandé de reset ? - Pourquoi ajouter une migration plutôt que modifier la migration existante de la table
utilisateurs?
Point de contrôle
Ouvrez votre base avec votre outil SQLite (vu dans le TP base de données) : la table utilisateurs doit avoir les deux nouvelles colonnes, à NULL pour tout le monde.
Envoyer des emails (sans serveur mail)
Pas de panique, nous n'allons pas installer de serveur mail. En développement, Laravel propose le « mailer » log : les emails ne partent pas sur internet, ils sont écrits dans le fichier de log du projet. Vérifiez dans votre .env :
MAIL_MAILER=logC'est la valeur par défaut d'un projet Laravel récent, vous n'avez probablement rien à changer.
Un petit utilitaire d'envoi
Pour envoyer des emails HTML à partir de templates Blade (oui, Blade sert aussi pour les emails !), nous allons créer un petit utilitaire. Créez le fichier app/Utils/EmailHelpers.php :
<?php
namespace App\Utils;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\View;
class EmailHelpers
{
/**
* Envoie un email HTML généré à partir d'un template Blade.
*
* @param string $to Adresse email du destinataire.
* @param string $subject Sujet de l'email.
* @param string $template Nom du template Blade (ex: 'emails.reset-mot-de-passe').
* @param array $data Variables à transmettre au template.
*/
public static function sendEmail($to, $subject, $template, $data = []): void
{
// On génère le HTML de l'email avec Blade, comme pour une page web
$body = View::make($template, $data)->render();
Mail::send([], [], function ($message) use ($to, $subject, $body) {
$message->to($to)
->subject($subject)
->html($body);
});
}
}Tester l'envoi
Pour tester, nous allons utiliser Tinker, la console interactive de Laravel : elle permet d'exécuter du code PHP directement dans le contexte de votre application (nous l'utiliserons plus largement dans le TP Aller plus loin) :
php artisan tinkerMail::raw('Coucou depuis Laravel', fn ($message) => $message->to('test@test.com')->subject('Mon premier email'));Ouvrez maintenant le fichier storage/logs/laravel.log (allez tout en bas) : votre email est là.
[2026-09-12 12:17:11] local.DEBUG: From: Laravel <hello@example.com>
To: test@test.com
Subject: Mon premier email
MIME-Version: 1.0
Date: Sat, 12 Sep 2026 12:17:11 +0000
Message-ID: <1ad58c6746b4ee9224b8422639bcef9e@example.com>
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: quoted-printable
Coucou depuis LaravelPoint de contrôle
Vous devez retrouver votre email de test (sujet et contenu) dans storage/logs/laravel.log. C'est comme ça que nous « recevrons » tous les emails de ce TP.
Pour aller plus loin : le serveur SMTP de test du BTS SIO
Le mailer log est pratique, mais dans les équipes de développement on utilise plutôt un serveur SMTP de test : un faux serveur mail qui piège tous les emails envoyés par l'application (rien ne part réellement sur internet) et permet de les consulter dans une interface type webmail.
Le BTS SIO de Chevrollier dispose du sien : un Mailtrap classique, déployé sur la ferme de serveurs du BTS et accessible à l'adresse mail.dombtsig.local depuis le réseau du lycée (et via le VPN). Pour l'utiliser, il suffit de pointer votre .env vers ce serveur :
MAIL_MAILER=smtp
MAIL_HOST=mail.dombtsig.local
MAIL_PORT=1025C'est totalement optionnel pour ce TP (le mailer log suffit), mais retenez le principe : c'est exactement la configuration du projet que vous retrouverez en AP. Pour travailler hors du lycée sans VPN, l'équivalent à installer sur votre machine est Mailpit (avec MAIL_HOST=localhost).
« Mot de passe oublié »
Passons au cœur du sujet. Créez un contrôleur dédié :
php artisan make:controller ResetMotDePasseControleurEt les routes correspondantes dans routes/web.php :
Route::get('/mot-de-passe-oublie', [ResetMotDePasseControleur::class, 'showMotDePasseOublie']);
Route::post('/mot-de-passe-oublie', [ResetMotDePasseControleur::class, 'envoyerMotDePasseOublie']);Le traitement de la demande
Pour cette étape, je vous donne le code du traitement (c'est le morceau le plus sensible, nous le décortiquons ensemble) :
public function envoyerMotDePasseOublie(Request $request)
{
// Valide le formulaire : si l'email est absent ou invalide, Laravel
// redirige automatiquement vers le formulaire avec les erreurs.
$request->validate(['email' => 'required|email']);
$utilisateur = Utilisateur::where('email', $request->email)->first();
if ($utilisateur) {
// Str::random(64) génère une chaîne aléatoire de 64 caractères
$token = Str::random(64);
$utilisateur->reset_token = $token;
$utilisateur->reset_token_expires_at = now()->addMinutes(30);
$utilisateur->save();
EmailHelpers::sendEmail(
$utilisateur->email,
'Réinitialisation de votre mot de passe',
'emails.reset-mot-de-passe',
[
'name' => $utilisateur->name,
// url() génère l'URL complète (avec le http://localhost:8000)
'lien' => url('/reset-mot-de-passe/' . $token),
]
);
}
// ⚠️ Ce message est envoyé DANS TOUS LES CAS, que l'email existe ou non.
return redirect()->back()->with('success', 'Si cette adresse est associée à un compte, un email vous a été envoyé.');
}Deux nouveautés dans ce code
$request->validate([...]): jusqu'ici nous vérifiions les champs à la main. Laravel propose un système de validation intégré, avec des règles commerequired,email,min:8… En cas d'échec, l'utilisateur est automatiquement redirigé vers le formulaire. La documentation est ici.now(): retourne la date et l'heure actuelles, et->addMinutes(30)fait ce que son nom indique. Pratique pour notre expiration.
N'oubliez pas les use en haut du fichier : App\Models\Utilisateur, App\Utils\EmailHelpers et Illuminate\Support\Str.
Question :
- Le
if ($utilisateur)n'a pas deelse. Que se passe-t-il si l'email n'existe pas en base ? Pourquoi est-ce exactement le comportement voulu ?
Le template de l'email
Créez la vue resources/views/emails/reset-mot-de-passe.blade.php :
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
</head>
<body>
<p>Bonjour {{ $name }},</p>
<p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le lien ci-dessous pour en choisir un nouveau :</p>
<p><a href="{{ $lien }}">{{ $lien }}</a></p>
<p>Ce lien est valable 30 minutes.</p>
<p>Si vous n'êtes pas à l'origine de cette demande, ignorez cet email. Votre mot de passe ne sera pas modifié.</p>
</body>
</html>À vous : le formulaire
Il reste la méthode showMotDePasseOublie et sa vue. Vous avez déjà fait des dizaines de formulaires, je vous laisse faire :
- Un champ
emailet un bouton « Envoyer le lien ». - L'affichage du message flash
success(revoyez le TP d'introduction si besoin). - N'oubliez pas
@extends('layouts.base')et@csrf. - Ajoutez un lien « Mot de passe oublié ? » sur votre page de connexion.


Point de contrôle
Saisissez l'email d'un utilisateur existant dans votre formulaire :
- Le message « Si cette adresse est associée à un compte… » s'affiche.
- Dans
storage/logs/laravel.log, vous trouvez l'email avec un lien du typehttp://localhost:8000/reset-mot-de-passe/gT7fKq2Zp9…. - Dans votre outil SQLite, l'utilisateur a maintenant un
reset_tokenet une date d'expiration.
Testez aussi avec un email inexistant : même message, pas d'email dans le log, rien en base.

L'email dans le log ressemble à ceci (le lien est généré par url(), le HTML vient de votre template Blade) :
[2026-09-12 12:07:04] local.DEBUG: From: Laravel <hello@example.com>
To: jane@doe.com
Subject: =?utf-8?Q?R=C3=A9initialisation?= de votre mot de passe
Content-Type: text/html; charset=utf-8
<!DOCTYPE html>
<html lang="fr">
<body>
<p>Bonjour Jane Doe,</p>
<p>Vous avez demandé la réinitialisation de votre mot de passe. […]</p>
<p><a href="http://localhost:8000/reset-mot-de-passe/NOxrc3W6x5m9FuZUR1oM3S4XNtmDWuuK3SkQn8dvKQmCX7lmo1htX9Ey97xG9Adr">http://localhost:8000/reset-mot-de-passe/NOxrc3W6…</a></p>
<p>Ce lien est valable 30 minutes.</p>
[…]Et si l'email est vide ou mal formé, c'est $request->validate() qui renvoie l'utilisateur sur le formulaire avec l'erreur (affichez-la avec @error('email'), le message est en anglais par défaut) :

Le lien reçu par email
L'utilisateur clique sur le lien de l'email. Il faut maintenant afficher le formulaire de nouveau mot de passe, uniquement si le token est valide.
Ajoutez les deux dernières routes :
Route::get('/reset-mot-de-passe/{token}', [ResetMotDePasseControleur::class, 'showResetMotDePasse']);
Route::post('/reset-mot-de-passe/{token}', [ResetMotDePasseControleur::class, 'resetMotDePasse']);Cette fois, je ne vous donne que la requête clé, celle qui vérifie le token et son expiration en une seule fois :
$utilisateur = Utilisateur::where('reset_token', $token)
->where('reset_token_expires_at', '>', now())
->first();Je vous laisse écrire la méthode showResetMotDePasse(string $token) :
- Si aucun utilisateur ne correspond (token inconnu ou expiré) : redirection vers
/mot-de-passe-oublieavec un message d'erreur « Ce lien est invalide ou a expiré ». - Sinon : afficher une vue avec un formulaire « nouveau mot de passe » + « confirmation », qui poste vers
/reset-mot-de-passe/{{ $token }}.
Besoin d'un indice pour la vue ?
Le formulaire a besoin de connaître le token pour construire son action. Passez-le à la vue (return view('reset-mot-de-passe', ['token' => $token]);) et utilisez-le dans l'attribut action du formulaire.
Point de contrôle
Avec le lien du log, le formulaire s'affiche. Avec un token inventé, retour sur « mot de passe oublié » avec l'erreur.


Question :
- Un token expiré est toujours présent en base, pourtant la page affiche « lien invalide ». Quelle partie de la requête Eloquent s'en charge ?
Enregistrer le nouveau mot de passe
Dernière ligne droite, et cette fois c'est vous qui écrivez tout. La méthode resetMotDePasse(Request $request, string $token) doit :
- Vérifier le token avec la même requête que dans « Le lien reçu par email » (un token peut expirer entre l'affichage du formulaire et sa soumission !).
- Valider le formulaire : mot de passe obligatoire, 8 caractères minimum, et identique à la confirmation.
- Enregistrer le nouveau mot de passe hashé (souvenez-vous :
password_hash, comme à l'inscription). - Invalider le token : remettre
reset_tokenetreset_token_expires_atànull. - Rediriger vers
/loginavec un message flash « Mot de passe mis à jour, vous pouvez vous connecter ».
Les pièges à éviter
- Stocker le mot de passe en clair : c'est le point de vérification numéro 1 (regardez en base après votre test).
- Oublier d'invalider le token : le token resterait valable 30 minutes après le changement de mot de passe. Quelqu'un qui met la main sur l'email pourrait re-changer le mot de passe.
- Oublier de re-vérifier le token dans le POST : la vérification du GET ne protège pas le POST.
Un indice pour la validation ?
La règle confirmed de Laravel vérifie automatiquement que le champ password est identique au champ password_confirmation :
$request->validate([
'password' => 'required|string|min:8|confirmed',
]);Votre formulaire doit donc contenir un champ nommé password et un champ nommé password_confirmation.
En cas d'échec (mot de passe trop court, confirmation différente), Laravel renvoie sur le formulaire avec l'erreur :

Point de contrôle final
Déroulez le parcours complet :
- Demandez un reset pour votre compte, récupérez le lien dans
storage/logs/laravel.log. - Choisissez un nouveau mot de passe via le lien.
- Vérifiez en base : le hash a changé,
reset_tokenest repassé àNULL. - Connectez-vous avec le nouveau mot de passe : ça fonctionne.
- Connectez-vous avec l'ancien : refusé.
- Réutilisez le même lien de reset : « Ce lien est invalide ou a expiré ».
Testez comme un attaquant
Un système d'authentification se teste aussi « en mode attaquant ». Vérifiez par vous-même :
- Que se passe-t-il avec un token inventé (
/reset-mot-de-passe/nimportequoi) ? - Modifiez à la main
reset_token_expires_aten base pour mettre une date passée : le lien doit être refusé. - Le message de « Mot de passe oublié » est-il vraiment identique pour un email existant et inexistant ? (comparez aussi le temps de réponse, pour la culture)
Questions :
- Pourquoi un token de 64 caractères aléatoires ne peut-il pas être « deviné » par force brute, alors qu'un code à 6 chiffres pourrait l'être ?
- Notre formulaire de demande de reset peut être utilisé pour spammer quelqu'un (une demande = un email). Quelle protection pourrait limiter ça ? (indice : limiter le nombre de requêtes, nous verrons comment dans le TP Aller plus loin)
Vous avez suivi les recommandations OWASP sans le savoir
L'OWASP (croisée dans le TP Comprendre l'authentification) publie une Forgot Password Cheat Sheet : les recommandations officielles pour un « mot de passe oublié » sécurisé. Parcourez-la, vous allez reconnaître point par point ce que vous venez de coder : réponse identique quel que soit l'email (anti-énumération), token aléatoire impossible à deviner, expiration courte, usage unique, et même le rate limiting du bonus ci-dessous.
Bonus
Si vous êtes en avance :
- Après un reset réussi, envoyez un email de confirmation « Votre mot de passe a été modifié » (réutilisez votre
EmailHelpers). Pourquoi cet email est-il une bonne pratique de sécurité ? - Pour les plus curieux : limitez le nombre de requêtes sur les deux routes POST du TP avec le middleware
throttle:5,1(5 requêtes par minute). Nous détaillerons le rate limiting dans le TP Aller plus loin.
Exercice 1 : mot de passe oublié dans la bibliothèque
Vous êtes en avance ?
Les deux exercices qui suivent reprennent le projet ma-bibliotheque utilisé dans l'exercice du TP Comprendre l'authentification. Si vous ne l'avez pas, commencez par là. Le TP suivant ne dépend pas de ces exercices, vous pouvez donc y aller directement si le temps vous manque.
Les membres de la bibliothèque oublient eux aussi leur mot de passe. Je vous laisse reproduire le parcours complet de ce TP dans ma-bibliotheque (migration, EmailHelpers, les quatre routes, les vues, l'email), sur la table membres cette fois, avec deux différences :
- Le lien est valable 15 minutes, pas 30 (pensez aussi au texte de l'email).
- Le token est stocké hashé en base : le lien envoyé par email contient le token en clair, mais la colonne
reset_tokenne contient quehash('sha256', $token).
C'est la seconde règle qui va vous faire réfléchir : relisez chaque endroit où votre code manipule le token, et demandez-vous s'il doit travailler avec la version en clair ou la version hashée.
La demande de réinitialisation
- Un lien « Mot de passe oublié ? » sur la page de connexion.
- Une page
/mot-de-passe-oubliequi génère le token, le stocke hashé avec son expiration, envoie l'email, et affiche toujours le même message.
La cible, après l'envoi du formulaire (que l'email existe ou non) :

Le nouveau mot de passe
- Le lien reçu par email affiche le formulaire de nouveau mot de passe, uniquement si le token est valide et non expiré.
- Le formulaire enregistre le nouveau mot de passe hashé, invalide le token et renvoie vers la connexion.
La cible, en ouvrant le lien de l'email :

Point de contrôle
Demandez un reset pour un membre, puis comparez le token du lien (dans storage/logs/laravel.log) avec la colonne reset_token en base : les deux valeurs sont différentes. Le lien fonctionne quand même, et le parcours complet se déroule comme dans le TP (nouveau mot de passe, token remis à NULL, lien inutilisable une seconde fois).
Questions :
- Pourquoi stocker le token hashé ? Imaginez que votre base de données fuite pendant qu'un membre attend son email de reset.
- Pour les mots de passe, nous utilisons
password_hash. Pourquoi ne peut-on pas l'utiliser ici pour le token, alors quehash('sha256', ...)fonctionne ? (indice : essayez d'écrire la requêtewherequi retrouve le membre)
Les réponses
- En clair, un token en base est un mot de passe temporaire : quelqu'un qui lit la base peut ouvrir le lien et changer le mot de passe du membre avant lui. Hashé, le token de la base ne sert à rien sans sa version en clair, qui n'existe que dans l'email. C'est d'ailleurs une recommandation de la Forgot Password Cheat Sheet de l'OWASP.
password_hashajoute un sel aléatoire : le même token donne un hash différent à chaque appel, impossible donc de le retrouver avec unwhere('reset_token', ...).hash('sha256', ...)donne toujours le même résultat pour la même entrée, la recherche fonctionne. Et ce n'est pas un problème de sécurité ici : un token de 64 caractères aléatoires est impossible à deviner, alors qu'un mot de passe choisi par un humain a besoin d'un hash lent et salé pour résister aux attaques par dictionnaire.
Exercice 2 : changer son mot de passe
Le reset sert quand on a oublié son mot de passe. Mais un membre connecté doit aussi pouvoir le changer quand il le souhaite. Je vous laisse ajouter un formulaire « Changer mon mot de passe » sur la page /mon-compte (créée dans l'exercice du TP 2FA, sinon créez-la, réservée aux membres connectés) :
- Trois champs : le mot de passe actuel, le nouveau, et sa confirmation.
- Si le mot de passe actuel est faux, l'erreur s'affiche sous ce champ et rien n'est modifié.
- Le nouveau mot de passe respecte les mêmes règles que dans ce TP (8 caractères minimum, identique à la confirmation).
- Le nouveau mot de passe est enregistré hashé, et un éventuel token de reset en cours est invalidé.
- Un email « Votre mot de passe a été modifié » est envoyé au membre.
C'est à vous de jouer !
La cible, en saisissant un mauvais mot de passe actuel :

Point de contrôle
Changez votre mot de passe depuis /mon-compte, déconnectez-vous, puis reconnectez-vous : seul le nouveau mot de passe fonctionne. L'email de confirmation est dans storage/logs/laravel.log. Recommencez avec un mauvais mot de passe actuel : l'erreur s'affiche et le hash en base ne change pas.
Question :
- Le membre est déjà connecté : pourquoi lui redemander son mot de passe actuel avant d'en changer ?
La réponse
Parce qu'être connecté prouve seulement que quelqu'un a la session, pas que c'est le propriétaire du compte. Sans cette vérification, une personne qui trouve une session ouverte (ou qui a volé le cookie de session) pourrait changer le mot de passe et s'approprier définitivement le compte. Vous retrouverez ce réflexe sur tous les sites sérieux, pour toutes les actions sensibles : changer d'email, désactiver la 2FA, supprimer son compte…
Conclusion
Vous venez de coder la fonctionnalité « mot de passe oublié » comme le font les vrais sites :
- Un site ne renvoie jamais un mot de passe, il permet d'en choisir un nouveau.
- Le token est une preuve temporaire : aléatoire, avec expiration, à usage unique.
- La réponse est identique que l'email existe ou non (anti-énumération).
- Blade et le mailer
logpermettent de développer tout ça sans serveur mail.
Et comme pour l'authentification : oui, nous avons tout codé à la main, mais Laravel sait générer ce mécanisme automatiquement avec Breeze. La force de Laravel, c'est justement de ne pas tout recoder… maintenant que vous savez ce qu'il y a dedans, vous pourrez l'utiliser en confiance.
N'oubliez pas de commiter votre projet.
La suite du fil rouge : Aller plus loin avec Laravel, pour finaliser le projet TODO (relations entre tables, données de test, rate limiting, Tinker…).
Et gardez un rendez-vous en tête : dans le TP Tester son application avec PHPUnit, nous testerons automatiquement cette fonctionnalité de sécurité.