Créa-blog

#100JoursPourCoder
Projet Créa-code

Ressources pour développeur web

Jour 16 : Formulaire de connexion sécurisé en PHP et MVC

Temps de lecture : 13 minutes
Accueil Projets Jour 16 : Formulaire de connexion sécurisé en PHP et MVC

Depuis le début de notre aventure #100JoursPourCoder, nous avons appris à créer les bases solides d’un site dynamique : structure MVC en PHP, base de données relationnelle, système d’inscription… Aujourd’hui, nous allons franchir une étape essentielle dans la gestion des utilisateurs : le formulaire de connexion sécurisé.

Pourquoi est-ce si important ? Parce que c’est souvent à cette étape que se produisent les erreurs les plus critiques : failles de sécurité, mots de passe mal protégés, sessions mal gérées… Nous allons donc prendre notre temps, et coder ensemble une connexion fiable, étape par étape pour notre site Créa-code.

L’objectif du jour est de mettre en place le formulaire HTML de connexion, de valider les données en JS et en PHP, de vérifier l’existence de l’utilisateur dans la base de données, et enfin de démarrer une session sécurisée si les informations sont valides.

1. Optimisation de la vue login.php dans notre architecture MVC

Comme d’habitude, dans notre MVC en PHP, chaque vue HTML est un fichier .php qui appartient au dossier app/Views/.

Nous allons coder un fichier login.php contenant un formulaire très simple. Pour l’instant, nous ne faisons aucune mise en page ni CSS, nous voulons juste que le HTML fonctionne.

Voici le contenu de notre fichier app/Views/login.php :

<h1>Connexion</h1>

<?php if (!empty($errors)) : ?>
    <div style="color:red;">
        <?php foreach ($errors as $error) : ?>
            <p><?= htmlspecialchars($error) ?></p>
        <?php endforeach; ?>
    </div>
<?php endif; ?>

<form id="login-form" action="<?= BASE_URL ?>login/post" method="post">
    <label for="email">Email :</label><br>
    <input type="email" name="email" id="email" required>
    <div id="email-error" class="error-message"></div>

    <label for="password">Mot de passe :</label><br>
    <input type="password" name="password" id="password" required>
    <div id="password-error" class="error-message"></div>

    <button type="submit">Se connecter</button>
</form>

<p>Pas encore inscrit ? <a href="<?= BASE_URL ?>signin">Créer un compte</a></p>

Prenons un moment pour expliquer :

  • Le formulaire utilise la méthode POST pour ne pas afficher les données dans l’URL.
  • Il envoie les données à l’adresse /login/post.
  • Nous avons deux champs obligatoires : email et password.
  • Un petit bloc permet d’afficher les erreurs si besoin, via une variable $errors transmise par le contrôleur.

À la fin de ce fichier app/Views/login.php, nous allons ajouter une balise script pour relier un script JS de vérification des champs.

<script src="<?= BASE_URL ?>assets/js/login-form-validation.js" defer></script>

Ce script JS, nous allons le placer dans le dossier public/assets/js/login-form-validation.js. Il n’apporte qu’une très légère couche de sécurité : la validation en PHP restera la plus importante. Son objectif est principalement d’optimiser l’UX, l’expérience utilisateur.

document.addEventListener('DOMContentLoaded', function () {
    const form = document.querySelector('#login-form');
    const emailInput = document.querySelector('#email');
    const passwordInput = document.querySelector('#password');

    const emailError = document.querySelector('#email-error');
    const passwordError = document.querySelector('#password-error');

    form.addEventListener('submit', function (e) {
        let isValid = true;

        // Réinitialise les messages d'erreur
        emailError.textContent = '';
        passwordError.textContent = '';

        const emailValue = emailInput.value.trim();
        const passwordValue = passwordInput.value;

        // Vérification de l'email
        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
        if (emailValue === '') {
            emailError.textContent = 'Veuillez entrer votre adresse e-mail.';
            isValid = false;
        } else if (!emailRegex.test(emailValue)) {
            emailError.textContent = 'Adresse e-mail invalide.';
            isValid = false;
        }

        // Vérification du mot de passe
        const passwordRegex = /^(?=.*[A-Z])(?=.*\d).{8,}$/;
        if (passwordValue === '') {
            passwordError.textContent = 'Veuillez entrer votre mot de passe.';
            isValid = false;
        } else if (!passwordRegex.test(passwordValue)) {
            passwordError.textContent = 'Le mot de passe doit contenir au moins 8 caractères, une majuscule et un chiffre.';
            isValid = false;
        }

        if (!isValid) {
            e.preventDefault(); // Empêche l'envoi du formulaire si une erreur est présente
        }
    });
});

