Synchronisation des requêtes SQL depuis PHP

L'auteur

Site personnel

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Environnement

Cet article a été réalisé en se basant sur les composants suivants :

  • Linux (ici Fedora 2 / kernel 2.6)
  • Un serveur HTTP en local, sur l'IP 127.0.0.1 (ici Apache 2.0.54)
  • Un serveur SQL (ici MySQL 4.1.12)
  • PHP (ici PHP 5.0.4)

Bien que les tests n'aient pas été réalisés sur d'autres plateformes, les résultats obtenus devraient vraisemblablement être les mêmes, étant donné que les tests sont basés sur le comportement de PHP et qu'ils ne font en aucun cas intervenir la plateforme sur laquelle PHP est exécuté.

II. Identifiants de session en PHP

Les identifiants de session sont utilisés par PHP pour suivre le parcours d'un même visiteur de page en page. Cet identifiant est envoyé au navigateur du client en début de visite, le navigateur est ensuite chargé de retransmettre cet identifiant pour s'identifier. Les sessions sont un point critique pour la sécurité car elles sont utilisées pour la création d'espaces privés sur les sites (authentification par nom d'utilisateur et mot de passe par exemple). L'identifiant de session permet dans ce cas de vérifier que l'utilisateur qui accède aux pages est bien le même que celui qui s'est identifié.

Les identifiants de session PHP sont générés en utilisant un 'timeofday' et une source d'entropie, comme un générateur pseudo aléatoire ou le fichier /dev/urandom sous linux. Ces données sont ensuite hachées en utilisant MD5 ou SHA1, ce qui produit respectivement une empreinte de 128 ou 160 bits. Par défaut, MD5 est utilisé, ce qui produit une empreinte qui est convertie en chaîne de caractères (8 bits sont représentés par leur code hexadécimal, de 00 à FF). On obtient donc une chaîne de 32 caractères.

III. Transmission des identifiants de session

Si le navigateur accepte les cookies, PHP envoie le champ suivant dans les entêtes HTTP :

 
Sélectionnez
Set-cookie: PHPSESSID=31a8fb9c318db2bf1eb231837431385b; path=/

PHPSESSID correspond bien entendu à l'identifiant de session PHP. path correspond au chemin distant pour lequel ce cookie est valide. Ici le cookie est valide pour tout le domaine. Pour chaque page du domaine, le navigateur devra donc renvoyer le cookie en placant la ligne suivante dans la requête HTTP :

 
Sélectionnez
Cookie: PHPSESSID=31a8fb9c318db2bf1eb231837431385b

Si le navigateur n'accepte pas les cookies, l'identifiant est transmis en variable GET dans l'url, par exemple :

 
Sélectionnez
http://www.domaine.tld/page.php?PHPSESSID=31a8fb9c318db2bf1eb231837431385b

Les liens de la page sont automatiquement modifiés par PHP pour ajouter la variable PHPSESSID dans l'url. De cette manière, la variable de session est effectivement transmise de page en page. Cela dit, cette méthode pose un gros problème de sécurité, car les identifiants de sessions transitent dans les URL. Il y a donc un risque que les identifiants de session soient mis en cache par le navigateur ou par les éventuels proxies. Le risque de vol d'identifiant de session est alors très important.

IV. Synchronisation des accès aux pages

Un client peut demander l'accès simultané à plusieurs pages PHP, dans ce cas, PHP s'exécute de manière concurrente et exécute le code des 2 pages de manière simultanée. Dans le cas le plus trivial, cela ne pose aucun problème car 2 pages PHP ne peuvent pas partager de ressources ou de variables.

Les variables de session posent un problème car elles peuvent être partagées par plusieurs pages. Pour résoudre ce problème, PHP synchronise l'accès à 2 pages ayant le même identifiant de session par un principe de session critique. Une seule page est donc exécutée à la fois, même si 2 requêtes sont effectuées simultanément.

V. Scénario du compte banquaire

