Le livre d’or du cours précédent est ouvert à tous… y compris aux messages indésirables. Camille veut un espace de modération, réservé à quelques personnes, pour supprimer les messages. Il faut donc des comptes, des mots de passe et des pages protégées. C’est l’occasion de découvrir les mécanismes d’authentification du Web, et surtout les attaques que chacun d’eux empêche.
1. Trois notions à ne pas confondre
| Notion | Question posée | Dans notre site | Au guichet d’un aéroport |
|---|---|---|---|
| Identification | Qui prétendez-vous être ? | Saisir l’identifiant camille | Donner son nom |
| Authentification | Pouvez-vous le prouver ? | Saisir le bon mot de passe | Montrer sa carte d’identité |
| Autorisation | Qu’avez-vous le droit de faire ? | Le rôle admin ouvre la page « Comptes » | La carte d’embarquement ouvre la porte du bon vol |
Un mot de passe est un facteur « ce que je sais ». L’authentification multifacteur y ajoute ce que je possède (téléphone, clé de sécurité) ou ce que je suis (empreinte) : voir Les fondamentaux, § 4.8. Ce cours se concentre sur le mot de passe, qui reste la base de presque toutes les applications.
2. Les sessions : comment le serveur se souvient de vous
2.1 Le problème : HTTP n’a pas de mémoire
Chaque requête HTTP est indépendante des précédentes (Fiche HTTP). Après avoir vérifié le mot de passe, le serveur doit pourtant reconnaître le visiteur à chaque page suivante, sans lui redemander de se connecter.
La solution est la session, qui fonctionne comme un vestiaire :
- À la première visite, PHP crée un identifiant aléatoire (le numéro du ticket) et un fichier sur le serveur (le portemanteau).
- Il envoie l’identifiant au navigateur dans un cookie.
- Le navigateur renvoie automatiquement ce cookie à chaque requête vers le site.
- PHP retrouve le fichier correspondant et remplit le tableau
$_SESSION.
Après une connexion, voici ce que contient réellement le fichier de session sur un serveur Debian (/var/lib/php/sessions/sess_ic590vtjape4sb8audfoghc4qn) :
utilisateur|a:3:{s:2:"id";i:1;s:5:"login";s:7:"camille";s:4:"role";s:5:"admin";}derniere_activite|i:1792695590;
Le navigateur, lui, ne détient que l’identifiant ic590vtjape4sb8audfoghc4qn. Le rôle ne peut donc pas être modifié par le visiteur.
2.2 Un cookie bien protégé
On ajoute à inc/fonctions.php une fonction qui démarre la session avec un cookie protégé, et une autre qui indique si quelqu’un est connecté :
<?php
// inc/fonctions.php (suite)
// ---------- Session ----------
const NOM_SESSION = 'carte_session';
// Démarre la session avec un cookie protégé
function demarrerSession(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
session_name(NOM_SESSION);
session_set_cookie_params([
'lifetime' => 0, // cookie supprimé à la fermeture du navigateur
'path' => '/',
'secure' => !empty($_SERVER['HTTPS']), // envoyé uniquement en HTTPS quand le site l'est
'httponly' => true, // illisible pour JavaScript
'samesite' => 'Lax', // pas envoyé par les formulaires d'autres sites
]);
ini_set('session.use_strict_mode', '1'); // refuse un identifiant de session inventé
session_start();
}
// Renvoie l'utilisateur connecté, ou null (sans créer de session pour un simple visiteur)
function utilisateurConnecte(): ?array
{
if (session_status() !== PHP_SESSION_ACTIVE && isset($_COOKIE[NOM_SESSION])) {
demarrerSession();
}
return $_SESSION['utilisateur'] ?? null;
}
| Réglage | Effet | Attaque limitée |
|---|---|---|
httponly | Le cookie est illisible pour JavaScript (document.cookie) | Vol de session par une faille XSS |
secure | Le cookie n’est envoyé qu’en HTTPS | Vol de session par écoute du réseau |
samesite => 'Lax' | Le cookie n’est pas joint aux formulaires POST venus d’autres sites | CSRF (§ 7) |
lifetime => 0 | Le cookie disparaît à la fermeture du navigateur | Session oubliée sur un poste partagé |
use_strict_mode | PHP refuse un identifiant qu’il n’a pas créé lui-même | Fixation de session (§ 5) |
utilisateurConnecte() ne démarre la session que si le navigateur présente déjà un cookie : un simple visiteur du livre d’or ne reçoit aucun cookie. C’est plus sobre, et conforme à l’esprit du RGPD : un cookie de session nécessaire à la connexion ne demande pas de consentement, encore faut-il qu’il serve à quelque chose.
3. Stocker les mots de passe
3.1 Ce qu’il ne faut jamais faire
Les bases de données finissent parfois par fuiter : sauvegarde mal protégée, injection SQL, serveur compromis. Le stockage des mots de passe doit donc rester sûr même si un attaquant lit toute la table.
| Méthode | Ce que voit l’attaquant qui vole la table | Verdict |
|---|---|---|
| Mot de passe en clair | Tous les mots de passe, immédiatement | Inacceptable |
| Mot de passe chiffré (réversible) | Tous les mots de passe, dès qu’il trouve la clé, souvent stockée sur le même serveur | Inadapté |
Empreinte rapide : md5(), sha1(), sha256 | ab4f63f9ac65152575886860dde480a1 : retrouvé en un instant (« azerty »), car ces fonctions se calculent à raison de milliards par seconde sur une carte graphique | Inadapté |
Empreinte lente et salée : password_hash() | Une empreinte unique par compte, qu’il faut attaquer mot de passe par mot de passe, très lentement | La bonne méthode |
Rappelons qu’une empreinte (hash) est à sens unique : on ne peut pas « déchiffrer » une empreinte, seulement tester des mots de passe un par un jusqu’à retrouver la même. Tout l’enjeu est de rendre chaque essai coûteux.
3.2 password_hash() et password_verify()
PHP fournit deux fonctions qui font tout le travail :
<?php
// À la création du compte : on calcule l'empreinte et on ne stocke QU'ELLE
$empreinte = password_hash('Bonne-phrase-de-passe-2026', PASSWORD_DEFAULT);
// À la connexion : on compare la saisie à l'empreinte stockée
if (password_verify($saisie, $empreinte)) {
// mot de passe correct
}
Le même mot de passe haché deux fois donne deux empreintes différentes :
$2y$12$qnY1U9xUra9lRRPV3fgyIOmi7BTmBidRLQkAaBlUmoZ1V7eJO4ec2
$2y$12$a4g6Wxwrgw3.xuKCRDHSq.1Ha7bIgKqMd7bapvNvtZBinPWEpH1Iq
| Partie | Signification |
|---|---|
$2y$ | L’algorithme : bcrypt |
12$ | Le coût : 2¹² tours de calcul. Chaque +1 double le temps de calcul. PHP 8.4 (Debian 13) utilise 12 par défaut |
| 22 caractères suivants | Le sel : une valeur aléatoire, différente pour chaque empreinte |
| Le reste | L’empreinte proprement dite |
- Le sel explique que deux empreintes du même mot de passe diffèrent : impossible de repérer les comptes qui partagent un mot de passe, ou d’utiliser des tables de mots de passe précalculées.
- Le coût rend chaque essai lent : quelques dixièmes de seconde, imperceptibles pour l’utilisateur, mais énormes pour un attaquant qui doit en tester des milliards.
- Tout est contenu dans la chaîne :
password_verify()y retrouve l’algorithme, le coût et le sel. La colonne de la base se contente donc d’unVARCHAR(255), ce qui laisse de la place siPASSWORD_DEFAULTchange d’algorithme dans une future version de PHP.
4. Les comptes de modération
4.1 La table et les droits
base-auth.sql
-- À exécuter en administrateur : sudo mariadb < base-auth.sql
CREATE TABLE carte.utilisateur (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
login VARCHAR(30) NOT NULL UNIQUE,
mot_de_passe VARCHAR(255) NOT NULL, -- l'empreinte, jamais le mot de passe
role ENUM('admin', 'moderateur') NOT NULL DEFAULT 'moderateur',
echecs TINYINT UNSIGNED NOT NULL DEFAULT 0,
bloque_jusqua DATETIME NULL,
cree_le DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Le site peut lire les comptes et mettre à jour SEULEMENT le compteur d'échecs
GRANT SELECT ON carte.utilisateur TO 'carte_web'@'localhost';
GRANT UPDATE (echecs, bloque_jusqua) ON carte.utilisateur TO 'carte_web'@'localhost';
-- La modération a besoin de supprimer des messages
GRANT DELETE ON carte.message TO 'carte_web'@'localhost';
Le compte carte_web utilisé par le site reçoit le strict nécessaire :
- lire les comptes, pour vérifier les mots de passe ;
- modifier seulement deux colonnes,
echecsetbloque_jusqua, pour compter les erreurs de connexion. MariaDB permet de donner des droits colonne par colonne : même en exploitant une faille du site, un attaquant ne pourrait ni changer un rôle, ni remplacer un mot de passe ; - supprimer des messages, ce qui est le but de la modération.
Il n’a pas le droit de créer des comptes : c’est volontaire (§ 4.2).
4.2 Créer un compte… sans page d’inscription
Pour un espace d’administration, une page d’inscription publique serait une porte ouverte. Les comptes sont créés par un script en ligne de commande, rangé hors du dossier public :
outils/creer-compte.php
<?php
// Création d'un compte, en ligne de commande uniquement (hors du dossier public).
// Usage : php outils/creer-compte.php <login> <admin|moderateur> | sudo mariadb carte
if (PHP_SAPI !== 'cli') {
exit('À lancer en ligne de commande.');
}
[$script, $login, $role] = $argv + [null, '', 'moderateur'];
if (!preg_match('/^[a-z0-9._-]{3,30}$/', $login) || !in_array($role, ['admin', 'moderateur'], true)) {
fwrite(STDERR, "Usage : php outils/creer-compte.php <login> <admin|moderateur>\n");
fwrite(STDERR, "Login : 3 à 30 caractères parmi a-z, 0-9, point, tiret, tiret bas.\n");
exit(1);
}
// Saisie du mot de passe sans l'afficher (Linux), et sans qu'il apparaisse dans l'historique
fwrite(STDERR, 'Mot de passe (12 caractères minimum) : ');
$masquer = stream_isatty(STDIN) && PHP_OS_FAMILY !== 'Windows';
if ($masquer) { shell_exec('stty -echo'); }
$mdp = rtrim((string) fgets(STDIN), "\r\n");
if ($masquer) { shell_exec('stty echo'); }
fwrite(STDERR, "\n");
if (mb_strlen($mdp) < 12) {
fwrite(STDERR, "Mot de passe trop court : au moins 12 caractères.\n");
exit(1);
}
$hash = password_hash($mdp, PASSWORD_DEFAULT);
// Le login est validé par l'expression régulière ; l'empreinte ne contient pas d'apostrophe
echo "INSERT INTO utilisateur (login, mot_de_passe, role) VALUES ('$login', '$hash', '$role');\n";
fwrite(STDERR, "Compte « $login » ($role) prêt à être créé.\n");
php outils/creer-compte.php camille admin | sudo mariadb carte
- Le mot de passe est saisi sans s’afficher, et n’apparaît donc ni à l’écran ni dans l’historique des commandes (ce qui serait le cas avec
php -r "password_hash('…')"). - Le script vérifie une longueur minimale de 12 caractères. Les recommandations actuelles (ANSSI, CNIL) privilégient la longueur, par exemple une phrase de passe, plutôt que des règles de complexité qui conduisent à des mots de passe prévisibles (
Azerty2026!). - Il affiche la requête
INSERTsur la sortie standard, transmise àsudo mariadb: c’est l’administrateur du serveur, et non le site, qui crée le compte. Le login étant strictement contrôlé par l’expression régulière, il ne peut pas contenir d’apostrophe ni de code SQL.
5. La page de connexion
public/connexion.php
<?php
require __DIR__ . '/../inc/fonctions.php';
require __DIR__ . '/../inc/auth.php';
const ECHECS_MAX = 5;
demarrerSession();
$titre = 'Connexion';
$erreur = '';
$login = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verifierCsrf();
$login = trim($_POST['login'] ?? '');
$mdp = $_POST['mot_de_passe'] ?? '';
$requete = connexion()->prepare(
'SELECT id, login, mot_de_passe, role, echecs,
(bloque_jusqua IS NOT NULL AND bloque_jusqua > NOW()) AS bloque
FROM utilisateur WHERE login = :login'
);
$requete->execute(['login' => $login]);
$u = $requete->fetch();
if ($u) {
$mdpCorrect = password_verify($mdp, $u['mot_de_passe']);
} else {
// Compte inexistant : on calcule quand même une empreinte, pour que la réponse prenne
// le même temps que pour un vrai compte (sinon, le délai trahit les comptes existants)
password_hash($mdp, PASSWORD_DEFAULT);
$mdpCorrect = false;
}
if ($u && !$u['bloque'] && $mdpCorrect) {
// Succès : on remet le compteur à zéro et on ouvre la session
connexion()->prepare('UPDATE utilisateur SET echecs = 0, bloque_jusqua = NULL WHERE id = :id')
->execute(['id' => $u['id']]);
ouvrirSession($u);
header('Location: admin.php');
exit;
}
// Échec : on compte l'erreur, et on bloque le compte 15 minutes après 5 échecs
if ($u && !$u['bloque']) {
$echecs = $u['echecs'] + 1;
if ($echecs >= ECHECS_MAX) {
$sql = 'UPDATE utilisateur SET echecs = 0, bloque_jusqua = NOW() + INTERVAL 15 MINUTE WHERE id = :id';
connexion()->prepare($sql)->execute(['id' => $u['id']]);
} else {
$sql = 'UPDATE utilisateur SET echecs = :echecs WHERE id = :id';
connexion()->prepare($sql)->execute(['echecs' => $echecs, 'id' => $u['id']]);
}
}
// Journaliser l'échec (json_encode neutralise les retours à la ligne glissés dans le login)
error_log('[auth] échec de connexion login=' . json_encode($login) . ' ip=' . $_SERVER['REMOTE_ADDR']);
// Un seul message, quelle que soit la cause : on ne révèle pas si le compte existe
$erreur = 'Identifiant ou mot de passe incorrect, ou compte temporairement bloqué après plusieurs erreurs.';
}
require __DIR__ . '/../inc/header.php';
?>
<section class="carte">
<h2>Connexion à l'espace de modération</h2>
<?php if (isset($_GET['expire'])): ?>
<p class="erreur">Votre session a expiré après une période d'inactivité.</p>
<?php endif; ?>
<?php if ($erreur): ?>
<p class="erreur"><?= e($erreur) ?></p>
<?php endif; ?>
<form method="post" action="connexion.php">
<input type="hidden" name="csrf" value="<?= e(jetonCsrf()) ?>">
<label for="login">Identifiant</label>
<input type="text" id="login" name="login" required autocomplete="username" value="<?= e($login) ?>">
<label for="mot_de_passe">Mot de passe</label>
<input type="password" id="mot_de_passe" name="mot_de_passe" required autocomplete="current-password">
<button type="submit">Se connecter</button>
</form>
</section>
<?php require __DIR__ . '/../inc/footer.php'; ?>
Ce script contient plusieurs protections, chacune contre une attaque précise.
| Protection | Ligne(s) concernée(s) | Attaque empêchée |
|---|---|---|
| Requête préparée | prepare(… WHERE login = :login) | Injection SQL (PHP 1, § 8) |
| Un message unique pour toutes les causes d’échec | $erreur = 'Identifiant ou mot de passe incorrect…' | Énumération des comptes : avec « identifiant inconnu » d’un côté et « mot de passe incorrect » de l’autre, un attaquant dresse la liste des comptes existants |
| Un temps de réponse identique | password_hash() calculé même si le compte n’existe pas | Énumération par le temps : sans cela, un compte inexistant répond en quelques millisecondes, un compte existant en plusieurs dizaines |
| Blocage temporaire après 5 échecs | Colonnes echecs et bloque_jusqua | Force brute : tester des milliers de mots de passe sur un compte |
session_regenerate_id(true) | Dans ouvrirSession() (§ 6) | Fixation de session : l’attaquant fait utiliser à la victime un identifiant de session qu’il connaît, puis attend qu’elle se connecte |
| Journalisation des échecs | error_log('[auth] échec…') | Rend visibles les attaques, pour les détecter et y réagir |
| Jeton CSRF | verifierCsrf() | Connexion forcée depuis un autre site (§ 7) |
6. Protéger les pages
6.1 Une fonction pour toutes les pages réservées
inc/auth.php
<?php
// Durée d'inactivité au-delà de laquelle on est déconnecté (en secondes)
const INACTIVITE_MAX = 30 * 60;
// À appeler en tête de chaque page réservée
function exigerConnexion(?string $role = null): array
{
demarrerSession();
$utilisateur = $_SESSION['utilisateur'] ?? null;
// 1. Authentification : est-on connecté ?
if ($utilisateur === null) {
header('Location: connexion.php');
exit;
}
// 2. Inactivité trop longue : on ferme la session
if (time() - $_SESSION['derniere_activite'] > INACTIVITE_MAX) {
fermerSession();
header('Location: connexion.php?expire=1');
exit;
}
$_SESSION['derniere_activite'] = time();
// 3. Autorisation : a-t-on le bon rôle ?
if ($role !== null && $utilisateur['role'] !== $role) {
http_response_code(403);
exit('Accès refusé.');
}
return $utilisateur;
}
// Ouvre la session d'un utilisateur dont le mot de passe vient d'être vérifié
function ouvrirSession(array $utilisateur): void
{
session_regenerate_id(true); // nouvel identifiant : contre la fixation de session
$_SESSION = [
'utilisateur' => [
'id' => (int) $utilisateur['id'],
'login' => $utilisateur['login'],
'role' => $utilisateur['role'],
],
'derniere_activite' => time(),
];
}
// Détruit la session côté serveur ET le cookie côté navigateur
function fermerSession(): void
{
$_SESSION = [];
$p = session_get_cookie_params();
setcookie(session_name(), '', [
'expires' => time() - 3600,
'path' => $p['path'],
'secure' => $p['secure'],
'httponly' => $p['httponly'],
'samesite' => $p['samesite'],
]);
session_destroy();
}
exigerConnexion() fait trois vérifications, dans cet ordre : est-on connecté (authentification) ? la session n’a-t-elle pas expiré ? a-t-on le rôle demandé (autorisation) ? Une seule ligne en tête de page suffit ensuite à la protéger.
6.2 La page de modération
public/admin.php
<?php
require __DIR__ . '/../inc/fonctions.php';
require __DIR__ . '/../inc/auth.php';
$moi = exigerConnexion(); // toute personne connectée (admin ou modérateur)
$titre = 'Modération';
// Suppression d'un message : POST + jeton CSRF + requête préparée
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verifierCsrf();
$id = (int) ($_POST['id'] ?? 0);
connexion()->prepare('DELETE FROM message WHERE id = :id')->execute(['id' => $id]);
error_log('[moderation] message ' . $id . ' supprimé par ' . json_encode($moi['login']));
header('Location: admin.php?supprime=1');
exit;
}
$messages = connexion()
->query('SELECT id, nom, contenu, cree_le FROM message ORDER BY cree_le DESC')
->fetchAll();
require __DIR__ . '/../inc/header.php';
?>
<section class="carte">
<h2>Modération du livre d'or</h2>
<p>Connecté en tant que <strong><?= e($moi['login']) ?></strong> (<?= e($moi['role']) ?>).</p>
<?php if (isset($_GET['supprime'])): ?>
<p class="ok">Message supprimé.</p>
<?php endif; ?>
<?php foreach ($messages as $m): ?>
<article class="avis">
<p><strong><?= e($m['nom']) ?></strong> : <?= e($m['contenu']) ?></p>
<form method="post" action="admin.php">
<input type="hidden" name="csrf" value="<?= e(jetonCsrf()) ?>">
<input type="hidden" name="id" value="<?= (int) $m['id'] ?>">
<button type="submit">Supprimer</button>
</form>
</article>
<?php endforeach; ?>
</section>
<?php require __DIR__ . '/../inc/footer.php'; ?>
La suppression se fait en POST (jamais par un simple lien en GET, qu’un robot ou un aperçu de message pourrait suivre), avec le jeton CSRF, une requête préparée et une trace dans le journal : qui a supprimé quoi.
6.3 Une page réservée aux administrateurs
public/comptes.php
<?php
require __DIR__ . '/../inc/fonctions.php';
require __DIR__ . '/../inc/auth.php';
$moi = exigerConnexion('admin'); // réservé au rôle admin : un modérateur reçoit « Accès refusé »
$titre = 'Comptes';
$comptes = connexion()
->query('SELECT login, role, cree_le, (bloque_jusqua > NOW()) AS bloque FROM utilisateur ORDER BY login')
->fetchAll();
require __DIR__ . '/../inc/header.php';
?>
<section class="carte">
<h2>Comptes de modération</h2>
<table>
<tr><th>Identifiant</th><th>Rôle</th><th>Créé le</th><th>État</th></tr>
<?php foreach ($comptes as $c): ?>
<tr>
<td><?= e($c['login']) ?></td>
<td><?= e($c['role']) ?></td>
<td><?= e(date('d/m/Y', strtotime($c['cree_le']))) ?></td>
<td><?= $c['bloque'] ? 'Bloqué' : 'Actif' ?></td>
</tr>
<?php endforeach; ?>
</table>
</section>
<?php require __DIR__ . '/../inc/footer.php'; ?>
6.4 Le menu s’adapte
inc/header.php
<?php $connecte = utilisateurConnecte(); ?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($titre) ?> — Camille Martin</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<header>
<h1>Camille Martin</h1>
<p class="metier">Étudiante en BTS SIO</p>
<nav>
<a href="index.php">Accueil</a>
<a href="livre-or.php">Livre d'or</a>
<?php if ($connecte): ?>
<a href="admin.php">Modération</a>
<?php if ($connecte['role'] === 'admin'): ?>
<a href="comptes.php">Comptes</a>
<?php endif; ?>
<form method="post" action="deconnexion.php" class="inline">
<input type="hidden" name="csrf" value="<?= e(jetonCsrf()) ?>">
<button type="submit">Se déconnecter (<?= e($connecte['login']) ?>)</button>
</form>
<?php else: ?>
<a href="connexion.php">Connexion</a>
<?php endif; ?>
</nav>
</header>
<main>
7. La faille CSRF
7.1 L’attaque
Camille est connectée à l’espace de modération. Dans un autre onglet, elle visite un forum où un attaquant a déposé ce code :
<!-- Page piégée, sur un AUTRE site -->
<form method="post" action="https://carte.exemple.fr/admin.php" id="piege">
<input type="hidden" name="id" value="12">
</form>
<script>document.getElementById('piege').submit();</script>
Le formulaire part tout seul vers le site de Camille. Le navigateur y joint automatiquement son cookie de session : pour le serveur, c’est Camille qui demande la suppression du message 12. C’est une attaque CSRF (Cross-Site Request Forgery, falsification de requête intersite). L’attaquant ne voit rien de la réponse, mais l’action est faite : supprimer, modifier un mot de passe, effectuer un virement…
7.2 Les parades
<?php
// inc/fonctions.php (suite)
// ---------- Protection CSRF ----------
// Jeton secret propre à la session, à placer dans chaque formulaire sensible
function jetonCsrf(): string
{
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf'];
}
// Arrête tout si le jeton reçu ne correspond pas à celui de la session
function verifierCsrf(): void
{
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
http_response_code(403);
exit('Requête refusée.');
}
}
- Chaque formulaire sensible contient un champ caché avec un jeton aléatoire, propre à la session. Le site de l’attaquant ne peut pas le connaître, et sa requête est refusée (code 403).
hash_equals()compare les deux chaînes en un temps constant : une comparaison classique (===) s’arrête au premier caractère différent, ce qui laisserait mesurer, caractère par caractère, si l’on approche du bon jeton.- Le réglage
SameSite=Laxdu cookie (§ 2.2) bloque déjà ce scénario dans les navigateurs récents. Le jeton reste indispensable : c’est la défense en profondeur, deux protections indépendantes, au cas où l’une ferait défaut.
8. Déconnexion et expiration
public/deconnexion.php
<?php
require __DIR__ . '/../inc/fonctions.php';
require __DIR__ . '/../inc/auth.php';
// La déconnexion modifie l'état : elle se fait en POST, avec le jeton CSRF
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit('Méthode non autorisée.');
}
demarrerSession();
verifierCsrf();
fermerSession();
header('Location: index.php');
exit;
- La déconnexion passe par un formulaire POST avec jeton (le bouton du menu), pour qu’un autre site ne puisse pas déconnecter l’utilisateur à son insu.
fermerSession()(§ 6.1) vide$_SESSION, supprime le fichier de session sur le serveur et efface le cookie dans le navigateur. Se contenter de supprimer le cookie laisserait la session valide sur le serveur : un identifiant volé fonctionnerait encore.- Après 30 minutes d’inactivité,
exigerConnexion()ferme la session et renvoie versconnexion.php?expire=1. Une session oubliée sur un poste du CDI ne reste pas ouverte indéfiniment.
9. Vérifier soi-même : la recette de sécurité
Une fonction de sécurité qui n’a pas été testée ne protège rien. Voici les tests à mener, avec le résultat attendu. Ils ont tous été réalisés sur le code de ce cours, avec un navigateur et avec curl (voir Le recettage informatique).
| N° | Test | Résultat attendu |
|---|---|---|
| 1 | Ouvrir admin.php sans être connecté | Redirection vers connexion.php |
| 2 | Visiter l’accueil sans être connecté (F12 → Stockage → Cookies) | Aucun cookie |
| 3 | Se connecter avec un mauvais mot de passe, puis avec un identifiant inconnu | Le même message dans les deux cas |
| 4 | Comparer les cookies avant et après une connexion réussie | L’identifiant de session a changé |
| 5 | Taper 5 mauvais mots de passe, puis le bon | Toujours refusé pendant 15 minutes ; les 5 échecs figurent dans le journal |
| 6 | Supprimer le champ csrf du formulaire avec F12, puis l’envoyer | « Requête refusée. » (code 403) |
| 7 | Se connecter en modérateur et taper l’adresse comptes.php | « Accès refusé. » (code 403) |
| 8 | Ouvrir deconnexion.php directement dans la barre d’adresse | « Méthode non autorisée. » (code 405) |
| 9 | Se déconnecter, puis revenir sur admin.php avec la flèche « Précédent » et actualiser | Redirection vers connexion.php |
| 10 | Dans la console (F12), taper document.cookie une fois connecté | Le cookie carte_session n’apparaît pas (HttpOnly) |
10. Synthèse cybersécurité
| Attaque | Principe | Parade dans ce cours |
|---|---|---|
| Vol de la base de données | Lire les mots de passe dans une table volée | password_hash() : empreinte lente et salée |
| Force brute | Tester un grand nombre de mots de passe | Blocage temporaire, longueur minimale, journalisation, MFA |
| Énumération des comptes | Découvrir quels identifiants existent | Message unique, temps de réponse identique |
| Vol de session | Utiliser l’identifiant de session d’un autre | HttpOnly, Secure, HTTPS, expiration |
| Fixation de session | Imposer un identifiant connu avant la connexion | session_regenerate_id(true), use_strict_mode |
| CSRF | Faire envoyer une requête par le navigateur de la victime | Jeton CSRF, SameSite=Lax, actions en POST |
| Contrôle d’accès défaillant | Accéder à une page en tapant son adresse | exigerConnexion('role') sur chaque page réservée |
| Injection dans les journaux | Fabriquer de fausses lignes de journal | json_encode() des données journalisées |
| Compte de base trop puissant | Exploiter une faille pour tout modifier | Droits colonne par colonne, pas de création de compte par le site |
11. À retenir
🧠 Testez vos connaissances
8 questions. Répondez puis cliquez sur « Corriger ». Rien n’est enregistré : c’est de l’auto-évaluation.
© 2026 Bruno Jousseaume. Ce cours est publié sous licence CC BY-NC-SA 4.0 : vous pouvez le partager et l’adapter en citant l’auteur et un lien vers cette page, sans usage commercial, et en partageant vos adaptations sous la même licence.