2. Ajout de la route et création de la méthode login dans le contrôleur

Passons à notre routeur. Nous avons besoin de deux routes :

  • Une route GET pour afficher le formulaire (/login)
  • Une route POST pour traiter le formulaire (/login/post)

Dans app/Route/Router.php, ajoutons ceci :

// Existait déjà
$router->get('/login', 'LoginController@index');
// À ajouter
$router->post('/login/post', 'LoginController@login');

Cela signifie que :

  • Si l’URL est /login, on appelle la méthode index du LoginController.
  • Si l’URL est /login/post et la méthode HTTP est POST, on appelle la méthode login.

Optimisons et créons maintenant ces deux méthodes dans le contrôleur.

Pour commencer, voici le fichier app/Controllers/LoginController.php :

<?php
namespace App\Controllers;

use App\Core\Controller;
use App\Core\View;
use App\Models\User;
use App\Core\Database;


class LoginController extends Controller
{
    public function index(): void
    {

        if (isset($_SESSION['user'])) {
            header('Location: '.BASE_URL.'dashboard');
            exit;
        }

        $title = "Login - Créa-code";
        $desc = "Login - Créa-code";
        $errors = $_SESSION['login_errors'] ?? [];
        unset($_SESSION['login_errors']);
        
        View::render('login', compact('title', 'desc', 'errors'));

    }
}

Cette méthode index se contente d’afficher la vue login.php sans erreur au départ.

Maintenant, codons la méthode de traitement, la méthode login. Celle-ci est un peu plus longue, car elle doit faire plusieurs choses importantes :

  • Vérifier si les champs sont remplis
  • Chercher l’utilisateur dans la base
  • Vérifier le mot de passe
  • Démarrer une session
  • Rediriger ou afficher une erreur

Nous allons tout détailler ensemble.

3. Traitement du formulaire et validation des champs

Toujours dans LoginController, nous ajoutons la méthode login() :

public function login()
{

    if (isset($_SESSION['user'])) {
        header('Location: '.BASE_URL.'dashboard');
        exit;
    }
    
    $errors = [];

    // Vérifier si le formulaire est bien soumis
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $email = trim($_POST['email'] ?? '');
        $password = trim($_POST['password'] ?? '');

        // Vérification des champs requis
        if (empty($email) || empty($password)) {
            $errors[] = "Tous les champs sont obligatoires.";
        }

        // Vérification du format de l'email
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            $errors[] = "L'adresse email est invalide.";
        }

        // Si aucune erreur, on passe à la suite
        if (empty($errors)) {
            $userModel = new User(Database::getInstance());
            $user = $userModel->findByEmail($email);

            if ($user && password_verify($password, $user['password'])) {
                // Connexion réussie
                $_SESSION['user'] = [
                    'id' => $user['id'],
                    'email' => $user['email'],
                    'pseudo' => $user['pseudo'],
                    'role' => $user['role']
                ];
                header('Location: '.BASE_URL.'dashboard');
                exit;
            } else {
                $errors[] = "Identifiants incorrects.";
            }
        }
    }

    if (!empty($errors)) {
        $_SESSION['login_errors'] = $errors;
        header('Location: ' . BASE_URL . 'login');
        exit;
    }

}

Prenons le temps de tout expliquer.

Vérification de la méthode POST

Nous commençons par vérifier que la méthode est bien POST. Cela garantit que le traitement n’a lieu que lorsque le formulaire est réellement soumis.