Au cours de cet article, nous allons nous baser sur un scénario assez courant dans les problèmes de synchronisation, le problème du compte bancaire. Le principe simple, vous êtes un établissement bancaire, et vous disposez d'une base de donnée regroupant tous vos client, ainsi que le solde de leur compte. Une application PHP permet de faire des accès à cette base et de faire des retraits. Pour éviter que certaines personnes ne se mettent en débit, vous avez mis en place un système permettant de vérifier si le solde du membre est suffisant avant d'effectuer un débit.

Voici une première version de ce script :

script_v1.php
Sélectionnez
<?php
mysql_connect("localhost","user","password");
mysql_select_db("db");

list($s)=mysql_fetch_array(mysql_query("SELECT balance FROM users WHERE id=1"));
sleep(5);
if($s>=10)
        mysql_query("UPDATE users SET balance=balance-10 WHERE id=1");
?>

Le sleep(5) permet de matérialiser une attente qui peut se produire entre l'exécution de 2 lignes de code. En effet, en multitâche monoprocesseur, les processus ne sont pas exécutés de manière simultanée, mais séquentielle. Au cours de l'exécution d'un script, il est donc possible que l'OS passe à l'exécution d'un autre script, créant ainsi un temps d'attente entre l'exécution de 2 lignes de code consécutives. Comme ce laps de temps est très bref (quelques millisecondes à peine) et pour que les tests soient plus facile à réaliser, ce script prolonge volontairement l'attente.

Pour cet exemple, nous utilisons une base de données sous MySQL avec une table users comportant les champs suivants :

 
Sélectionnez
id INT UNSIGNED 
balance INT

VI. Exécution de requêtes simultanées

Comme certains navigateurs ou proxies limitent eux-même les requêtes simultanées vers un même site, nous allons utiliser un programme en C qui effectue de manière très rapprochée 2 requêtes HTTP. Ceci nous permet de nous assurer que les 2 requêtes sont bien effectuées en même temps, et de nous affranchir de la dépendance due au navigateur.

main.c
Sélectionnez
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

int main(void)
{
        struct sockaddr_in Dest;
        int s,re,len,i;
        char buf[512];
        
        strcpy(buf,"GET /script.php HTTP/1.1\nHost: 127.0.0.1\n\n");
        len=strlen(buf);
        
        Dest.sin_family=AF_INET;
        Dest.sin_port=htons(80);
        Dest.sin_addr.s_addr=inet_addr("127.0.0.1");
        
        s=socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
        re=connect(s,(struct sockaddr*)&Dest,sizeof(Dest));
        send(s,buf,len,0);
        close(s);
        
        s=socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
        re=connect(s,(struct sockaddr*)&Dest,sizeof(Dest));
        send(s,buf,len,0);
        close(s);
        return 0;
}

Le programme suivant effectue une requête sur la page :

 
Sélectionnez
http://127.0.0.1/script.php

Pour le compiler (sous linux), utilisez :

 
Sélectionnez
$ gcc main.c -o request

VII. Synchronisation hors session

Nous allons tout d'abord tester comment PHP synchronise 2 accès à une même page sans session. Pour cela, le programme request créé plus tôt est directement utilisable, étant donné que nous ne fournissons aucun identifiant de session.

Le solde du membre 1 étant placé à 10, effectuons 2 requêtes :

 
Sélectionnez
$ ./request

Nous observons que le solde du membre passe à -10. Le script qui effectue la vérification n'a donc pas eu le comportement attendu.

Analyse :

Les 2 requêtes étant effectuées en même temps (ou presque), les 2 scripts récupèrent la même valeur $s puis attendent. Comme la décrémentation du solde du compte n'as pas encore été effectuée (à cause du sleep(5)), les 2 scripts ont ($s == 10). Puis les 2 scripts effectuent le test ($s >= 10). Cette condition est vérifiée pour chaque script, et chaque script décrémente ensuite la valeur du solde. L'utilisateur est donc en débit, alors que le script était supposé empêcher cette situation d'arriver.

