Tutoriels gratuits et ouverts à toutes et à tous · mis à jour régulièrementNouveautésGlossaireÀ propos

Série « Parcours PHP » · partie 2 / 2

PHP 2 : authentification, sessions et mots de passe

Suite du cours PHP : un espace de modération protégé par mot de passe. Sessions et cookies, stockage des mots de passe avec password_hash, page de connexion, rôles, jeton CSRF, limitation des tentatives, déconnexion et expiration, avec les attaques que chaque mesure empêche.

Développement✎ B. Jousseaume📅 22 octobre 2026⏱ 6 h📶 Intermédiaire

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

NotionQuestion poséeDans notre siteAu guichet d’un aéroport
IdentificationQui prétendez-vous être ?Saisir l’identifiant camilleDonner son nom
AuthentificationPouvez-vous le prouver ?Saisir le bon mot de passeMontrer sa carte d’identité
AutorisationQu’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 :

  1. À la première visite, PHP crée un identifiant aléatoire (le numéro du ticket) et un fichier sur le serveur (le portemanteau).
  2. Il envoie l’identifiant au navigateur dans un cookie.
  3. Le navigateur renvoie automatiquement ce cookie à chaque requête vers le site.
  4. 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.

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églageEffetAttaque limitée
httponlyLe cookie est illisible pour JavaScript (document.cookie)Vol de session par une faille XSS
secureLe cookie n’est envoyé qu’en HTTPSVol de session par écoute du réseau
samesite => 'Lax'Le cookie n’est pas joint aux formulaires POST venus d’autres sitesCSRF (§ 7)
lifetime => 0Le cookie disparaît à la fermeture du navigateurSession oubliée sur un poste partagé
use_strict_modePHP refuse un identifiant qu’il n’a pas créé lui-mêmeFixation 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éthodeCe que voit l’attaquant qui vole la tableVerdict
Mot de passe en clairTous les mots de passe, immédiatementInacceptable
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 serveurInadapté
Empreinte rapide : md5(), sha1(), sha256ab4f63f9ac65152575886860dde480a1 : retrouvé en un instant (« azerty »), car ces fonctions se calculent à raison de milliards par seconde sur une carte graphiqueInadapté
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 lentementLa 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
PartieSignification
$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 suivantsLe sel : une valeur aléatoire, différente pour chaque empreinte
Le resteL’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’un VARCHAR(255), ce qui laisse de la place si PASSWORD_DEFAULT change 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, echecs et bloque_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 INSERT sur 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.

ProtectionLigne(s) concernée(s)Attaque empêchée
Requête préparéeprepare(… 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 identiquepassword_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 échecsColonnes echecs et bloque_jusquaForce 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 échecserror_log('[auth] échec…')Rend visibles les attaques, pour les détecter et y réagir
Jeton CSRFverifierCsrf()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=Lax du 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 vers connexion.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°TestRésultat attendu
1Ouvrir admin.php sans être connectéRedirection vers connexion.php
2Visiter l’accueil sans être connecté (F12 → Stockage → Cookies)Aucun cookie
3Se connecter avec un mauvais mot de passe, puis avec un identifiant inconnuLe même message dans les deux cas
4Comparer les cookies avant et après une connexion réussieL’identifiant de session a changé
5Taper 5 mauvais mots de passe, puis le bonToujours refusé pendant 15 minutes ; les 5 échecs figurent dans le journal
6Supprimer le champ csrf du formulaire avec F12, puis l’envoyer« Requête refusée. » (code 403)
7Se connecter en modérateur et taper l’adresse comptes.php« Accès refusé. » (code 403)
8Ouvrir deconnexion.php directement dans la barre d’adresse« Méthode non autorisée. » (code 405)
9Se déconnecter, puis revenir sur admin.php avec la flèche « Précédent » et actualiserRedirection vers connexion.php
10Dans la console (F12), taper document.cookie une fois connectéLe cookie carte_session n’apparaît pas (HttpOnly)

10. Synthèse cybersécurité

AttaquePrincipeParade dans ce cours
Vol de la base de donnéesLire les mots de passe dans une table voléepassword_hash() : empreinte lente et salée
Force bruteTester un grand nombre de mots de passeBlocage temporaire, longueur minimale, journalisation, MFA
Énumération des comptesDécouvrir quels identifiants existentMessage unique, temps de réponse identique
Vol de sessionUtiliser l’identifiant de session d’un autreHttpOnly, Secure, HTTPS, expiration
Fixation de sessionImposer un identifiant connu avant la connexionsession_regenerate_id(true), use_strict_mode
CSRFFaire envoyer une requête par le navigateur de la victimeJeton CSRF, SameSite=Lax, actions en POST
Contrôle d’accès défaillantAccéder à une page en tapant son adresseexigerConnexion('role') sur chaque page réservée
Injection dans les journauxFabriquer de fausses lignes de journaljson_encode() des données journalisées
Compte de base trop puissantExploiter une faille pour tout modifierDroits 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.

1. Une personne saisit son identifiant et son mot de passe, puis accède à la page « Comptes » réservée aux administrateurs. Quelle étape vérifie qu’elle a le droit d’accéder à cette page ?

💬 L’identification dit qui l’on prétend être, l’authentification le prouve, l’autorisation décide de ce que l’on a le droit de faire.

2. Que contient le cookie de session envoyé au navigateur ?

💬 Le cookie n’est qu’un « ticket de vestiaire ». Mais celui qui le vole prend la place de l’utilisateur : il se protège comme un mot de passe.

3. Pourquoi ne faut-il pas stocker les mots de passe avec md5() ou sha256 ?

💬 password_hash() utilise un algorithme volontairement lent (bcrypt) et un sel aléatoire, ce qui rend les attaques par dictionnaire très coûteuses.

4. Deux utilisateurs ont le même mot de passe. Leurs empreintes password_hash() sont-elles identiques ?

💬 Le sel change à chaque appel : on ne peut ni repérer les mots de passe identiques ni utiliser des tables précalculées.

5. Pourquoi la page de connexion affiche-t-elle le même message pour un identifiant inconnu et pour un mauvais mot de passe ?

💬 Un message différent permettrait de dresser la liste des comptes existants, puis de concentrer les attaques sur eux.

6. À quoi sert session_regenerate_id(true) juste après une connexion réussie ?

💬 Sans ce changement, un attaquant qui a imposé son identifiant de session à la victime se retrouverait connecté avec elle.

7. Une attaque CSRF consiste à…

💬 Le navigateur joint automatiquement le cookie de session. Le jeton CSRF, que l’autre site ne peut pas connaître, permet au serveur de refuser ces requêtes.

8. Le lien « Comptes » est masqué dans le menu des modérateurs. Est-ce suffisant pour protéger la page ?

💬 Masquer un lien est du confort. La protection, c’est exigerConnexion('admin') en tête de la page, côté serveur.

© 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.

Pour aller plus loin

Introduction au Web : HTML, CSS et JavaScript

Comment une page web arrive sur votre écran (client, DNS, serveur, HTTP), où sont stockés et qui exécute le HTML, le CSS et le JavaScript, puis une première page complète à construire soi-même, avec les réflexes de cybersécurité.

Débutant⏱ 4 h · 22 octobre 2026