Nettoyage et récupération des données

Nous récupérons les valeurs du formulaire avec $_POST['email'] et $_POST['password']. On utilise trim() pour enlever les espaces inutiles.

Validation

Nous vérifions que les deux champs sont remplis, puis que l’email a un format valide grâce à filter_var().

Si tout est bon, nous passons à la requête SQL.

Poursuivons maintenant avec la partie modèle (Model), puis nous détaillerons la gestion sécurisée de la session, et terminerons par les tests et la sécurisation.

4. Ajout de la méthode findByEmail() dans le modèle User

Nous devons maintenant chercher l’utilisateur dans la base de données, à partir de son adresse e-mail. Pour cela, nous allons ajouter une méthode findByEmail() dans le modèle User, situé dans app/Models/User.php.

Voici le code complet :

public function findByEmail(string $email): ?array
{
    $sql = "SELECT * FROM users WHERE email = :email LIMIT 1";
    $stmt = $this->db->prepare($sql);
    $stmt->bindValue(':email', $email, PDO::PARAM_STR);
    $stmt->execute();

    $user = $stmt->fetch(PDO::FETCH_ASSOC);

    return $user ?: null;
}

Prenons bien le temps de tout expliquer :

  • Nous écrivons une requête SQL préparée pour éviter les injections : SELECT * FROM users WHERE email = :email LIMIT 1
  • On prépare cette requête avec $this->db->prepare()
  • On lie la valeur de l’e-mail à :email en utilisant bindValue(), ce qui est plus sûr que d’insérer directement la variable
  • On exécute la requête avec $stmt->execute()
  • Enfin, on récupère l’utilisateur avec fetch() et on retourne le tableau s’il existe, ou null sinon

🔒 Important : grâce aux requêtes préparées, notre code est protégé contre les injections SQL.

Cette méthode est utilisée dans notre LoginController, souvenez-vous :

$user = $userModel->findByEmail($email);

5. Vérification du mot de passe avec password_verify

Une fois que nous avons récupéré les données de l’utilisateur, nous devons comparer le mot de passe saisi par l’utilisateur avec celui stocké en base.

Mais attention : le mot de passe en base est chiffré (haché) grâce à la fonction password_hash() (utilisée lors de l’inscription). Nous ne pouvons donc pas faire une simple égalité comme $password === $user['password'].

À la place, nous utilisons la fonction PHP :

Formation web et informatique - Alban Guillier - Formateur

Des formations informatique pour tous !

Débutant ou curieux ? Apprenez le développement web, le référencement, le webmarketing, la bureautique, à maîtriser vos appareils Apple et bien plus encore…

Formateur indépendant, professionnel du web depuis 2006, je vous accompagne pas à pas et en cours particulier, que vous soyez débutant ou que vous souhaitiez progresser. En visio, à votre rythme, et toujours avec pédagogie.

Découvrez mes formations Qui suis-je ?
password_verify($password, $user['password'])

Cette fonction compare automatiquement la version non hachée (entrée dans le formulaire) avec la version hachée (en base). Elle est ultra-fiable et sécurisée.

Si elle retourne true, l’utilisateur est bien celui qu’il prétend être.

6. Démarrage sécurisé de la session utilisateur

Quand le mot de passe est bon, nous devons connecter l’utilisateur en créant une session. En PHP, cela se fait avec la variable spéciale $_SESSION.

Mais attention, il faut toujours s’assurer que la session est démarrée. Normalement, dans notre projet code.crea-troyes.fr, nous avons dans le fichier index.php comme point d’entrée et qui contient cette ligne :

session_start();

Elle doit être exécutée avant tout envoi de contenu HTML.

Ensuite, dans le contrôleur, après la vérification, nous écrivons :

$_SESSION['user'] = [
    'id' => $user['id'],
    'email' => $user['email'],
    'pseudo' => $user['pseudo'],
    'role' => $user['role']
];

Nous évitons volontairement d’y mettre le mot de passe. On stocke juste les informations utiles pour l’identification dans les autres pages.