Une solution évidente consiste à affecter la vérification dans la requête SQL :

 
Sélectionnez
UPDATE users SET balance=balance-10 WHERE id=1 AND balance>10

Comme les requêtes SQL sont synchronisées, cela aurait réglé le problème. Cela dit, dans certains scripts complexes, il est impossible d'effectuer toutes les vérifications dans une unique requête SQL. Il faut donc trouver une autre solution, qui permette d'effectuer plusieurs requêtes SQL de manière synchronisée.

Synchronisation en session

Apres avoir vu le comportement de PHP sans la présence de session, nous allons effectuer le même sur une page utilisant les sessions.

Pour cela, il nous faut un identifiant de session valide. Utilisons le script suivant pour récupérer un identifiant de session valide :

tellsession.php
Sélectionnez
<?php
session_start();
echo session_id();
?>

Il faut tout d'abord afficher la page tellsession.php une première fois avec votre navigateur favori. Cela a pour effet de demander à PHP d'initialiser une nouvelle session et de nous retourner l'identifiant associé. On peut ainsi récupérer cet identifiant et l'utiliser dans nos requêtes. On modifie donc la requête effectuée dans main.c de la manière suivante :

 
Sélectionnez
     strcpy(buf,

"GET /test/test.php HTTP/1.1\n\

Host: 192.168.0.2\n\

Cookie: PHPSESSID=4cfcb65ac353741200b29332e9e2d238\n\n"

           );

