Identification persistante avec PHP & MySQL

Allergy

 

Introduction

Lorsque j'ai voulu mettre en place un système de login persistants sur alrj.org, j'ai cherché des explications sur tous les sites PHP que je connaissais, sans jamais trouver d'exemple ou d'explications correspondant à mon désir.
Il me fallait un système ayant les caractéristiques suivantes :
Le système devait en outre être "relativement" sécurisé pour éviter les abus, et devait également être assez flexible pour permettre l'ajout de fonctionnalités, telles qu'une page d'administration du site, accessible seulement à quelques heureux élus, sans devoir modifier le code de fond en comble.

J'ai fini par trouver un source qui correspondait presque parfaitement à ce que je cherchais. Je l'ai un peu modifié, et je me propose ici de vous l'expliquer. J'espère que l'auteur dudit source ne m'en tiendra pas rigueur. Malheureusement, après avoir enregistré ce source, j'ai oublié de mettre la page en favoris, ce qui fait que j'en ai perdu l'adresse, et il m'a été impossible, jusqu'à présent, de la retrouver.

Pour tout ceci, je n'ai pas utilisé de programmation orientée objet, même si le sujet s'y prêtait à merveille. Le code est cependant très facilement adaptable.

Pour tout ce qui va suivre, se considère que la connexion à la base de donnnée comprenant notre table est déjà ouverte.
 

La table SQL

La table MySQL utilisée est très simple. Libre à vous d'ajouter les champs concernant des informations supplémentaires sur les utilisateurs :
 

CREATE TABLE Users (
   ID bigint(20) unsigned NOT NULL auto_increment,
   Nick varchar(40) NOT NULL,
   Password varchar(50) NOT NULL,
   Email varchar(50) NULL,
   ...
   PRIMARY KEY (ID)
};


 

Le fonctionnement

Comme vous vous en doutez, nous allons stocker des informations dans les cookies de l'utilisateur. Bien sûr pas directement le login et le mot de passe. Ce serait trop simple. Nous retiendrons le nick de l'utilisateur, ainsi qu'un certain checksum, qui sera détaillé plus tard.

Commençons par déclarer la variable qui nous servira pour le checksum :


<?
    $hidden_hash_var='Cette chaine de caractères servira à notre hash et je vous défie de la trouver';


Je vous conseille de choisir une phrase de hash qui soit assez longue, et qui peut être n'importe quoi. Celle que j'utilise est par exemple.... héhé, ben non, je vous le dirai pas :)

Ensuite, par mesure de sécurité, nous allons retirer toute possibilité à une personne malveillante de tenter de se logger sans en avoir le droit :


    $LOGGED_IN=false;
    $G_USER_RESULT=false;
    unset($LOGGED_IN);


Avec ça, nous évitons que la variable $LOGGED_IN soit déclarée via l'URL, ou envoyée par un formulaire bidon. Nous verrons le cas de $G_USER_RESULT par la suite.

Une fois ces précautions élémentaires prises, voyons quelles sont les fonctions que notre module doit proposer.
 

Enregistrement d'un utilisateur

Commençons par le commencement, il faut que les utilisateurs puissent s'enregistrer.
Pour cela, il nous faut le nick désiré, le password et sa confirmation, et l'adresse mail facultative.

    function user_register($user_name, $password1, $password2, $email)
    {
        global $feedback, $hidden_hash_var;

        if ($user_name && $password1 && ($password1==$password2)) {
            // Le nom existe déjà ?
            $sql="SELECT * FROM Users WHERE Nick='$user_name'";
            $result=mysql_query($sql);
            if ($result && mysql_num_rows($result) > 0) {
                $feedback .= "ERREUR - Le nick existe déjà.\n";
                return false;
            } else {
                $sql = "INSERT INTO Users (Nick, Password, Email) ".
                        "VALUES ('$user_name', password('$password1'), '$email')";
                $result = mysql_query($sql);
                if (!result) {
                    $feedback .= "ERREUR - (DB) : ".mysql_error() .".\n";
                    return false;
                } else {
                    $feedback .= "Vous êtes enregistré.\n<br>";
                    return true;
                }
            }
        } else {
            $feedback .= "ERREUR - Vous devez entrer votre nick et deux fois votre password.\n";
            return false;
        }
    }


La première chose que nous faisons est de vérifier la présence des champs obligatoires (nick et password) ainsi que la correspondance des deux passwords entrés.
On s'assure ensuite que personne ne s'est enregistré avec ce nom.
Si toutes ces conditions sont remplies, on ajoute l'utilisateur dans la base de données. Le password est stockée de manière cryptée, grace à la fonction password() de MySQL.

La variable globale $feedback sera utilisée dans chaque fonction et permet de retourner une chaine de caractères.
 

Login