Une fois la session définie, nous redirigeons l’utilisateur vers le tableau de bord :

header('Location: '.BASE_URL.'dashboard');exit;

À noter : exit est important après une redirection. Il empêche le reste du code de s’exécuter par erreur.

7. Gestion des erreurs et retour au formulaire

Si l’utilisateur n’est pas trouvé, ou si le mot de passe est incorrect, nous affichons une erreur :

$errors[] = "Identifiants incorrects.";

Puis nous renvoyons à la vue login.

Et dans login.php, nous avons prévu l’affichage de chaque erreur avec une simple boucle :

<?php if (!empty($errors)) : ?>
    <div>
        <?php foreach ($errors as $error) : ?>
            <p><?= htmlspecialchars($error) ?></p>
        <?php endforeach; ?>
    </div>
<?php endif; ?>

Ainsi, l’utilisateur comprend immédiatement ce qui ne va pas.

8. Optimisation de l’erreur 404

Nous allons modifier la méthode render404() pour qu’elle redirige vers /error404, ce qui permettra de changer l’URL dans la barre d’adresse, de déclencher proprement le contrôleur d’erreur (ErrorController) et surtout d’éviter de rester sur une URL invalide.

Pour commencer, on ajoute une vraie route /error404 dans le fichier app/Route/Routes.php :

// Erreur 404
$router->get('/error404', 'ErrorController@notFound');

Ensuite, nous ajoutons un nouveau contrôleur, le fichier app/Controllers/ErrorController.php :

<?php

namespace App\Controllers;

use App\Core\Controller;
use App\Core\View;

class ErrorController extends Controller
{
    public function notFound()
    {
        http_response_code(404);
        $title = "Erreur 404 - Page introuvable";
        $desc = "La page demandée n'existe pas ou plus sur Créa-code.";
        View::render('error', compact('title', 'desc'));
    }
}

Et enfin, on modifie la méthode render404() dans le fichier app/Route/Router.php. On remplace l’ancienne méthode render404() par celle-ci :

protected function render404()
{
    header('Location: '.BASE_URL.'error404');
    exit;
}

exit est important ici pour stopper l’exécution après la redirection.

Quand une URL inconnue est saisie comme /login/oops, /dashboard/xyz ou /toto, elle sera redirigée automatiquement vers :

/public/error404

Et cette URL affichera la vue error, code HTTP 404 inclus.

9. Sécurisation et bonnes pratiques

Notre formulaire fonctionne, mais il y a encore quelques points à améliorer pour une sécurité optimale.

Éviter les failles XSS

Même si l’utilisateur ne peut pas saisir de HTML dans un champ comme le mot de passe, il vaut mieux être prudent.

C’est pourquoi, dans la vue, nous utilisons :

<?= htmlspecialchars($error) ?>

Cela empêche un éventuel script malveillant d’être affiché comme du code HTML.

Ajouter un token CSRF (facultatif à ce stade)

Pour empêcher les attaques de type CSRF (Cross Site Request Forgery), il est possible de générer un token dans la session, puis de l’inclure dans le formulaire et de le vérifier côté PHP.

Ce n’est pas obligatoire pour une première version, mais c’est un bon objectif pour la suite.

Protéger l’accès au tableau de bord

Nous devons aussi empêcher les visiteurs non connectés d’accéder à certaines routes, comme /dashboard.

Dans le contrôleur du dashboard, nous pourrons faire une vérification simple :

if (!isset($_SESSION['user'])) {
    header('Location: '.BASE_URL.'signin');
    exit;
}

Aujourd’hui, nous avons appris à :

  • Créer un formulaire de connexion simple
  • Ajouter la route, la vue et le traitement dans notre architecture MVC
  • Valider les champs et vérifier les identifiants en base
  • Utiliser password_verify() pour une comparaison sécurisée
  • Lancer une session PHP
  • Gérer proprement les erreurs

Ce formulaire est maintenant totalement fonctionnel, même sans CSS. Il est clair, sécurisé, et prêt à évoluer avec nous. À demain pour la suite …

Live on Twitch