Sécuriser la boîte à idées : home et connexion en POO
Sommaire
Au TP précédent, vous avez transformé la boîte à idées en projet orienté objet : une classe Database pour la connexion, un modèle Idea qui porte à la fois les données d'une idée et tout le SQL, et un autoloader qui charge tout ça sans un seul require.
Aujourd'hui, nous ajoutons la partie qui manque à toute application sérieuse : qui a le droit de faire quoi. N'importe qui peut proposer une idée, mais seul l'administrateur pourra voir l'espace d'administration et supprimer une idée.
Vous connaissez déjà la logique : c'est exactement celle du TP 5 sur la protection des pages (une home publique, une page de connexion, une whitelist qui change selon l'état de connexion). La différence, c'est qu'aujourd'hui tout ce mécanisme va vivre dans une classe : Auth.
Dans ce TP, je vous invite à avoir en parallèle :
- Le complément de cours POO
- Le TP sur l'authentification (le hachage des mots de passe, c'est là)
- Le complément sécurité
- L'aide mémoire PHP
Les slides
Avant de commencer, un tour rapide des compétences du jour : le flux de connexion, la classe Auth et la whitelist conditionnelle.
Prérequis
Pour suivre ce TP, il vous faut :
- XAMPP ou WAMP démarré (Apache + MySQL/MariaDB) et phpMyAdmin accessible.
- Le projet
boite-a-ideesdu TP 1, en état de marche.
Rattrapage : votre projet du TP 1 est incomplet ?
Pas de panique, mais il n'y a pas d'archive toute faite : la suite du TP s'appuie sur votre code. Reprenez le TP 1 et vérifiez que vous avez bien tout ça avant d'aller plus loin :
boite-a-idees/
├── index.php (session_start, spl_autoload_register, whitelist, entry-point)
├── classes/
│ ├── Database.php (méthode statique getPdo())
│ └── Idea.php (le modèle : les données, puis all(), find(), count(), save())
├── common/
│ ├── header.php
│ └── footer.php
├── pages/
│ ├── home.php
│ ├── proposer.php
│ └── idee.php
└── public/
└── main.cssPlus la base boite_idees avec la table idees (id, titre, auteur, contenu, date_creation).
Si Idea::delete() (le bonus du TP 1) vous manque, ce n'est pas grave : nous l'écrirons ensemble à l'étape 5.
Objectifs
À la fin de ce TP vous saurez :
- Stocker des utilisateurs en base avec un mot de passe haché, jamais en clair.
- Écrire un modèle
Useren autonomie, sur le modèle deIdea. - Regrouper toute la logique de connexion dans une classe de service :
Auth. - Utiliser des méthodes statiques là où elles ont du sens (
Auth::check(),Auth::attempt()). - Conditionner la whitelist de votre entry-point à l'état de connexion.
- Protéger un espace d'administration et y proposer une suppression.
Étape 1 : la table des utilisateurs
Nos utilisateurs vont vivre en base, à côté des idées. Voici le script, à exécuter dans l'onglet SQL de phpMyAdmin (après avoir sélectionné la base boite_idees à gauche) :
CREATE TABLE utilisateurs (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(150) NOT NULL UNIQUE,
mot_de_passe VARCHAR(255) NOT NULL,
nom VARCHAR(100) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;Trois remarques sur cette table :
emailest enUNIQUE: deux comptes ne peuvent pas partager la même adresse, et c'est la base qui le garantit.mot_de_passefait 255 caractères, alors qu'un hashbcrypten fait 60. C'est la taille conseillée par la documentation de PHP : le jour où l'algorithme par défaut changera (et il changera), votre colonne sera déjà assez grande.- La colonne s'appelle
mot_de_passe, mais elle ne contiendra jamais de mot de passe. Elle contient une empreinte.
Créer votre compte administrateur
Il nous faut maintenant une ligne dans cette table. Et là, attention :
Jamais de mot de passe en clair
On ne stocke jamais un mot de passe en clair dans une base de données. Jamais. Pas « en attendant », pas « juste pour tester ». On stocke une empreinte (un hash) calculée par password_hash(), qui est impossible à inverser.
Pourquoi ? Parce que le jour où votre base fuite (et ça arrive tous les jours à des entreprises bien plus grosses que nous), les mots de passe de vos utilisateurs sont dans la nature. Et comme beaucoup de gens réutilisent le même mot de passe partout, vous ne leur faites pas perdre un compte sur votre site : vous leur faites perdre leur boîte mail.
Toute la théorie est dans le TP sur l'authentification et dans le complément sécurité.
Pour obtenir le hash de votre mot de passe, créez un petit script jetable à la racine de votre projet :
<?php
// hash.php : à exécuter une fois, puis à SUPPRIMER
echo password_hash("motdepasse", PASSWORD_DEFAULT);Ouvrez http://localhost/boite-a-idees/hash.php, copiez la chaîne affichée (elle commence par $2y$), puis supprimez le fichier. Insérez ensuite votre compte, toujours dans l'onglet SQL :
INSERT INTO utilisateurs (email, mot_de_passe, nom) VALUES
('admin@exemple.com', 'COLLEZ-ICI-VOTRE-HASH', 'Valentin');Point de contrôle
Dans phpMyAdmin, la table utilisateurs contient une ligne, et la colonne mot_de_passe affiche quelque chose comme $2y$10$JwcxeB9JYpPA1sVJvO67E.bY6tCTus….
Relancez hash.php (avant de le supprimer) plusieurs fois avec le même mot de passe : vous obtenez un hash différent à chaque fois. Surpris ? C'est le sel (« salt ») qui est tiré au hasard et rangé dans le hash lui-même. Deux utilisateurs avec le même mot de passe auront deux empreintes différentes, et une table arc-en-ciel ne sert plus à rien. L'explication complète est dans le TP sur l'authentification.
Étape 2 : le modèle User
Vous avez écrit Idea au TP 1. Ici, c'est exactement la même logique, appliquée à une autre table : une classe User, avec les données d'une ligne et la méthode qui va la chercher en base. Je vous donne les squelettes, à vous de remplir les trous.
Les données
Un modèle représente une ligne de la table. Quatre propriétés, typées, avec la promotion de constructeur :
<?php
class User
{
public function __construct(
public ?int $id,
public string $email,
public string $passwordHash,
public string $name
) {
}
}Pourquoi passwordHash et pas password ?
Le nom d'une propriété, ça se lit. password, ça laisse croire qu'on manipule un mot de passe. passwordHash, ça dit clairement qu'on manipule une empreinte, et donc qu'on ne pourra jamais la comparer avec un ==. Nommer correctement, c'est déjà documenter.
Au passage : les propriétés sont en anglais (passwordHash), les colonnes SQL en français (mot_de_passe). C'est la convention du projet, et c'est le modèle qui fait la traduction entre les deux.
L'accès à la base
Reste la méthode qui parle à la base et qui fabrique un objet User. Pour l'instant, une seule nous suffit. Ajoutez-la dans classes/User.php, à la suite du constructeur :
// Retrouve un utilisateur à partir de son email, ou null s'il n'existe pas.
public static function findByEmail(string $email): ?User
{
// 1. Préparer un SELECT sur la table utilisateurs, filtré sur l'email
// 2. Exécuter la requête avec l'email reçu en paramètre
// 3. Récupérer la ligne avec fetch(PDO::FETCH_ASSOC)
// 4. Si fetch() n'a rien renvoyé : aucun utilisateur, on renvoie null
// 5. Sinon : construire et renvoyer un objet User à partir de la ligne
}Elle est static, comme Idea::find() : au moment où on l'appelle, on n'a pas encore d'utilisateur sous la main, c'est justement ce qu'on lui demande de fabriquer. On l'appellera donc avec User::findByEmail(...).
Requête préparée obligatoire
L'email vient d'un formulaire, donc de l'utilisateur, donc d'un attaquant potentiel. Il ne doit jamais être concaténé dans la requête SQL. Requête préparée, paramètre, point final. Si le mot « injection SQL » ne vous dit plus rien, un petit tour par le complément sécurité s'impose.
Besoin d'aide pour le type de retour ?User ?
Le ? devant un type veut dire « ce type ou null ». findByEmail() renvoie donc soit un objet User, soit null quand l'email n'existe pas.
C'est exactement ce que fait déjà votre Idea::find(int $id): ?Idea. Ouvrez-le, la structure est la même, seuls la requête et la classe construite changent.
Point de contrôle
Créez temporairement une page de test (ou ajoutez ces deux lignes en haut de pages/home.php) :
var_dump(User::findByEmail('admin@exemple.com'));
var_dump(User::findByEmail('personne@exemple.com'));Vous devez obtenir :
- un
object(User)#…avec vos quatre propriétés remplies (et unpasswordHashqui commence par$2y$) ; - puis un
NULL.
Si les deux renvoient NULL, votre requête ou votre base ne sont pas d'accord. Pensez à retirer ce var_dump() ensuite.
Étape 3 : la classe Auth
Nous avons de quoi retrouver un utilisateur. Il faut maintenant vérifier son mot de passe et se souvenir qu'il est connecté.
Pourquoi une classe Auth plutôt que ce code dans la page connexion ?
Posez-vous la question avant de lire la réponse. Qu'est-ce qui vous gênerait si toute la vérification vivait dans pages/connexion.php ?
La réponse
Deux raisons, et ce sont les deux raisons qui justifient à peu près toute la POO :
- La réutilisation. « Est-ce que l'utilisateur est connecté ? », vous allez vous le demander dans
index.php, dansheader.php, dansadmin.php. Si le test est écrit dans la page de connexion, vous allez le recopier trois fois. Et le jour où vous changez la clé de session, vous oubliez forcément l'un des trois. - La responsabilité unique. Le rôle d'une page, c'est d'afficher. Le rôle de
Auth, c'est de savoir qui est connecté. Chaque chose à sa place, et quand quelque chose casse, vous savez où regarder.
Bonus : si demain vous ajoutez un « se souvenir de moi » ou une connexion via Google, vous modifiez Auth et rien d'autre.
Le squelette
Créez classes/Auth.php. Je vous donne les signatures, le corps de attempt() est à vous :
<?php
class Auth
{
public static function attempt(string $email, string $password): bool
{
// À vous !
}
public static function check(): bool
{
return isset($_SESSION['user_id']);
}
public static function name(): ?string
{
return $_SESSION['user_name'] ?? null;
}
public static function logout(): void
{
unset($_SESSION['user_id'], $_SESSION['user_name']);
session_destroy();
}
}Pourquoi des méthodes static ici ?
Une méthode statique s'appelle sur la classe, pas sur un objet : Auth::check() et non $auth->check().
C'est le bon choix ici parce que Auth ne représente rien de concret : ce n'est pas « un » quelque chose, c'est une boîte à outils. Il n'y a aucune donnée à lui donner à la construction, et il n'y a qu'une session par visiteur. Exactement le même raisonnement que pour Database::getPdo() au TP 1.
Même raisonnement pour User::findByEmail() : elle sert à obtenir un utilisateur, elle ne peut donc pas en supposer un. En revanche, le jour où vous ajouterez un save() à User, il ne sera pas statique : il enregistrera cet utilisateur-là.
Pourquoi Auth::name() et pas Auth::user() qui renverrait un objet User ?
Bonne question, et les deux se défendent.
Renvoyer un objet User complet obligerait à relire la base à chaque page (on ne stocke pas un objet entier en session, on stocke son id). Or, pour l'instant, la seule chose que nous affichons, c'est le nom. Une requête SQL sur toutes les pages pour afficher un prénom, c'est cher payé.
Nous stockons donc deux choses en session : user_id (l'identité, dont nous aurons besoin plus tard) et user_name (l'affichage). Et Auth::name() se contente de lire la session.
À retenir pour la suite : les frameworks proposent bien un Auth::user() qui renvoie l'objet complet, parce qu'ils savent mettre le résultat en cache pour la durée de la requête. Nous y viendrons.
L'algorithme de attempt()
En « algo », ce que doit faire attempt() :
SI utilisateur = CHERCHER_PAR_EMAIL(email) EST NULL ALORS
RENVOYER faux
FIN SI
SI password_verify(mot_de_passe_saisi, utilisateur.hash) EST FAUX ALORS
RENVOYER faux
FIN SI
REGENERER_ID_DE_SESSION
SESSION[user_id] = utilisateur.id
SESSION[user_name] = utilisateur.nom
RENVOYER vraiQue se passe-t-il derrière ?
password_verify($saisi, $hash) : on ne compare jamais deux hashs avec ==. Pourquoi ? Parce que re-hacher le mot de passe saisi donnerait une empreinte différente (le sel est tiré au hasard, souvenez-vous de l'étape 1). password_verify() fait le travail correctement : il relit le sel et l'algorithme à l'intérieur du hash stocké, recalcule l'empreinte du mot de passe saisi avec ce sel-là, et compare le résultat en temps constant (pour ne pas donner d'indice à un attaquant qui chronomètre les réponses).
session_regenerate_id(true) : au moment où l'utilisateur change de statut (anonyme, puis connecté), on lui donne un nouvel identifiant de session et on détruit l'ancien (c'est le rôle du true). Ça bloque une attaque qui s'appelle la « fixation de session » : un attaquant qui aurait réussi à imposer un identifiant de session à sa victime avant sa connexion se retrouve avec un identifiant périmé juste après. Une ligne, une classe d'attaque en moins.
Voir l'une des solutions possibles
public static function attempt(string $email, string $password): bool
{
$user = User::findByEmail($email);
if ($user === null) {
return false;
}
if (!password_verify($password, $user->passwordHash)) {
return false;
}
session_regenerate_id(true);
$_SESSION['user_id'] = $user->id;
$_SESSION['user_name'] = $user->name;
return true;
}Remarquez l'ordre : on sort tôt (return false) dès qu'une condition n'est pas remplie, plutôt que d'empiler des if imbriqués. Le code se lit mieux, et le chemin « tout va bien » reste en bas, sans indentation.
Étape 4 : la page de connexion et la whitelist
Vous avez le moteur, il manque le tableau de bord.
La page connexion
Créez pages/connexion.php. Elle doit :
- afficher un formulaire en POST (email + mot de passe) qui poste vers
index.php?page=connexion; - si le formulaire a été soumis, appeler
Auth::attempt(); - en cas de succès, rediriger vers
index.php?page=admin; - en cas d'échec, réafficher le formulaire avec un message d'erreur.

Un message d'erreur, un seul
Le message doit être générique : « Identifiants incorrects. »
Surtout pas « Cet email n'existe pas » d'un côté et « Mot de passe incorrect » de l'autre. Sinon vous offrez à un attaquant un moyen de savoir quels comptes existent sur votre site, ce qui est la moitié du travail. Même message, même comportement, quelle que soit la raison de l'échec.
Besoin d'aide pour la structure de la page ?
La structure est celle d'un traitement de formulaire classique, vue au TP 4 :
<?php
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Récupérer $_POST['email'] et $_POST['password'] (avec ?? '' au cas où)
// Si Auth::attempt(...) renvoie true : header('location: …'); die();
// Sinon : $error = "Identifiants incorrects.";
}
?>
<h2>Connexion</h2>
<!-- Si $error n'est pas null, l'afficher -->
<!-- Le formulaire method="post" action="index.php?page=connexion" -->Et n'oubliez pas le die() après le header('location: …') : header() n'arrête pas le script, il se contente d'ajouter une entête. Sans le die(), la suite de la page s'exécute quand même.
Rendre la redirection possible
Rappel du TP 1 : votre index.php commence par ob_start(). Sans cette ligne, header('location: …') et session_regenerate_id() (qui envoient des entêtes HTTP) échoueraient puisque header.php est affiché avant la page. Vérifiez qu'elle est bien là avant de continuer.
La whitelist conditionnelle
C'est la partie que vous avez déjà faite au TP 5, en procédural. Le principe ne change pas d'un pouce, seule la manière de poser la question change : au lieu d'un isset($_SESSION[...]) écrit à la main, vous appelez Auth::check().
En algo :
SI Auth::check() ALORS
$whitelist = ['home', 'proposer', 'idee', 'admin', 'deconnexion']
SINON
$whitelist = ['home', 'proposer', 'idee', 'connexion']
FIN SIJe vous laisse l'écrire dans index.php, à la place de votre whitelist actuelle.
Prenez le temps de regarder ce tableau
- Pourquoi
connexiondisparait de la liste quand on est connecté ? - Pourquoi
adminetdeconnexionne sont-ils pas dans la liste des visiteurs anonymes ? - Et
home,proposer,idee: pourquoi sont-ils dans les deux ?
Le header
Adaptez common/header.php pour que la navigation s'adapte :
- si
Auth::check(): un lien vers l'administration, le texte « Connecté : Nom » et un lien « Déconnexion » ; - sinon : un lien « Connexion ».
Besoin d'aide pour écrire du HTML conditionnel proprement ?
Dans un fichier majoritairement HTML, la syntaxe alternative est bien plus lisible que des accolades :
<?php if (Auth::check()) : ?>
<a href="index.php?page=admin">Administration</a>
<span>Connecté : <?= htmlspecialchars(Auth::name()) ?></span>
<a href="index.php?page=deconnexion">Déconnexion</a>
<?php else : ?>
<a href="index.php?page=connexion">Connexion</a>
<?php endif; ?>Et <?= ?> est simplement un raccourci pour <?php echo ?>.
La déconnexion
Créez pages/deconnexion.php. Deux lignes utiles : appeler Auth::logout(), puis rediriger vers la home. Vous savez faire.
Point de contrôle
index.php?page=connexionaffiche le formulaire.- Un mauvais mot de passe réaffiche le formulaire avec « Identifiants incorrects. ».
- Le bon couple email / mot de passe vous emmène ailleurs, et le header affiche « Connecté : … ».
- « Déconnexion » vous ramène à la home, avec le lien « Connexion » de retour.
Étape 5 : l'espace d'administration
Cette fois, pas de squelette : vous avez tout ce qu'il faut. Voici le cahier des charges, à vous de jouer.
La page pages/admin.php doit :
- Afficher la liste complète des idées dans un tableau HTML (titre, auteur, date).
- Afficher un petit résumé du type « Bonjour Nom, il y a N idée(s) dans la boîte » (vous avez déjà
count()etAuth::name()). - Proposer sur chaque ligne un lien « Supprimer » qui supprime l'idée, puis revient sur la page d'administration.

Si vous n'avez pas fait le bonus delete() du TP 1, ajoutez-le à Idea : c'est un DELETE FROM idees WHERE id = ? en requête préparée sur $this->id, la méthode n'a pas de paramètre et ne renvoie rien (public function delete(): void).
Besoin d'aide pour le lien « Supprimer » ?
Un lien, c'est une requête GET. Il suffit donc de passer l'id à supprimer en paramètre, en plus de la page :
<a href="index.php?page=admin&delete=<?= $idea->id ?>">Supprimer</a>Et tout en haut de admin.php, avant l'affichage :
if (isset($_GET['delete'])) {
// Retrouver l'idée avec Idea::find((int) $_GET['delete'])
// Si elle existe, lui demander de se supprimer : $idea->delete()
// Puis rediriger vers index.php?page=admin et die()
}Pourquoi rediriger après la suppression plutôt que d'afficher directement la liste ? Pour que l'URL affichée dans le navigateur ne contienne plus &delete=…. Sinon, un simple F5 relance la suppression. C'est le motif « Post / Redirect / Get », et il vaut aussi pour les liens.
Le (int) devant $_GET['delete'], lui, n'est pas décoratif : il garantit qu'on passe bien un entier à delete(int $id), quoi que l'utilisateur ait tapé dans l'URL. Rappelez-vous la règle d'or : on ne fait jamais confiance aux données reçues.
Que se passe-t-il si je tape index.php?page=admin sans être connecté ?
Réfléchissez avant de tester : où se joue l'autorisation, dans votre projet ?
La réponse
Dans la whitelist, et nulle part ailleurs. Si vous n'êtes pas connecté, admin n'est pas dans la liste des pages autorisées : in_array() renvoie false, et votre index.php affiche la home. La page pages/admin.php n'est même pas chargée. C'est bien plus solide qu'un test écrit dans la page, parce qu'on ne peut pas l'oublier : toutes les pages passent par l'entry-point.
Un point de vigilance tout de même : l'accès direct à pages/admin.php dans l'URL, lui, contourne l'entry-point. Sur un vrai serveur, on empêche ça en plaçant pages/ hors de la racine web (c'est ce que font tous les frameworks, dont seul le dossier public/ est exposé). Pour ce TP, on s'en tient à la règle : on passe toujours par index.php?page=….
Point de contrôle
Faites le test pour de vrai, dans une fenêtre de navigation privée (pas de session) :
index.php?page=admin: vous devez voir la page d'accueil publique, pas l'administration.- Connectez-vous dans la fenêtre normale, puis
index.php?page=admin: le tableau s'affiche. - Supprimez une idée : elle disparait du tableau et de la page d'accueil.
- Déconnectez-vous, retentez
index.php?page=admin: retour à la home.
Pour aller plus loin
Vous avez terminé et il vous reste du temps ? Voici trois évolutions, de la plus simple à la plus intéressante.
Limiter les tentatives
Un attaquant qui teste 10 000 mots de passe à la seconde finira par trouver. Comptez les échecs dans la session (par exemple $_SESSION['login_attempts']) et, au-delà de 5, refusez la connexion avec un message du type « Trop de tentatives ». Remettez le compteur à zéro après une connexion réussie.
Une protection, pas LA protection
Ce compteur vit en session, donc dans le navigateur de l'attaquant : il lui suffit de supprimer son cookie pour repartir à zéro. C'est une vraie protection contre un humain qui tâtonne, pas contre un script. Les vraies défenses (compteur en base par compte et par IP, délai croissant, captcha) sont détaillées dans le TP sur l'authentification.
Échapper systématiquement
Relisez toutes vos pages : chaque valeur qui vient de la base ou d'un formulaire et qui est affichée doit passer par htmlspecialchars(). Proposez une idée intitulée <script>alert('coucou')</script> et vérifiez qu'elle s'affiche comme du texte, et ne s'exécute pas.
La page d'inscription
Ajoutez une page inscription (publique, donc dans la whitelist des anonymes) avec un formulaire nom / email / mot de passe. Ajoutez une méthode save(): void à User (non statique, comme celle de Idea), et n'oubliez pas : c'est au moment de l'insertion qu'on appelle password_hash(). Pensez aussi au cas de l'email déjà pris (votre contrainte UNIQUE va lever une exception, à vous de l'attraper et d'afficher un message propre).
Conclusion
Beau travail ! Faisons le point sur ce que vous venez de construire :
- Une table
utilisateursavec des mots de passe hachés, jamais en clair. - Un modèle
User, écrit presque sans aide : la POO commence à devenir une routine. - Une classe de service
Auth, qui regroupe en un seul endroit tout ce que votre application sait de la connexion. - Une whitelist conditionnelle : l'autorisation est décidée à l'entrée du site, pas au fond d'une page.
- Un espace d'administration protégé, avec une suppression qui fonctionne.
Et surtout, le réflexe qui compte : quand un même bout de logique commence à être recopié à plusieurs endroits, c'est qu'il demande une classe.
La suite
Vous avez maintenant deux modèles (Idea, User), une classe utilitaire (Database), une classe de service (Auth) et des pages qui ne font plus que de l'affichage et du traitement. C'est, à peu de chose près, le découpage MVC, dont le principe est résumé dans le support.
La suite du parcours, c'est le TP 6 et le MVC.
👋 Si vous avez des questions, n'hésitez pas. C'est à vous de jouer !