Une fois que notre utilisateur est dans la base SQL, il faut lui laisser la possibilité de se logger, sinon, notre travail n'aura pas servi a grand chose.

    function user_login($user_name, $password)
    {
        global $feedback;
        if (!$user_name || !$password) {
            $feedback .= " ERREUR - Nick ou password manquant.\n";
            return false;
        } else {
            $sql = "SELECT * FROM Users WHERE Nick='$user_name' AND Password=password('$password')";
            $result = mysql_query($sql);
            if (!$result || mysql_num_rows($result) < 1) {
                $feedback .= " ERREUR - Nick ou password incorrect.\n";
                return false;
            } else {
                user_set_tokens($user_name);
                return true;
            }
        }
    }


Après avoir vérifié que les nick et password étaient bien passés à la fonction, on va vérifier s'il existe bien un utilisateur qui correspond. Pour cela, on demande TOUS les utilisateurs dont le nick et le password correspondent, et on vérifie si on en a au moins un. Comme deux utilisateurs ne peuvent pas avoir le même nick, ca roule !

En cas de réussite, on a cet appel à une fonction étrange : user_set_tokens().
Cette fonction (que voici) ...


    function user_set_tokens($user_name_in)
    {
        global $hidden_hash_var, $user_name, $id_hash;

        if (!$user_name_in) {
            return false;
        }

        $id_hash = md5($user_name_in.$hidden_hash_var);

        setcookie('user_name',$user_name_in,(time()+2592000),'/','',0);
        setcookie('id_hash',$id_hash,(time()+2592000),'/','',0);
    }


... sert à envoyer les cookies au navigateur de l'utilisateur.
Le premier cookie est simplement son nick. Le second est plus compliqué. Nous appliquons une somme de contôle md5 sur la concaténation du nick et ... de notre variable de hash cachée !
Voilà donc à quoi elle allait servir !

Comme vous le voyez, les cookies ont une durée de vie de 30 jours (2592000 secondes). Libre à vous d'adapter cette valeur, voire même de l'omettre si vous préférez que l'identification ne dure que le temps où le navigateur reste ouvert.

Pour ceux qui se poseraient la question, la somme md5 d'une phrase ne permet pas de retrouver la phrase d'origine.
Il est donc impossible de créer un faux id_hash, puisqu'un éventuel "pirate" ne connait pas notre variable de hash cachée, mais il est également impossible, par la nature même du md5, de faire le chemin inverse pour retrouver cette variable cachée en partant de $id_hash.
 

L'utilisateur est-il loggé ?

Tout ceci ne nous sert à rien si à chaque page, nous devons demander son mot de passe à l'utilisateur. Il nous faut donc également une fonction qui vérifie la présence éventuelle des cookies qui nous intéressent.
C'est en se basant sur le résultat de cette fonction qu'une page PHP déterminera si elle doit proposer à l'utilisateur de se logger ou de s'inscrire, ou à l'inverse, proposer un lien vers une page permettant de changer ses préférences.