Notez que les 3 lignes qui constituent la requête (depuis GET jusqu'à \n\n) doivent impérativement être alignées à gauche dans votre éditeur, et sans tabulations. Ces 3 lignes doivent donc commencer à la colonne 1 de la source. Pensez également à remplacer l'identifiant de session par celui que vous avez récupéré grâce à la page tellsession.php.

On peut ensuite recompiler le programme puis le ré exécuter, après avoir replacé le solde de l'utilisateur 1 à 10 :

 
Sélectionnez
$ gcc main.c -o request 
$ ./request

Cette fois on observe que le solde passe à 0 et non -10.

Analyse :

Cette fois PHP à synchronisé l'exécution des 2 scripts. Il n'a pas effectué l'exécution des 2 scripts de manière simultanée mais bien séquentielle. De fait, notre script de vérification du solde du compte a bien fonctionné et le compte n'est pas en débit. PHP exécute donc les scripts d'une même session en section critique, c'est-à-dire jamais 2 à la fois. Sans la présence des sessions, cela est impossible car PHP ne peut pas déterminer si 2 requêtes proviennent bien du même client. Une vérification par l'IP n'est pas envisageable car beaucoup de clients peuvent être masqués derrière la même IP (cas du NAT ou des Masquerades par exemple).

A priori, on peut penser que la présence des sessions va résoudre le problème. En effet dans la plupart des applications critiques l'utilisateur doit s'identifier, et on utilise donc les sessions. Le problème est que rien n'empêche un même utilisateur de se connecter 2 fois (avec 2 navigateurs différents). Il obtient ainsi 2 identifiants de sessions distincts et peut de nouveau effectuer des requêtes concurrentes sans protection. De cette manière le membre peut de nouveau se mettre en débit. La présence des sessions n'est donc pas une garantie suffisante.

VIII. Section critique

Les sections critiques sont couramment utilisées en programmation multithread. Elles permettent de synchroniser plusieurs threads, de manière à ce qu'aucun d'eux n'exécutent les même parties de code en même temps. Si un morceau de code est placé dans une section critique, un seul thread à la fois pourra l'exécuter. Les autres threads seront mis en attente, puis débloqués à tour de rôle, de manière à ce qu'un seul à la fois exécute le code protégé.

Pseudo section critique en PHP

Pour que notre banque fonctionne correctement, nous allons créer une pseudo section critique en PHP, de manière à ce qu'un utilisateur ne puisse pas profiter de l'exécution concurrente de scripts pour se mettre en débit.

Pour commencer, nous allons modifier la table users, et placer les champs suivants dedans :

 
Sélectionnez
id INT UNSIGNED 
locked TINYINT 
balance INT

Le champ locked permettra de savoir quand un script critique est en cours d'exécution. Ceci permettra de bloquer les autres requêtes jusqu'à la fin de l'exécution du script critique.

Créons une seconde version du script de débit :

script_v2.php
Sélectionnez
<?php
function end_script()
{
        mysql_query("UPDATE users SET locked=0 WHERE id=1");
}

mysql_connect("localhost","user","password");
mysql_select_db("db");

mysql_query("UPDATE users SET locked=1 WHERE id=1 AND locked=0");
if(mysql_affected_rows()==0)
{
        echo "Locked !";
        die();
}

register_shutdown_function(end_script);

list($s)=mysql_fetch_array(mysql_query("SELECT balance FROM users WHERE id=1"));
sleep(5);
if($s>=10)
        mysql_query("UPDATE users SET balance=balance-10 WHERE id=1");
?>

Analyse du script :

Cette fois-ci le script est un peu plus complexe. Tout d'abord, on crée une fonction end_script qui permet de déverrouiller la section critique. Cette fonction doit impérativement être appelée lors de la sortie du script, sinon la section critique va rester verrouillée.

L'opération la plus importante (et la plus complexe à implémenter) dans une section critique est le Test & Set. C'est le moment où on vérifie que la section est libre et où on la verrouille le cas échéant. Cette opération est complexe à réaliser car elle doit absolument être indivisible (ne pas pouvoir être interrompue par l'OS). Comme il est impossible de faire cela depuis PHP, nous allons nous reposer sur le SGBD pour cette opération. En effet, les SGBD synchronisent l'exécution des requêtes. On peut donc effecuter un Test & Set en une unique requête effectuée au SGBD. On effectue donc la requête suivante :

 
Sélectionnez
UPDATE users SET locked=1 WHERE id=1 AND locked=0

Cette requête verrouille la session uniquement si elle est libre. Pour savoir si on a obtenu le verrou ou non on effectue un appel à mysql_affected_rows() qui nous retournera 1 ou 0 selon que le verrou a été modifié ou pas. Si aucune modification n'a été effectuée, c'est que le verrou est déjà positionné sur 1, on quitte donc le script sans modifier le verrou et sans effectuer aucune opération. Si le verrou a été positionné sur 1, alors on peut continuer l'exécution du script sans risque.

Une fois que l'on est certain d'avoir obtenu le verrou, on utilise register_shutdown_function() pour appeler la fonction end_script() en fin d'exécution du script (cette fonction place le verrou sur 0). De cette manière, quelque soit la manière dont le script se termine, on est certain que la fonction end_script() sera appelée juste avant de terminer. Ceci évite de laisser le verrou sur 1, ce qui bloquerait complètement l'accès au script.

On peut ensuite effectuer les opérations critiques (ici la vérification et le débit du compte). Le verrou sera automatiquement positionné sur 0 lorsque le script se terminera. Il est indispensable que la fonction end_script() ne soit appelée qu'une seule fois (sinon on court le risque de déverrouiller une session qui ne nous appartient pas). Il ne faut donc pas appeler end_script() manuellement.

Cette nouvelle version du script est immune aux requête simultanées. Le membre ne pourra donc jamais être mis en débit. De plus elle ne nécessite même pas la présence d'une session. Pour vous en convaincre, replacez le solde du compte à 10 et effectuez 2 requêtes hors session :

 
Sélectionnez
strcpy(buf,"GET /script_v2.php HTTP/1.1\nHost: 127.0.0.1\n\n");

Puis recompilez et exécutez :

 
Sélectionnez
$ gcc main.c -o request 
$ ./request

On obtient un solde de 0 (ce qui est normal) car une des deux requêtes a été ignorée. Ceci résout donc le problème de synchronisation et de requêtes concurrentes.

IX. Bibliographie

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2013 Bob. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.