De plus, cette fonction devra être appelée au début de chaque page qui requiert un utilisateur enregistré (comme la page d'édition de préférences), pour éviter les abus.


    function user_isloggedin()
    {
        global $user_name, $id_hash, $hidden_hash_var, $LOGGED_IN;

        if (isset($LOGGED_IN)) {
            return $LOGGED_IN;
        }

        if ($user_name && $id_hash) {
            $hash = md5($user_name.$hidden_hash_var);
            if ($hash == $id_hash) {
                $LOGGED_IN=true;
                return true;
            } else {
                $LOGGED_IN = false;
                return false;
            }
        } else {
            $LOGGED_IN = false;
            return false;
        }
    }


(Remarquez au passage qu'ici, nous n'avons plus aucun appel à la table SQL, ce qui évite de trop utiliser de resources sur le seveur).

Si vous êtes observateur, peut-être aurez-vous apperçu que cette fonction ne prend aucun argument. Elle ne sert pas à vérifier si tel ou tel utilisateur est loggé, mais bien si l'utilisateur qui demande la page est loggé.

La première chose que nous faisons est de vérifier l'état de la variable $LOGGED_IN. Même si celle-ci est "effacée" au début du script, elle peut très bien avoir été recréée a un endroit quelconque dans la page, lors d'un appel précédent à la fonction.
Si elle n'existe pas, il nous faut vérifier l'existance des deux cookies. Si ceux-ci sont absent, vous pouvez être certain que l'utilisateur n'est pas loggé. S'ils existent, nous recréons la somme md5 sensée se trouver dans le cookie $id_hash et nous comparons leurs valeurs respectives (rappelez-vous, il n'y a aucun moyen de retrouver le texte original depuis son md5, tout ce que nous pouvons faire est de calculer un autre md5 et de le comparer avec l'ancien).
 

Autres fonctions intéressantes

Une fonction qui sera bien pratique est sans conteste celle qui retourne le nick de l'utilisateur :

    function user_getnick() {
        if (user_isloggedin()) {
            return $GLOBALS['user_name'];
        } else {
            return ' ERROR - Not Logged In ';
        }
    }


D'autres, qui ont le mérite d'apporter une subtilité supplémentaire, sont celles qui retournent l'adresse mail (que nous prendrons comme exemple), et autres informations sur l'utilisateur.

    function user_getemail() {
        global $G_USER_RESULT;
        //On vérifie si on a déjà récupéré les infos de l'utilisateur, si non, on le fait
        if (!$G_USER_RESULT) {
            $G_USER_RESULT=mysql_query("SELECT * FROM Users WHERE Nick='" . user_getnick() . "'");
        }
        if ($G_USER_RESULT && mysql_num_rows($G_USER_RESULT) > 0) {
            return mysql_result($G_USER_RESULT,0,'email');
        } else {
            return false;
        }
    }


Ici, la variable globale $G_USER_RESULT contient toute la ligne (au sens SQL du terme) des infos de l'utilisateur. Il suffit de mettre ce test (le premier 'if') dans chaque fonction du genre pour, une fois de plus, éviter les requêtes inutiles : la requête n'est effectuée qu'une seule fois. Pour le reste de la page, c'est la variable globale qui est utilisée.

Voici quelques autres fonctions que je ne commenterai pas. Si vous êtes arrivé jusqu'ici, c'est que vous connaissez suffisemment le PHP pour comprendre la suite tout seul comme un grand :-)


    function user_getid() {
        global $G_USER_RESULT;
        //On vérifie si on a déjà récupéré les infos de l'utilisateur, si non, on le fait
        if (!$G_USER_RESULT) {
            $G_USER_RESULT=mysql_query("SELECT * FROM Users WHERE Nick='" . user_getnick() . "'");
        }
        if ($G_USER_RESULT && mysql_num_rows($G_USER_RESULT) > 0) {
            return mysql_result($G_USER_RESULT,0,'ID');
        } else {
            return false;
        }
    }



    function user_change_email($password1, $new_email, $user_name)
    {
        global $feedback, $hidden_hash_var;

        $sql="UPDATE Users SET Email='$new_email' WHERE Nick='$user_name' AND Password=password('$password1')";
        $result=mysql_query($sql);
        if (!$result || (mysql_affected_rows() <1)) {
            $feedback .= "ERREUR - Nick ou password incorrect.\n";
            return false;
        } else {
            return true;
        }
    }



    function user_change_password($new_password1, $new_password2,
                                  $change_user_name, $old_password)
    {
        if ($new_password1 && ($new_password1 == $new_password2)) {
            if ($change_user_name && $old_password) {
                $sql="SELECT * FROM Users " .
                     "WHERE Nick='$change_user_name' AND Password=password('$old_password')";

                $result = mysql_query($sql);
                if (!$result || mysql_num_rows($result) <1) { //>
                    $feedback .= " ERREUR - Nick inexistant ou password incorrect\n.";
                    return false;
                } else {
                    $sql="UPDATE Users SET Password=password('$new_password1') ".
                         "WHERE Nick='$change_user_name' AND Password=password('$old_password')";
                    $result=mysql_query($sql);
                    if (!$result || mysql_affected_rows() <1 ) { //>
                        $feedback .= "ERREUR - (DB) : " . mysql_error() . "\n";
                        return false;
                    } else {
                        return true;
                    }
                }
            } else {
                $feedback .= "ERREUR - Vous devez entrer un Nick et un password.\n";
                return false;
            }
        } else {
            $feedback .= "ERREUR - Les password ne correspondent pas.\n";
            return false;
        }
    }



    function user_logout()
    {
        setcookie('user_name','',(time()+2592000),'/','',0);
        setcookie('id_hash','',(time()+2592000),'/','',0);
    }


 

Conclusion

Nous voici arrivés au bout de ce tutorial qui, je l'espère, vous sera utile si vous désirez un jour ajouter une telle fonctionnalité à votre site.

N'oubliez pas, avant d'appeler certaines fonctions comme user_getid() ou user_getemail(), que vous devez vous assurer que l'utilisateur est loggé (grâce à user_isloggedin()).
Si vous respectez les règles d'usages pour les fichiers PHP, il me semble que ce script offre une sécurité relativement grande pour les utilisateurs (tiens, la sécurité de base pour les scripts PHP, voilà une bonne idée de tutorial :p ).

Si vous désirez plus d'informations ou si vous avez des questions, n'hésitez pas à me contacter par mail (allergy@alrj.org) ou sur le forum de http://www.alrj.org.