Tous les articles par jbnahan

A propos jbnahan

Consultant dans un cabinet d'expertise technique, j'aide les entreprises en difficulté avec leur projet Web.

The next version of Win32Service

Win32Service Logo
Win32Service Logo

I work in my free time to improve the win32service extension to make it great and easier to use. The future version is 1.0.0 only for PHP 8.

I’m excited about this future version.

What is the planned changes?

This extension needs a little cleanup. In many cases, the Windows version is checked. All this check will be removed because VS16 is used to build PHP 8 and this extension works only on Windows 7 SP1 and newer or Windows 2012 R2 or newer.

With PHP 8 a new ValueError exception is available. Many checks will be added to improve the work of this extension. Especially the win32_create_service function.

And the last, same as many other functions in PHP extensions, all function who returns an error code will be changed to throw an exception. All PHP Errors and some PHP Warning will be changed by throwing an exception.

What is the side effect to change?

You need to change your code when you upgrade your project to PHP 8.

The Win32Service project provides a PHP library and a Symfony Bundle. Both will be upgraded to PHP 8 on a new major version.

My Actual To-do List

All this plan needs more work. This is my to-do list but you can help me.

  • Upgrade the extension code.
  • Write more tests for the extension.
  • Update the English PHP documentation for Win32Service.
  • Update the French PHP documentation for Win32Service.
  • Update the PHP library win32service/service-library.
  • Update the Symfony Bundle win32service/win32servicebundle.
  • Update the example project.

Help me

You can help me in many ways:

You are interested in this project, but you cannot help in this way, you can buy a day of work. Please contact me.

Or ping me to encourage me.

Comment ne plus penser à PHP CS Fixer

PHP CS Fixer c’est quoi ? A quoi sert PHP CS Fixer ?

PHP CS Fixer est un outil permettant de vérifier et corriger le formatage du code PHP selon le code style défini dans la configuration du projet (fichier .php_cs.dist ou .php_cs).

Le style code est une convention qui définit comment doit être écrit le code. Par exemple l’emplacement des accolades des structures de contrôle, des classes, des fonctions, les espaces autour d’un signe égal, etc.

Cela permet de faciliter la lecture du code pour tout le monde.

Le PHP-FIG a défini plusieurs conventions dont la dernière en date est la PSR-12. Il existe également des conventions plus spécifiques comme le Coding Standards de Symfony.

Pourquoi ne plus y penser ?

Le style de code est une nécessité, mais très franchement c’est frustrant quand l’intégration continue échoue à cause du style code.

Tout comme voir des messages de commit tels que fix style code, fix cs ou cs ou bien d’autre encore.

Il existe une solution pour ne plus y penser et l’appliquer tout de même, le hook (crochet) GIT pre-commit.

Un hook (crochet en français) est un point d’extension d’une application permettant à l’utilisateur d’exécuter des actions qu’il a lui-même définies. En général, ce sont des scripts Shell.

Pré requis

Cette procédure a été écrite pour les systèmes GNU/Linux. La compatibilité pour les Mac est quasi assurée. Pour Windows, il est nécessaire d’adapter la procédure au système.

PHP 7 et PHP CS Fixer doivent être installé sur le poste qui exécute les commandes GIT. Si PHP CS Fixer n’est pas installé, suivre la procédure d’installation globale manuelle.

Pour ce blog post, PHP CS Fixer est installé à cet emplacement /usr/local/bin/php-cs-fixer. Pour connaitre l’emplacement du binaire exécuter la commande type php-cs-fixer.

Le projet sur lequel vous choisissez de définir ce hook contient déjà une configuration pour PHP CS Fixer (fichier .php_cs.dist ou .php_cs).

Ajout d’un hook sur un dépôt

Pour ne pas perturber les projets qui ne dispose pas de configuration ou qui n’ont pas de fichier PHP, le hook vérifie la présence du fichier de configuration .php_cs.dist et n’appliquera les modifications que sur les fichiers PHP inclus dans le commit.

Pour l’ajouter, se placer dans le dossier de votre projet puis ouvrir le fichier .git/hooks/pre-commit (il peut déjà exister ou devra être ajouté).

Puis coller le contenu du script suivant :

#!/bin/sh

ROOT=$(dirname "$0")
ROOT=$(dirname "$ROOT")
ROOT=$(dirname "$ROOT")

echo "php-cs-fixer pre commit hook start"

# Modifier le chemin de PHP CS Fixer ici si votre binaire n'est pas installé au même endroit :
PHP_CS_FIXER="/usr/local/bin/php-cs-fixer"

HAS_PHP_CS_FIXER=false

if [ -x  "$PHP_CS_FIXER" ]; then
    HAS_PHP_CS_FIXER=true
else
    echo "PHP CS Fixer not installed into $PHP_CS_FIXER"
fi

#PHP_CS_CONFIG=

for file in .php_cs.dist .php_cs
do
    if [ -f "$ROOT/$file" ]; then
        echo PHP CS Fixer config file found in projet at $file
        PHP_CS_CONFIG=$file
    fi
done

if [ "x$PHP_CS_CONFIG" = "x" ]; then
    echo "No PHP CS Fixer config file found !"
    HAS_PHP_CS_FIXER=false
fi

if $HAS_PHP_CS_FIXER; then
    git status --porcelain | grep -e '^[AM]\(.*\).php$' | cut -c 3- | while read line; do
        $PHP_CS_FIXER fix --config=$ROOT/$PHP_CS_CONFIG --verbose "$line";
        git add "$line";
    done
else
    echo ""
    echo "Please install php-cs-fixer, see:"
    echo ""
    echo "  https://github.com/FriendsOfPHP/PHP-CS-Fixer#installation"
    echo ""
fi

echo "php-cs-fixer pre commit hook finish"

Il est nécessaire de s’assurer que ce fichier est exécutable grâce à cette commande : chmod u+x .git/hooks/pre-commit.

Exécuter la commande dans un terminal après s’être placé dans le dossier de votre projet.

Pour tester son efficacité, modifier le placement d’accolade dans un fichier PHP du projet puis ajouter un commit.

PHP CS Fixer est exécuté sur chaque fichier modifié pour corriger le style code avant le commit.

Ajout d’un hook pour tous les dépôts

Maintenant que notre hook fonctionne, nous pouvons l’ajouter au modèle de dépôt GIT. Ainsi tout nouveau dépôt disposera du hook.

La première étape consiste à définir un dossier de template pour les nouveaux dépôts GIT.

Exécuter la commande git config --global init.templatedir '~/.git-templates'

Le dossier n’existant pas, nous allons l’ajouter à notre dossier utilisateur avec cette commande mkdir -p ~/.git-templates/hooks

Toujours depuis le dossier du projet où le hook a été ajouté exécuter la commande suivante pour copier le hook cp .git/hooks/pre-commit ~/.git-templates/hooks/

Cette fois, nous allons nous assurer que tout le monde peut l’exécuter avec cette commande : chmod a+x ~/.git-templates/hooks/pre-commit.

Ajouter le hook sur un dépôt existant

Maintenant que le hook est dans le template de GIT, il est possible de l’ajouter à un projet existant en exécutant la commande : git init dans le dossier du projet.

Pour les dépôts ayant déjà un hook pre-commit, il ne sera pas modifié.

Besoin d’aide pour mettre en place cette solution ? Contactez-moi !

Il est nécessaire de s’assurer que ce fichier est exécutable grâce à cette commande : chmod u+x .git/hooks/pre-commit.

Exécuter la commande dans un terminal après s’être placé dans le dossier de votre projet.

Pour tester son efficacité, modifier le placement d’accolade dans un fichier PHP du projet puis ajouter un commit.

PHP CS Fixer est exécuté sur chaque fichier modifié pour corriger le style code avant le commit.

Allez plus loin en ajoutant un fichier de configuration GIT pour ignorer globalement certain fichier.

10 astuces pour éviter des failles de sécurité

https://www.pxfuel.com/en/free-photo-jrpjv
Security Solutions – Global Security – Online Security – House Security https://www.pxfuel.com/en/free-photo-jrpjv

Pour plus de simplicité, j’utilise le terme application pour désigner les applications web et les sites web (par exemple, un site vitrine, etc.).

Filtrer, filtrer, filtrer

La première astuce consiste à filtrer côté serveur Web toutes les données reçues de l’utilisateur.

Si vous développez une librairie, les entrées correspondent à tous les arguments des méthodes publiques mise à disposition du développeur qui utilise votre librairie.
Il est également nécessaire de bloquer les contournements possibles de vos protections en prévoyant des points d’extension contrôler et en bloquant la modification du fonctionnement de votre code.

Si vous développez une application, les entrées utilisateurs sont toutes les données reçues via une requête HTTP, les arguments reçus lors de l’exécution d’une commande dans le terminal.

Pour filtrer, il existe différentes méthodes selon les cas.

  • Un texte libre où l’utilisateur est libre de saisir ce qu’il souhaite, un filtre avec une expression rationnelle est efficace, mais ne suffit pas. J’en reparlerai plus bas.
  • Une date
  • Un numéro de téléphone
  • Une couleur
  • Un entier ou nombre décimal
  • Une case à cocher
  • Une liste de choix

Contrôler les URL fournies

Cette astuce est d’un niveau supérieur, il existe une donnée qui est extrêmement difficile de contrôler, c’est les URL.

Si votre application demande à l’utilisateur une URL pour faire un rappel (callback) ou charger des données qui seront affichées plus tard (ex.: agrégateur RSS) vous avez un risque d’exposer vos API internes.

Il existe des services sur Internet permettant de tromper les vérifications DNS. Par exemple, le domaine 192.168.0.1.fake-dns.com passeront les filtres de validité de l’URL, mais lors de la résolution DNS enverra la requête sur le réseau interne.

Certains services proposent de résoudre le nom de domaine avec deux IP fourni l’une après l’autre… Ainsi lors de la première résolution, l’IP retourné est bien une IP externe, mais lors de la seconde résolution (cette fois pour envoyer la requête) l’IP retourné par le DNS est une IP interne.

Extrême prudence donc sur les URL fournies pas les utilisateurs.

Il existe selon les clients HTTP la possibilité de fournir la liste des plages IP interdite permettant de limiter les accès. Cependant, comme indiqué plus haut l’efficacité est liée à l’implémentation.

À mon sens réaliser une résolution DNS manuelle (pour filtrer) avant l’exécution de la requête est une bonne chose uniquement s’il est possible de fournir au client HTTP l’IP du serveur sur lequel se connecter indépendamment du hostname.

La validation navigateur n’est pas suffisante

Avec l’évolution du développement coté navigateur, nombreux sont ceux qui font confiance au filtre mis en place avec JavaScript. Ces filtres sont une bonne chose, mais s’ils ne sont pas également exécutés sur le serveur qui reçoit les données cela ne vous protège de rien.

Voici un scoop, les malveillants n’utilisent pas de navigateur pour exfiltrer les données. Les filtres JavaScript ne sont donc pas exécutés et votre serveur est vulnérable !

Préparer vos requêtes SQL

Cette astuce consiste à protéger les éléments variables que vous utilisez dans vos requêtes SQL.

Pour toute requête, les données utilisées pour la valeur des colonnes des tables doivent être passées en argument avec une requête préparer. Je vous laisse lire la documentation de la librairie que vous utilisez pour accéder aux données.

Cette pratique est assez courante, mais il reste des éléments qu’il n’est pas possible de passer en argument d’une requête préparée.

Par exemple, le nom de la colonne à utiliser pour le tri, le sens du tri, les noms des colonnes à retourner, le nom de la table à interroger. Pour ces éléments, vous devez les filtrer (et oui encore) avec une liste blanche.

Par exemple, la valeur du sens de tri est égale à ASC ? Alors utilisation de la valeur ASC, sinon utilisation de la valeur DESC. Le nom de la colonne fourni pour le tri est-il dans la liste des colonnes autorisées pour le tri ? Si oui, alors la colonne est utilisée, sinon, la valeur par défaut est utilisée.

Échapper l’affichage

Cette astuce est extrêmement simple à mettre en place. Toute donnée affichée dans les templates (pour une réponse HTTP) doit être échappée.

C’est-à-dire que si le texte à afficher contient le caractère < il sera remplacé part &lt;. L’affichage est correct et s’il contient une balise HTML utilisée pour réaliser une injection XSS le code sera affiché au lieu d’être exécuté par le navigateur.

L’échappement évitera également l’hébergement de site peu recommandable sur votre site grâce à l’injection de code HTML dans votre page.

Gérer correctement les fichiers téléversés

La première astuce est de limiter les types de fichiers autorisés et la taille. La vérification de ces informations doit être réalisée sur le serveur sans tenir compte des informations fournies par l’utilisateur (ou son navigateur).

La seconde est de ne pas stocker le fichier avec le nom original sur votre serveur. Calculer une somme de contrôle unique ou générer des données aléatoires pour être sûr du nom du fichier.

La gestion des fichiers uploadés par les utilisateurs de votre application est différente en fonction de ces deux cas :

  • les fichiers publics
  • les fichiers privés

Pour les fichiers publics, le stockage du fichier sera réalisé en général dans un dossier public. Ainsi le fichier sera plus simple à télécharger. L’exécution des fichiers présents dans le dossier de dépôt doit être désactivée !

Pour les fichiers privés, le stockage du fichier sera réalisé dans un dossier privé (inaccessible de l’extérieur via le serveur web). Le téléchargement passera forcément par votre application pour vérifier les accès (j’y reviendrai plus bas). Il est en général nécessaire de stocker certaines informations en base de données (tel que le nom original du fichier, le type, la taille, le nom réel utilisé sur l’espace de stockage).

Si vos fichiers privés sont hébergés sur un service de stockage distant (ObjectStorage, S3), bien vérifier les droits d’accès à l’espace de stockage.

Stocker correctement les mots de passe

Cette astuce consiste à rendre illisibles les mots de passe de vos utilisateurs stockés en base de données. Illisible, mais également difficile à retrouver. C’est pourquoi les méthodes de hachage tel que MD5, SHA1 et suivante sont à proscrire.

À la place, utilisez une méthode de hachage qui contient un paramètre de temps de calcul.

Imaginons que le calcul du hash d’un mot de passe prend sur votre serveur 5ms. Lors de la vérification du mot de passe, l’utilisateur ne le sentira pas. Par contre, si vous avez 50 000 mots de passe dont il faut calculer le mot de passe, le temps n’est plus le même.

Un malveillant sera capable de mettre beaucoup d’argent et de temps pour casser les mots de passe s’il est sûr que cela lui rapportera gros.

Pour terminer cette astuce, lors de la vérification du mot de passe, utilisez la méthode de comparaison fournie par la librairie. Il existe des failles basées sur le temps lors de la vérification du hash.

Protéger les actions dangereuses

Cette astuce consiste à sécuriser les actions réalisées par les utilisateurs de votre application au travers d’un formulaire.

Lorsque votre utilisateur ajoute, modifie ou supprime une donnée, vous devez vous assurer que l’action vient bien de lui. Pour cela, lors de la génération de la page du formulaire, ajouter un champ caché contenant un code unique aléatoire (token CSRF) que vous stockez également dans la session de l’utilisateur côté serveur.

Lors de la soumission du formulaire par l’utilisateur, la valeur du champ caché doit être égale à la valeur stockée en session.

Cette astuce est plutôt répandue (grâce aux frameworks qui l’intègre) pour les formulaires d’ajout et modification, mais pas pour les actions dangereuses qui ne passent pas par un formulaire telle la suppression.

Dans le cas de la suppression ou de toute autre action (activation/désactivation par exemple) il est possible de transformer le lien en formulaire et ainsi de positionner le token.

Mais il est également possible lors de la génération du lien de générer un token qui sera passé en paramètre GET de la requête.

Pourquoi tout cela ?

Il est très simple pour un malveillant de générer une page web contenant une image dont l’URL est celle d’une action dangereuse de votre application. Lors du chargement de la page malveillante, votre navigateur réalisera une requête vers votre application pour charger l’image. Si vous êtes connecté à l’application, l’action sera réalisée sans que vous le sachiez.

Voici à quoi peut ressembler une image piégée. Cela fonctionne également avec les courriels si votre client charge les images par défaut.

<img src="https://votre_application.fr/user/10/delete">

Le code ne fonctionnera pas si un token est nécessaire (ex.: https://votre_application.fr/user/10/delete?token=2528425faf41) car la valeur du token n’est pas devinable !

Contrôler les accès

Cette astuce consiste à vérifier si l’utilisateur actuel à le droit de voir la page demandée. Dans le cas ou l’utilisateur n’est pas connecter, une erreur HTTP 401 sera renvoyé et dans le cas ou l’utilisateur est connecté, mais qu’il n’a pas les accréditations une erreur HTTP 403 sera renvoyé.

Vérifier l’accès à une page est une chose, mais parfois un utilisateur à l’accréditation pour accéder à une page, mais pas pour toutes les données.

Prenons le cas de l’affichage des données d’un utilisateur. L’URL est sous la forme https://monapplication.fr/user/<id>.

L’utilisateur courant peu visualiser la page de profils uniquement des utilisateurs qui sont dans le même groupe que lui. Il a donc l’accréditation pour accéder à l’URL.

Lors du traitement de la requête, le serveur doit vérifier que l’utilisateur courant est dans le même groupe que l’utilisateur qu’il souhaite afficher. S’il n’est pas dans le même groupe, une erreur HTTP 403 sera renvoyée.

Cette vérification est également à inclure dans les tests automatisés de votre application.

Protéger vos cookies

Cette astuce consiste à bloquer l’utilisation du cookie de session ou reconnexion automatique (remember me) par JavaScript et lors d’un clic sur un lien (email ou un autre site).

Lors de l’envoi d’un cookie au navigateur, il y a des drapeaux (flags) qui peuvent être activés. Activer le drapeau HttpOnly pour que JavaScript n’y accède pas et ainsi éviter le vol des cookies de session.
Activer le drapeau Secure pour que le navigateur émettre le cookie que pour les requêtes HTTPS.
Activer le drapeau SameSite=Strict si votre application n’utilise pas de système d’authentification tiers (SSO). Cette option expérimentale bloquera des attaques CSRF (voir « Protéger les actions dangereuses ».

N’utilisez pas les index auto-incrément

Cette astuce consiste à ne pas utiliser d’identifiant auto incrémental. Pourquoi ? Lors de l’accès aux données, il est fréquent de mettre l’identifiant dans l’URL.
Si l’accès à la donnée est protégé, il est facile de tenter une autre URL pour essayer d’accéder à des données auquel il n’est normalement pas possible d’accéder.

Voici l’exemple de l’URL d’accès aux informations d’un client de ma base client héberger sur un service SaaS https://monservice.fr/client/10.

Maintenant, je sais que l’ID de mon premier client est 10 et le dernier est 20. Qu’y a-t-il avant et après ? Il est très facile de le découvrir.

Si vous avez appliqué l’astuce « Contrôler les accès » le problème ne se posera pas.

Il ne restera qu’une possible estimation de la quantité de données disponibles dans la table.

Oui, il est encore possible de déterminer le dernier index utilisé, car une fois dépassé, l’erreur HTTP sera 404 (introuvable) à la place de 403 (accès refusé).

C’est ici que les identifiants non incrémentaux générés de façon sécurisée sont efficaces, car non prévisibles.

Protéger vos API

Toutes ces astuces sont applicables aux API HTTP avec une nuance pour la protection des actions par token temporaire (CSRF). Vos API doivent être protégés par un token ajouté dans les en-têtes des requêtes HTTP.

Ce token est soit récupéré après l’authentification de l’utilisateur. Sois généré une fois et restera valide tant qu’il est connu du serveur.

Grâce à ces tokens, il est possible de limiter les accès à vos API (nombre de requêtes possibles dans un laps de temps).

Conclusion

La sécurité des données et de l’information en général est l’affaire de tous. Nous pouvons tous faire quelque chose à notre niveau.

En tant que développeur, nous sommes en amont des futures fuites de données et nous devons faire notre possible pour les éviter, informer nos collègues et nos responsables sur la nécessiter de sécuriser les données. Avec de petites astuces, il est possible de réduire les risques !

Je n’ai pas parlé de langages de programmation dans ces astuces… Dites-moi dans les commentaires si vous êtes intéressé par une explication des failles pour PHP, mais également le format que vous préférez.

Nouvelle erreur ValueError dans PHP 8 (partie 3)

Dans la continuité du précédent article sur PHP 8, voici une modification qui aura une incidence sur notre code et son bon fonctionnement sur cette nouvelle version majeure de PHP.

Articles de la série :

Partie 1 : Migration de code vers PHP 8 (partie 1)
Partie 2 : Migration de code vers PHP 8 MBString (partie 2)
Partie 3 : Nouvelle erreur ValueError dans PHP 8 (partie 3)

Fournir un objet en argument à la place d’un entier provoque une exception TypeError, mais que se passe-t-il si le type est correct, mais que la valeur ne l’est pas ?
À partir de PHP 8 un grand nombre de méthodes lèveront une exception ValueError.

Cette modification aura plus de conséquences sur le code devant être compatible avec PHP 7 et PHP 8 et dont la valeur des arguments est fournie par l’utilisateur ou le développeur.

La solution la plus simple est de valider la donnée avant son usage. Par validation, je recommande la vérification du type de donnée, mais également de son contenu (regex, liste exhaustive des valeurs acceptées, nettoyeur HTML par exemple). La vérification aura également un avantage en termes de sécurité, mais j’en reparlerais sûrement dans un autre bloc post.

Concrètement, à quoi faut’il s’attendre dans PHP 8 ?

Voici comment réagit la fonction password_hash avec un argument invalide dans PHP 8 :

Interactive shell

php > echo phpversion();
8.0.0-dev
php > 
php > $return = password_hash('test', PASSWORD_ARGON2I, ['threads' => 0]);

Warning: Uncaught ValueError: Invalid number of threads in php shell code:1
Stack trace:
#0 php shell code(1): password_hash('test', 'argon2i', Array)
#1 {main}
  thrown in php shell code on line 1
php >

Ici, je passe un nombre incorrect de fils d’exécution à utiliser pour le calcul de la somme de contrôle du mot de passe. PHP lève une exception spécifique de type ValueError.

Dans votre code, si vous réalisez des tests similaires à if (is_string($return) === false) {}, il sera nécessaire de capturer l’exception par une structure de code try catch pour gérer les cas d’erreur en PHP 8.

<?php
try {
    $return = password_hash('test', PASSWORD_ARGON2I, ['threads' => 0]);
} catch (ValueError $e) {
    var_dump($e);
}

Donne la sortie suivante :

object(ValueError)#3 (7) {
  ["message":protected]=>
  string(25) "Invalid number of threads"
  ["string":"Error":private]=>
  string(0) ""
  ["code":protected]=>
  int(0)
  ["file":protected]=>
  string(14) "php shell code"
  ["line":protected]=>
  int(1)
  ["trace":"Error":private]=>
  array(1) {
    [0]=>
    array(4) {
      ["file"]=>
      string(14) "php shell code"
      ["line"]=>
      int(1)
      ["function"]=>
      string(13) "password_hash"
      ["args"]=>
      array(3) {
        [0]=>
        string(4) "test"
        [1]=>
        string(7) "argon2i"
        [2]=>
        array(1) {
          ["threads"]=>
          int(0)
        }
      }
    }
  }
  ["previous":"Error":private]=>
  NULL
}

Maintenant, comment rendre notre code compatible avec PHP 7 et 8 ?

Voici un exemple de code :

<?php
try {
    $return = password_hash('test', PASSWORD_ARGON2I, ['threads' => 0]);

    // Test du retour de la fonction
    if (is_string($return) === false) {
        throw new ValueError('Error with password_hash function, see the PHP logs to more details.');
    }
} catch (ValueError $e) {
    var_dump($e);
    // Traitement de l'erreur
}

Pour PHP 7, il est nécessaire de définir la classe ValueError, car elle n’existe pas. Cependant, plutôt que définir cette classe dans tous vos projets, utilisez la librairie polyfill pour PHP 8.0 réalisée par Symfony.

La mise à jour du code est donc possible dès maintenant pour nos projets et nos librairies.

Après avoir aperçu la modification du comportement, voici la liste des méthodes PHP qui peuvent émettre une exception ValueError :

array_chunk
array_combine
array_fill
array_multisort
array_pad
array_rand
assert_options
base_convert
bcadd
bccomp
bcdiv
bcmod
bcmul
bcpow
bcpowmod
bcscale
bcsqrt
bcsub
bzopen
bzread
cal_days_in_month
cal_from_jd
cal_info
cal_to_jd
count
count_chars
deflate_add
deflate_init
dirname
dns_check_record
easter_date
enchant_broker_free
explode
extract
fgetcsv
fgets
file
file_get_contents
flock
fputcsv
fread
fscanf
ftruncate
func_get_arg
gettype
gmp_pow
gzcompress
gzdeflate
gzencode
gzinflate
gzread

gzuncompress
hash
hash_file
inflate_add
inflate_init
jdtojewish
jdtounix
json_decode
log
max
mb_check_encoding
mb_chr
mb_convert_case
mb_convert_encoding
mb_convert_kana
mb_convert_variables
mb_decode_numericentity
mb_detect_encoding
mb_detect_order
mb_encode_numericentity
mb_encoding_aliases
mb_internal_encoding
mb_language
mb_ord
mb_preferred_mime_name
mb_regex_encoding
mb_strcut
mb_strimwidth
mb_stripos
mb_stristr
mb_strlen
mb_strpos
mb_strrchr
mb_strrichr
mb_strripos
mb_strrpos
mb_str_split
mb_strstr
mb_strtolower
mb_strtoupper
mb_strwidth
mb_substitute_character

mb_substr
mb_substr_count
min
mt_rand
parse_url
password_hash
pow
print
printf
proc_open
putenv
range
ReflectionParameter
scandir
settype
socket_connect
socket_create_pair
socket_send
socket_sendto
socket_set_option
spl_autoload_register
sprintf
sscanf
stream_context_get_default
stream_filter_register
stream_select
stream_set_chunk_size
stripos
strncasecmp
strncmp
str_pad
strpbrk
strpos
strripos
strrpos
str_split
str_word_count
substr_compare
substr_count
test
trigger_error
unixtojd
unserialize
version_compare
vfprintf
wordwrap
zip_close

Cette liste n’est pas exhaustive. Elle a été réalisée sur la base des tests de PHP.

Conclusion

Autant que possible, préparons dès maintenant la migration vers PHP 8 en modifiant notre code de façon à faciliter la migration.

Les tests sur PHP 8.0 ont été réalisés avec l’image docker php8-jit de Keinos. Merci pour le partage.

Restez à l’écoute, je vais réaliser d’autre article sur les modifications apportées par PHP 8 ayant une incidence sur notre code.

Migration de code vers PHP 8 MBString (partie 2)

Dans la continuité du précédent article sur PHP 8, voici les modifications de l’extension multi-byte string qui peuvent avoir une incidence sur notre code et son bon fonctionnement sur cette nouvelle version majeure de PHP.

Articles de la série :

Partie 1 : Migration de code vers PHP 8 (partie 1)
Partie 2 : Migration de code vers PHP 8 MBString (partie 2)
Partie 3 : Nouvelle erreur ValueError dans PHP 8 (partie 3)

Alias de fonction ou fonctions supprimées

  • mbregex_encoding -> mb_regex_encoding
  • mbereg -> mb_ereg
  • mberegi -> mb_eregi
  • mbereg_replace -> mb_ereg_replace
  • mberegi_replace -> mb_eregi_replace
  • mbsplit -> mb_split
  • mbereg_match -> mb_ereg_match
  • mbereg_search -> mb_ereg_search
  • mbereg_search_pos -> mb_ereg_search_pos
  • mbereg_search_regs -> mb_ereg_search_regs
  • mbereg_search_init -> mb_ereg_search_init
  • mbereg_search_getregs -> mb_ereg_search_getregs
  • mbereg_search_getpos -> mb_ereg_search_getpos
  • mbereg_search_setpos -> mb_ereg_search_setpos

func_overload supprimée

La directive de configuration INI mbstring.func_overload a été supprimée avec les constantes afférentes MB_OVERLOAD_MAIL, MB_OVERLOAD_STRING, et MB_OVERLOAD_REGEX.
Tout comme la récupération des informations func_overload et func_overload_list via la méthode mb_get_info.

Autres modifications de comportement

  • Le tableau de résultat passé en référence dans l’argument &$result est maintenant obligatoire pour la fonction mb_parse_str.
  • Le modificateur e pour la fonction mb_ereg_replace a été supprimé. Il est nécessaire d’utiliser la fonction mb_ereg_replace_callback à la place.
  • Si la valeur de l’argument pattern de la fonction mb_ereg_replace n’est pas une chaîne de caractère, il sera transformé en chaine de caractère. Pour retrouver le comportement de PHP 7, il est nécessaire d’ajouter explicitement la fonction chr. Ce comportement ne peut pas être détecté par l’analyse statique du code.
  • La valeur de l’argument needle des fonctions suivantes mb_strpos, mb_strrpos, mb_stripos, mb_strripos, mb_strstr, mb_stristr, mb_strrchr et mb_strrichr ne peuvent plus être vides. Ce comportement ne peut pas être détecté par l’analyse statique du code.
  • Il n’est plus possible de fournir l’encodage en 3e argument à la fonction mb_strrpos. Il est obligatoire de saisir 0 pour la valeur de l’argument offset.

Conclusion

Comme pour de nombreuses améliorations, il est possible de détecter les modifications à apporter grâce à l’analyse statique. Certaines modifications peuvent même être réalisées automatiquement avec un outil de migration tel que Rector.

Restez à l’écoute, je vais réaliser d’autre article sur les modifications apportées par PHP 8 ayant une incidence sur notre code.

Double authentification et Symfony 5.1

Dans un article précédent, j’utilisais Guard pour réaliser un mécanisme de double authentification (si vous ne voyez pas de quoi je parle, lisez ce blog post « Double authentification avec Symfony »). J’ai souhaité le mettre à jour pour utiliser la nouvelle fonctionnalité introduite dans Symfony 5.1.

Avertissement

La fonctionnalité dont je vais parler est expérimentale ! Elle est susceptible d’être modifiée sans garantie de rétrocompatibilité dans les prochaines versions de Symfony 5.2 et suivante.

Cette fonctionnalité redessine la façon de fonctionner des classes d’authentification en se basant sur les évents. Ainsi les nouvelles classes d’authentification qui ressemblent un peu à celle de Guard génèrent un passeport contenant toutes les informations nécessaires pour la validation de l’authentification.

Pour ce blog post, j’ai préféré vous présenter cette migration sous forme de questions et réponses.

Compatibilité Symfony 5.2 ?

Le code présenté dans cet article est compatible avec Symfony 5.2. La fonctionnalité est toujours expérimentale !

Comment fonctionne la classe d’authentification ?

Premier point, une classe d’authentification doit (comme pour Guard) dire si oui ou non il supporte la requête en cours. Si oui, la méthode authenticate est appelée et devra retourner un objet qui implémente l’interface PassportInterface.

La méthode authenticate doit :

  • Charger l’utilisateur depuis le fournisseur d’utilisateur.
  • Collecter les informations d’authentification (depuis la requête en général).
  • Générer le passeport avec la liste des badges nécessaires.

Comment sont vérifiées les informations d’authentification ?

Lors de la construction du passeport, le premier argument est l’objet utilisateur qui implémente l’interface UserInterface de Symfony. Le second est un « badge » spécifique qui implémente l’interface CredentialsInterface. Cet objet contiendra les informations d’authentification saisie par l’utilisateur qui tente de se connecter.

<?php
namespace Symfony\Component\Security\Http\Authenticator\Passport;
//...
class Passport implements UserPassportInterface
{
//...
    /**
     * @param CredentialsInterface $credentials the credentials to check for this authentication, use
     *                                          SelfValidatingPassport if no credentials should be checked.
     * @param BadgeInterface[]     $badges
     */
    public function __construct(UserInterface $user, CredentialsInterface $credentials, array $badges = [])
    {
       //...
        }
    }
//...
}

La vérification en elle-même dépend d’un écouteur (Listener/Subscriber) qui prend en charge la classe utilisée en second argument du constructeur de la classe passeport.

Qu’est-ce qu’un badge ?

Un badge est un objet qui est ajouté au passeport et qui permet de déclencher des vérifications supplémentaires lors de l’authentification. Par exemple, si l’utilisateur est actif, validation d’un code Yubikey…

Un badge doit implémenter l’interface BadgeInterface qui réclame que la méthode isResolved soit implémentée. Si cette méthode retourne la valeur « faux », l’authentification sera bloquée et l’utilisateur sera redirigé vers la page d’authentification.

Comment sont résolus les badges ?

Pour chaque badge il est nécessaire d’écrire un écouteur de l’événement SymfonyCheckPassportEvent. Ce dernier contient le passeport généré par la méthode authenticate de la classe d’authentification.

L’écouteur doit :

  1. Vérifier la présence dans le passeport du badge qu’il gère.
  2. Réaliser les traitements liés au badge.
  3. Si les conditions sont réunies, marquer le badge comme résolu (pour que la méthode isResolved retourne « vrai ») pour autoriser l’authentification.

Quelles sont les modifications marquantes ?

J’ai surtout dû ajouter beaucoup de classe puis déplacer certaines parties de code présentes dans les classes d’authentification vers les écouteurs.

Quelles sont les modifications apportées aux classes d’authentification ?

La première modification est l’héritage des classes AppLoginAuthenticator et AppTwoFactorAuthenticator qui est passé de SymfonyComponentSecurityGuardAuthenticatorAbstractFormLoginAuthenticator à SymfonyComponentSecurityHttpAuthenticatorAbstractLoginFormAuthenticator.

En observant le nom complet de la classe, il est possible de constater que je suis passé d’une classe d’authentification Guard à une classe d’authentification HTTP.

En suite les deux méthodes getUser et getCredentials on fusionné dans authenticate. La méthode checkCredentials pour la classe d’authentification AppLoginAuthenticator a été simplement supprimée, car c’est l’écouteur fourni par Symfony qui prend le relais pour vérifier le mot de passe de l’utilisateur.

Comment l’utilisateur est redirigé vers la page de saisie du second facteur ?

J’ai ajouté une classe de badge TwoFactorBadge avec son écouteur TwoFactorBadgeSubscriber. Lors de la construction du passeport dans AppLoginAuthenticator, ce badge est ajouté.

<?php

namespace AppSecurity;

final class AppLoginAuthenticator extends AbstractLoginFormAuthenticator
{
    public function authenticate(Request $request): PassportInterface
    {
        $email = $request->get('email');
        $user = $this->userRepository->findOneBy(['email' => $email]);
        $request->getSession()->set(
            Security::LAST_USERNAME,
            $email
        );
        $request->getSession()->set(
            AppTwoFactorAuthenticator::USER_SESSION_KEY,
            $email
        );
        if (!$user) {
            throw new UsernameNotFoundException();
        }

        return new Passport($user, new PasswordCredentials($request->get('password')), [
            // and CSRF protection using a "csrf_token" field
            new CsrfTokenBadge('authenticate', $request->get('_csrf_token')),

            // and add support for upgrading the password hash
            new PasswordUpgradeBadge($request->get('password'), $this->userRepository),
            new TwoFactorBadge(),
        ]);
    }
}

Voir la classe AppLoginAuthenticator complète.

Cet écouteur a une particularité, il doit être exécuté après la vérification du mot de passe. J’ai donc dû modifier la priorité de l’écouteur à -10, car par défaut, les écouteurs sont tous exécutés avant la vérification du mot de passe.

//...
    public static function getSubscribedEvents()
    {
        return [
            CheckPassportEvent::class => ['resolveBadge', -10]
        ];
    }
//...

Maintenant, si le mot de passe est correct, l’écouteur ajoutera la clé need_auth_two avec la valeur « vrai » dans la session sans résoudre le badge.

<?php
namespace AppSecurityListener;
final class TwoFactorBadgeSubscriber implements EventSubscriberInterface
{
    public function resolveBadge(CheckPassportEvent $event)
    {
        /** @var Passport $passport */
        $passport = $event->getPassport();

        // Here I can check if the user has the two factor authentication enabled
        if ($passport->hasBadge(TwoFactorBadge::class) && $passport->hasBadge(PasswordCredentials::class) && $passport->getBadge(PasswordCredentials::class)->isResolved()) {
            $this->session->set('need_auth_two', true);
        }
    }
}

Voir la classe TwoFactorBadgeSubscriber complète.

Après le passage du passeport dans tous les écouteurs, Symfony vérifiera si tous les badges sont résolus. Comme ce ne sera pas le cas, il redirigera l’utilisateur vers la page de login. C’est là que la méthode getLoginUrl de classe d’authentification AppLoginAuthenticator génèrera une URL pour la saisie du code de second facteur d’authentification.

La méthode getLoginUrl n’a pas été modifiée !

<?php

namespace AppSecurity;

final class AppLoginAuthenticator extends AbstractLoginFormAuthenticator
{
//...
    protected function getLoginUrl(Request $request): string
    {
        if ($this->session->get('need_auth_two', false) === true) {
            return $this->urlGenerator->generate(AppTwoFactorAuthenticator::LOGIN_ROUTE);
        }

        return $this->urlGenerator->generate(self::LOGIN_ROUTE);
    }
//...
}

Comment est vérifié le second facteur ?

Comme avec Guard la page de saisie du code de second facteur est différente de celle de la connexion avec le mot de passe. Il y a donc deux classes d’authentification. J’ai également modifié la classe d’authentification AppTwoFactorAuthenticator.

<?php

namespace AppSecurity;

final class AppTwoFactorAuthenticator extends AbstractLoginFormAuthenticator
{
//...
    public function authenticate(Request $request): PassportInterface
    {
        $email = $request->getSession()->get(self::USER_SESSION_KEY);
        $user = $this->userRepository->findOneBy(['email' => $email]);
        $request->getSession()->set(
            Security::LAST_USERNAME,
            $email
        );
        $request->getSession()->set(
            AppTwoFactorAuthenticator::USER_SESSION_KEY,
            $email
        );
        if (!$user) {
            throw new UsernameNotFoundException();
        }

        return new Passport($user, new TwoFactorCredentials($request->get('password')), [
            // and CSRF protection using a "csrf_token" field
            new CsrfTokenBadge('authenticate', $request->get('_csrf_token')),
            new TwoFactorTimeoutBadge(),
            new TwoFactorMaxAttemptBadge(),

        ]);
    }
}

Cette classe d’authentification appliquait les règles suivantes en plus de la vérification du code :

  • L’utilisateur n’a que 3 essais.
  • L’utilisateur à un temps limité pour saisir le bon code.

Pour chacune de ces règles, j’ai ajouté un badge spécifique au passeport que génère la classe d’authentification. Bien sûr, j’ai également écrit l’écouteur qui correspond et qui marque le badge comme résolu si les conditions sont remplies (TwoFactorMaxAttemptBadgeSubscriber et TwoFactorTimeoutBadgeSubscriber).

Pour la partie vérification du code en lui-même, j’ai ajouté une classe TwoFactorCredentials qui permettra la vérification. Cette classe sera donc utilisée en second argument du constructeur du passeport et contiendra le code saisi par l’utilisateur.

J’ai ajouté une nouvelle classe, car ce n’est pas un mot de passe en tant que tel et je ne souhaite pas que l’écouteur fourni par Symfony se déclenche.

En suite, j’ai écrit l’écouteur pour réaliser la vérification du code.

<?php
namespace AppSecurityListener;
final class TwoFactorCredentialSubscriber implements EventSubscriberInterface
{
//...
    public function checkCredential(CheckPassportEvent $event)
    {
        /** @var Passport $passport */
        $passport = $event->getPassport();
        if ($passport->hasBadge(TwoFactorCredentials::class) === false) {
            return;
        }
        $badge = $passport->getBadge(TwoFactorCredentials::class);
        if ($badge->getPassword() === $this->session->get(AppTwoFactorAuthenticator::CODE_SESSION_KEY, null)) {
            $badge->markResolved();
            return;
        }

        $this->session->set(AppTwoFactorAuthenticator::COUNT_SESSION_KEY,
            $this->session->get(AppTwoFactorAuthenticator::COUNT_SESSION_KEY, 0) + 1);

    }
}

Fichier complet src/Security/Listener/TwoFactorCredentialSubscriber.php

Quelles sont les modifications apportées dans la configuration ?

Étant donné que les classes d’authentification ne sont plus liées à Guard, il a été nécessaire de modifier la configuration.
Ainsi, les ID de service enregistré pour le firewall main dans la clé guard.authenticators ont été déplacés dans la clé custom_authenticators. Étant donné que j’ai deux classes d’authentification, il est obligatoire de désigner le point d’entrée avec la clé entry_point à la place de guard.entry_point.

#...
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            provider: db
            entry_point: AppSecurityAppLoginAuthenticator
            custom_authenticators:
                - AppSecurityAppLoginAuthenticator
                - AppSecurityAppTwoFactorAuthenticator
            logout:
                path: app_logout
#...

Voir le fichier config/packages/security.yaml complet.

En conclusion, quels sont les avantages d’un tel système ?

Le premier avantage que je constate est la possibilité de réutiliser un badge et son écouteur sur plusieurs firewalls de l’application.

Le second est le fort découpage des responsabilités, ainsi qu’une grande facilité d’extension du système.

Enfin le dernier avantage est de pouvoir proposer plus facilement des bundles Symfony permettant d’étendre et mutualiser les badges et leur écouteur.

Dites-moi sur Twitter quels sont les avantages que vous voyez à cette modification du composant Security de Symfony.

Mise à jour d’octobre 2021 : Guard a été déprécié dans Symfony 5.3 pour être remplacé par ce nouveau système. Guard ne sera plus disponible dans Symfony 6.

Win32Service for PHP8

Win32Service Logo
Win32Service Logo

The version 8 of PHP is scheduled for December 2020. All active extension must be upgrading their code to need a run with the new version.

Like the last year for PHP 7.4 Win32Service will be updated.

Actually in development, the version 1.0 of the extension Win32Service will be compliant only with PHP 8. The version 1.0.0-dev is already upgraded and usable for tests. You can download the snapshot build of PHP for Windows (on the bottom of the page) and the Win32Service extension DLL on the GitHub repository.

Currently, version 1.0 of the Win32Service extension is building only for the x64 architecture and for two PHP mode (NTS and TS).

Maintained version

For the future, Win32Service version 0.4 will be maintained for all PHP 7 maintained versions. And Win32Service version 1.0 will be maintained for all PHP 8 maintained versions.

Migration de code vers PHP 8 (partie 1)

Quelques nouveautés de PHP 8 (cœur)

La future version de PHP est encore en cours de développement (sortie prévue pour décembre 2020) mais la liste des nouveautés déjà réalisées est déjà accessible. Elle n’est cependant pas encore figée.

Vous avez surement entendu parler du JIT (Just in time) de PHP 8 mais cela changera-t-il quelque chose à notre quotidien ? Pour certains oui.

Pour la plupart des développeurs, une version majeure implique une migration du code. Dans le cas de l’évolution de notre code à quoi faut-il s’attendre ?

Articles de la série :

Partie 1 : Migration de code vers PHP 8 (partie 1)
Partie 2 : Migration de code vers PHP 8 MBString (partie 2)
Partie 3 : Nouvelle erreur ValueError dans PHP 8 (partie 3)

Les avertissements et avis ont été revus

Dans ce nombreux cas PHP remontait un avertissement ou un avis, nombreux sont ceux qui ont été transformés en erreur.

Un certain nombre d’avertissements ont été convertis en exceptions d’erreur :

  • Tentative d’écriture dans une propriété d’une variable qui n’est pas un objet. Auparavant, cette action crée implicitement un objet stdClass pour les chaînes nulles, fausses et vides.
  • Tentative d’ajout d’un élément dans un tableau pour lequel la clé PHP_INT_MAX est déjà utilisée.
  • Tentative d’utilisation d’un type non valide (tableau ou objet) comme clé de tableau ou décalage de chaîne.
  • Tentative d’écriture dans un index de tableau d’une valeur scalaire.
  • Tentative d’utilisation de la fonction unpack sur une variable qui n’est pas un tableau ou un Traversable.
  • Une erreur d’héritage due à une signature de méthode incompatible génèrera une erreur fatale à la place d’un avertissement PHP RFC : Always generate fatal error for incompatible method signatures.

Un certain nombre d’avis ont été convertis en avertissements :

  • Tentative de lecture d’une variable non définie.
  • Tentative de lecture d’une propriété non définie.
  • Tentative de lecture d’une propriété d’une variable qui n’est pas un objet.
  • Tentative d’accès à un index de tableau d’un non-tableau.
  • Tentative de conversion d’un tableau en chaîne.
  • Tentative d’utilisation d’une ressource comme clé de tableau.
  • Tentative d’utiliser null, un booléen ou un flottant comme décalage de chaîne.
  • Tentative de lecture d’un décalage de chaîne hors limites.
  • Tentative d’affecter une chaîne vide à un décalage de chaîne.

PHP RFC : Reclassifying engine warnings

Les méthodes et comportements dépréciés ont été supprimés

  • La possibilité de supprimer une variable avec le cast (unset) ne sera plus possible.
  • Les constructeurs de classe style PHP 4 ne sont plus utilisable. Il faut remplacer le nom de la méthode constructeur par __construct. La fonction sera une fonction comme une autre.
<?php

class MaClass {
    public function MaClass() {
    }
}

Voir le résultat de l’analyse avec Phan

  • Suppression de l’argument $errcontext pour les gestionnaires d’erreur personnalisée.
  • Les constantes définies par define ne peuvent plus être insensible à la casse (le 3e argument)
  • La fonction create_function a été supprimée. Il faut les remplacer par des fonctions anonymes.
  • L’instruction each a été supprimée. Il faut la remplacer par foreach ou ArrayIterator.
  • L’utilisation de array_key_exists sur les objets n’est plus possible. Il faut remplacer par isset ou property_exists.
  • Suppression de la prise en charge des accolades pour l’accès aux données d’un tableau. PHP RFC: Deprecate curly brace syntax for accessing array elements and string offsets

Conversion d’un nombre à virgule en chaine de caractère

Avant PHP 8 la conversion d’un float en string utilisait le réglage de la locale pour le séparateur de décimales. Ce n’est plus le cas avec PHP 8.

Ceci entrainera des changements de formatage des nombres dans les applications qui utilisaient set_locale.

Modification apportée au Trait

Le code suivant ne fonctionnera plus et génèrera une erreur fatale :

trait T1 {
    function func() {}
}

trait T2 {
    function func() {}
}

class MaClass {
    use T1, T2 {
        func as otherFunc;
    }

    function func() {}
}

Voir le résultat d’analyse avec Phan

Il est nécessaire de déclarer explicitement les alias :

trait T1 {
    function func() {}
}

trait T2 {
    function func() {}
}

class MaClass {
    use T1 {
        func as otherFunc;
    }
    use T2 {
        func as otherFunc2;
    }

    function func() {}
}

Avec PHP 8, les méthodes abstraites présentes dans les traits seront vérifiées. Ainsi le code suivant est invalide :

        trait MyTrait {
            abstract private function neededByTrait(): string;
        }

        class MyClass {
            use MyTrait;

            // Error, because of return type mismatch.
            private function neededByTrait(): int { return 42; }
        }

PHP RFC : Validation for abstract trait methods

Autres modifications entrainant un changement de comportement

  • L’utilisation de « parent » dans une classe sans parent entrainera une erreur fatale lors de la compilation.
  • L’opérateur @ ne supprime plus les erreurs fatales. Il sera nécessaire d’adapter les gestionnaires d’erreur qui utilise error_reporting. Pour détecter une erreur silencieuse, il faut utiliser ce code : if (!(error_reporting() & $err_no)) { return; /* Silenced */ }
  • La priorité de l’opérateur de concaténation a changé par rapport à décalages de bits et addition ainsi que soustraction. Ainsi dans le code suivant l’opération sera réalisée avant la concaténation echo 'Total '. $a + $b;. PHP RFC : Change the precedence of the concatenation operator
  • Les fonctions désactivées sont désormais traitées exactement comme les fonctions inexistantes. L’appel d’une fonction désactivée la signalera comme inconnue et la redéfinition d’une fonction désactivée est désormais possible.
  • Les opérateurs arithmétiques et au niveau du bit (+, -, *, /, **,%, <<, >>, &, |, ^, ~, ++, -) vont maintenant systématiquement lancer une TypeError lorsque l’un des opérandes est un tableau, ressource ou objet non surchargé. La seule exception à cela est l’opération de fusion des tableaux, qui reste prise en charge. PHP RFC : Stricter type checks for arithmetic/bitwise operators

Conclusion

PHP 8 apporte de nombreuses nouveautés certaines attendue et déjà connue, car déprécié dans les précédentes versions de PHP. Utilisons le temps qu’il reste avant la sortie pour tester et corriger nos applications avec cette nouvelle version de PHP.

Nos outils d’aide à la migration tels que PHPCompatibility et Phan sont déjà en cours d’évolution pour tenir compte des nouveautés de PHP 8.

J’ai mis à disposition une version en ligne de PHPCompatibility et Phan sur le site phptools.online permettant le test de bout de code PHP.

Pendant ce temps, les frameworks tels que Symfony ont déjà commencé le travail de correction pour PHP 8.

Restez à l’écoute, je vais réaliser d’autre article sur les modifications apportées par PHP 8 ayant une incidence sur notre code.

Double authentification avec Symfony

Qu’est-ce que la double authentification ?

Tout le monde connaît l’authentification avec un identifiant et un mot de passe. Cependant, ce système est faillible pour plusieurs raisons :

  • Ils sont souvent les mêmes pour plusieurs sites.
  • Ils sont souvent mal stockés
  • Ils sont souvent trop faciles à trouver
  • Pour les mots de passe forts, les retenir tous est compliqué.

Ainsi, pour augmenter la sécurité lors de l’authentification, il est de plus en plus souvent demandé une seconde preuve que vous êtes bien la personne que vous prétendez être.

C’est la double authentification.

Quand la mettre en place ?

En trois mots : tout le temps. Il y a toujours un usage pirate possible pour toutes les pages protégées par une authentification.
Certains trouveront la réponse extrême. Cependant, pourquoi protéger par un mot de passe quelque chose qui n’a aucune valeur ?

Quels sont les différents types de double authentification ?

Beaucoup connaissent la double authentification avec l’envoi d’un message texte sur votre téléphone portable. Il en existe d’autres :

  • L’utilisation d’une clé physique (USB ou sans fil).
  • L’utilisation d’un code temporaire basé sur le temps.
  • L’utilisation d’un code temporaire envoyé à l’utilisateur selon un moyen que l’utilisateur a choisi (message texte, courriel).
  • L’utilisation d’une authentification via une application mobile liée au service en ligne.

Comment implémenter une double authentification en deux étapes avec Symfony ?

Avant de commencer, nous allons mettre en place une double authentification par l’envoi d’un code temporaire par courriel.
Voici le schéma du dialogue entre le navigateur et les serveurs.

Dialogue Double Authentification

La première authentification via un formulaire est déjà implémentée par Symfony. Cependant, il est nécessaire de personnaliser la vérification du mot de passe via le module Guard.

Dans notre Guard, nous allons stopper l’authentification si le mot de passe est correct et rediriger l’utilisateur vers la page de saisie du second facteur.

Avant la redirection, il est nécessaire de sauvegarder le nom de l’utilisateur en cours de connexion et envoyer un code temporaire sur la boîte courriel de l’utilisateur.

Une fois saisi par l’utilisateur, un autre Guard se chargera de vérifier le code saisi et valider la connexion effective de l’utilisateur.

Simple, non ?

Faisons les courses

Pour mettre en place correctement la double authentification, nous avons besoin de :

  • Sauvegarder le nom de l’utilisateur en cours de connexion.
  • Générer un code et l’associer à l’utilisateur en cours de connexion.
  • Définir un délai de réponse et sauvegarder la valeur limite.
  • Vérifier que l’utilisateur ne teste pas trop de codes (brute force).

Si j’oublie une mesure de sécurité, dites-le moi dans les commentaires.

Ajoutons notre second Guard

La classe AppTwoFactorAuthenticator

<?php declare(strict_types=1);

namespace AppSecurity;

use AppEntityUser;
use DoctrineORMEntityManagerInterface;
use SymfonyComponentHttpFoundationRedirectResponse;
use SymfonyComponentHttpFoundationRequest;
use SymfonyComponentHttpFoundationSessionSessionInterface;
use SymfonyComponentRoutingGeneratorUrlGeneratorInterface;
use SymfonyComponentSecurityCoreAuthenticationTokenTokenInterface;
use SymfonyComponentSecurityCoreExceptionCustomUserMessageAuthenticationException;
use SymfonyComponentSecurityCoreExceptionInvalidCsrfTokenException;
use SymfonyComponentSecurityCoreUserUserInterface;
use SymfonyComponentSecurityCoreUserUserProviderInterface;
use SymfonyComponentSecurityCsrfCsrfToken;
use SymfonyComponentSecurityCsrfCsrfTokenManagerInterface;
use SymfonyComponentSecurityGuardAuthenticatorAbstractFormLoginAuthenticator;
use SymfonyComponentSecurityHttpUtilTargetPathTrait;

final class AppTwoFactorAuthenticator extends AbstractFormLoginAuthenticator
{
    use TargetPathTrait;

    public const LOGIN_ROUTE = 'app_two_factor';

    public const USER_SESSION_KEY = 'two_auth_user';
    public const CODE_SESSION_KEY = 'two_auth_code';
    public const TIMEOUT_SESSION_KEY = 'two_auth_timeout';
    public const COUNT_SESSION_KEY = 'two_auth_count';

    private $entityManager;
    private $urlGenerator;
    private $csrfTokenManager;
    /**
     * @var SessionInterface
     */
    private $session;

    public function __construct(
        EntityManagerInterface $entityManager,
        UrlGeneratorInterface $urlGenerator,
        CsrfTokenManagerInterface $csrfTokenManager,
        SessionInterface $session
    ) {
        $this->entityManager = $entityManager;
        $this->urlGenerator = $urlGenerator;
        $this->csrfTokenManager = $csrfTokenManager;
        $this->session = $session;
    }

    public function supports(Request $request)
    {
        return self::LOGIN_ROUTE === $request->attributes->get('_route')
            && $request->isMethod('POST');
    }

    public function getCredentials(Request $request)
    {
        return [
            'email' => $request->getSession()->get(self::USER_SESSION_KEY),
            'count' => $request->getSession()->get(self::COUNT_SESSION_KEY, 1),
            'timeout' => $request->getSession()->get(self::TIMEOUT_SESSION_KEY),
            'password' => $request->request->get('password'),
            'csrf_token' => $request->request->get('_csrf_token'),
        ];
    }

    public function getUser($credentials, UserProviderInterface $userProvider)
    {
        $token = new CsrfToken('authenticate', $credentials['csrf_token']);
        if (!$this->csrfTokenManager->isTokenValid($token)) {
            throw new InvalidCsrfTokenException();
        }

        if (time() > $credentials['timeout']) {
            throw new TwoFactorTimedoutException();
        }

        if ($credentials['count'] >= 3) {
            throw new TwoFactorMaxAttemptReachedException();
        }

        $user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $credentials['email']]);

        if (!$user) {
            // fail authentication with a custom error
            throw new CustomUserMessageAuthenticationException('Email could not be found.');
        }

        return $user;
    }

    public function checkCredentials($credentials, UserInterface $user)
    {
        if ($credentials['password'] === $this->session->get(self::CODE_SESSION_KEY, null)) {
            return true;
        }

        $this->session->set(self::COUNT_SESSION_KEY, $credentials['count'] + 1);

        return false;
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
    {
        $request->getSession()->remove('need_auth_two');
        $request->getSession()->remove(self::USER_SESSION_KEY);
        $request->getSession()->remove(self::CODE_SESSION_KEY);
        $request->getSession()->remove(self::TIMEOUT_SESSION_KEY);
        $request->getSession()->remove(self::COUNT_SESSION_KEY);

        if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) {
            return new RedirectResponse($targetPath);
        }

        return new RedirectResponse('/');
    }

    protected function getLoginUrl()
    {
        return $this->urlGenerator->generate(self::LOGIN_ROUTE);
    }
}

La constante LOGIN_ROUTE contient le nom de la route dédiée à ce Guard. Le contrôleur doit être défini, nous le verrons plus bas.

Cette classe définit 4 constantes qui permettront de stocker les informations utiles dans la session. Vous pouvez tout à fait sauvegarder ces informations en base de données.

Activer le second Guard

Dans le fichier config/packages/security.yaml, ajouter le nom complet de la classe dans la liste des authenticators :

#[...]
    firewalls:
        main:
            guard:
                authenticators:
                    - AppSecurityAppLoginAuthenticator
                    - AppSecurityAppTwoFactorAuthenticator
                entry_point: AppSecurityAppLoginAuthenticator

Maintenant, Symfony a besoin de connaître le Guard principal. Ajouter la clé entry_point avec comme valeur le nom complet de la classe du Guard gérant la connexion par identifiant et mot de passe.

Ajouter le contrôleur

Maintenant, ajouter le contrôleur lié au Guard du second facteur. Ce contrôleur se comporte comme celui du Guard de l’authentification par identifiant et mot de passe avec une différence. Il n’est pas possible de modifier l’utilisateur.

Voici un exemple de la méthode contrôleur :

//Fichier : src/Controller/SecurityController.php

    /**
     * @Route("/two_factor", name="app_two_factor")
     */
    public function twoFactor(SessionInterface $session, CodeGeneratorInterface $codeGenerator, AuthenticationUtils $authenticationUtils): Response
    {
        $error = $authenticationUtils->getLastAuthenticationError();
        if ($session->get(AppTwoFactorAuthenticator::CODE_SESSION_KEY) === null) {
            $error = null;
            $session->set(AppTwoFactorAuthenticator::CODE_SESSION_KEY, $codeGenerator->generate());
            $session->set(AppTwoFactorAuthenticator::TIMEOUT_SESSION_KEY, time() + (60 * 5));
            $session->set(AppTwoFactorAuthenticator::COUNT_SESSION_KEY, 1);
            //Send here the code by email.
        }
        return $this->render('security/two_factor.html.twig', ['error' => $error]);
    }

Ici, nous retrouvons le nom de la route définie dans le Guard pour la double authentification.

Le rôle du contrôleur est d’afficher les erreurs de saisie du code et si le code n’existe pas, il l’initialise et l’envoie par courriel.

Le fichier Twig lié ressemble à ceci :

{% extends 'base.html.twig' %}

{% block title %}
Two factor auth
{% endblock %}

{% block body %}
    <form method="post">
        {% if error %}
            <div class="alert alert-danger">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
        {% endif %}

        <h1 class="h3 mb-3 font-weight-normal">Please confirm your identity</h1>
        <label for="inputPassword">Password</label>
        <input type="password" name="password" id="inputPassword" class="form-control" required>

        <input type="hidden" name="_csrf_token"
               value="{{ csrf_token('authenticate') }}"
        >
        <button class="btn btn-lg btn-primary" type="submit">
            Send code
        </button>
    </form>
{% endblock %}

Activer la double authentification

La seconde authentification est presque prête. Cependant, il n’est pas possible de l’utiliser. Pour l’utiliser, il est nécessaire de modifier la classe Guard liée à l’authentification par utilisateur et mot de passe.

La première modification consiste à indiquer que l’authentification a échoué dans tous les cas. Nous allons cependant indiquer qu’il faut rediriger vers le second facteur d’authentification grâce à une information stockée en session.

Voici le code de la méthode liée à la vérification du mot de passe :

// Fichier : src/Security/AppLoginAuthenticator.php
    public function checkCredentials($credentials, UserInterface $user)
    {
        if ($this->encoder->getEncoder($user)->isPasswordValid($user->getPassword(), $credentials['password'], null)) {
            $this->session->set('need_auth_two', true);
            return false;
        }
        return false;
    }

Maintenant que l’authentification échoue systématiquement, comment renvoyer l’utilisateur ayant saisi son mot de passe correct vers la double authentification ?

Cela se passe dans la fonction getLoginUrl du Guard

// Fichier : src/Security/AppLoginAuthenticator.php
    protected function getLoginUrl()
    {
        if ($this->session->get('need_auth_two', false) === true) {
            return $this->urlGenerator->generate(AppTwoFactorAuthenticator::LOGIN_ROUTE);
        }
        return $this->urlGenerator->generate(self::LOGIN_ROUTE);
    }

Dans le cas où la session contient la clé need_auth_two et que sa valeur est à vrai, alors l’URL retournée est celle correspondant à la page de l’authentification à deux facteurs.

Éviter la réutilisation du code ou la mauvaise redirection

Avec le code tel qu’il est actuellement, il est possible d’être redirigé vers la page de la double authentification alors que le mot de passe est erroné.

Il est également possible de réutiliser un code déjà utilisé si la session n’est pas complètement renouvelée lors de la déconnexion.

Pour éviter ces mauvais effets, nous allons réinitialiser le code et la demande de double authentification lors de la récupération de l’identifiant et du mot de passe saisis par l’utilisateur.

Voici donc le contenu de la méthode getCredentials du Guard AppLoginAuthenticator :

// Fichier : src/Security/AppLoginAuthenticator.php
    public function getCredentials(Request $request)
    {
        $credentials = [
            'email' => $request->request->get('email'),
            'password' => $request->request->get('password'),
            'csrf_token' => $request->request->get('_csrf_token'),
        ];
        $request->getSession()->set(
            Security::LAST_USERNAME,
            $credentials['email']
        );
        $request->getSession()->set(
            AppTwoFactorAuthenticator::USER_SESSION_KEY,
            $credentials['email']
        );

        $request->getSession()->set(AppTwoFactorAuthenticator::CODE_SESSION_KEY, null);
        $request->getSession()->set('need_auth_two', false);

        return $credentials;
    }

C’est également ici que nous sauvegardons le nom d’utilisateur saisi pour être utilisé lors de la double authentification.

Conclusion

La double authentification est maintenant prête à minima. Il reste encore quelques améliorations telles que :

  • Utiliser un événement pour déclencher la génération du code et son envoi.
  • Permettre le changement d’utilisateur lors de la double authentification.

Mais cela fera peut-être l’objet d’un autre billet de blog.

Dites-moi dans les commentaires comment vous avez implémenté la double authentification sur vos projets. Je suis à votre service si vous avez des questions.

PS J’ai utilisé ce projet pour écrire ce billet de blog.

[Tutoriel] Test Behat sur un projet Symfony

https://www.pxfuel.com/en/free-photo-xzywf
https://www.pxfuel.com/en/free-photo-xzywf

But de ce tutoriel

Après vous avoir présenté le design pattern PageObject dans un précédent billet de blog, nous allons voir ensemble comment le mettre en pratique sur un projet Web.

Ici nous vérifions que toutes les briques de l’application fonctionnent ensemble correctement. Nous allons utiliser le driver Symfony de Mink pour permettre la simulation des requêtes HTTP et la récupération des réponses.

J’ai utilisé ce projet pour écrire ce billet de blog.

Installation

Nous allons avoir besoin de plusieurs dépendances uniquement pour le développement.

La première est Behat lui-même. Ensuite Doctrine data fixtures permettra de réinitialiser la base de données entre chaque scenarii.
Puis viennent les extensions pour Behat telles que MinkExtension et SymfonyExtension. Mink BrowserKit Driver est requise par MinkExtension sans être requise dans son composer.json.

$ composer require --dev behat/behat doctrine/data-fixtures friends-of-behat/mink-browserkit-driver friends-of-behat/mink-extension friends-of-behat/page-object-extension friends-of-behat/symfony-extension

Si vous êtes sur la branche master du projet, Flex vous demandera la confirmation de l’application de la recette du packet friends-of-behat/symfony-extension. Il est préférable d’accepter.

Config

Le fichier behat.yml.dist a été ajouté à la racine du projet.

Modifier son contenu pour qu’il ressemble à celui-ci :

default:
    suites:
        default:
            contexts:
                - App\Behat\DemoContext

    extensions:
        Behat\MinkExtension:
            base_url: "https://localhost/"
            default_session: symfony
            sessions:
                symfony:
                    symfony: ~
        FriendsOfBehat\SymfonyExtension: null

Ici nous activons et configurons les extensions installées précédemment.

Pour permettre la suite des opérations, nous avons besoin de modifier le fichier config/services_test.yaml.

Ce fichier contient la configuration des services spécifiques à l’environnement de test.

Nous y définissons des alias, dont celui du générateur de code de double authentification pour le permettre d’être prévisible.

services:
    _defaults:
        autowire: true
        autoconfigure: true

    Doctrine\Persistence\ObjectManager: '@doctrine.orm.default_entity_manager'

    App\Security\CodeGeneratorInterface: '@App\Tests\Service\DefinedCodeGenerator'

    App\Tests\Service\:
        resource: '../tests/Service/*'

    App\Tests\Functional\:
        resource: '../tests/Functional/*'
        exclude: '../tests/Functional/{Page}'

    App\Tests\Functional\Page\:
        resource: '../tests/Functional/Page/*'
        parent: 'FriendsOfBehat\PageObjectExtension\Page\SymfonyPage'
        autoconfigure: false
        public: false
        autowire: true

    FriendsOfBehat\PageObjectExtension\Page\Page:
        abstract: true
        autoconfigure: false
        public: false
        autowire: true
        arguments:
            - '@behat.mink.default_session'
            - '@behat.mink.parameters'

    FriendsOfBehat\PageObjectExtension\Page\SymfonyPage:
        abstract: true
        autoconfigure: false
        public: false
        autowire: true
        parent: 'FriendsOfBehat\PageObjectExtension\Page\Page'
        arguments:
            - '@router'

Comme vous pouvez le voir, deux services abstraits sont définis pour les PageObject. Le service SymfonyPage sera utilisé par la configuration de tous les services présents dans le dossier tests/Functional/Page.

Notre projet est configuré.

Initialiser l’arborescence

Comme expliqué dans mon premier blog post’ sur le sujet, nous allons ajouter les dossiers qui contiendront les contextes et les PageObject.

tests
├── bootstrap.php
├── Functional
│   ├── Context
│   │   ├── Task
│   │   └── TodoList
│   ├── Hook
│   ├── Page
│   │   ├── Task
│   │   └── TodoList
│   └── Setup
└── Service

Dans le cas de mon projet, j’ai besoin de modifier le comportement du générateur de code de double authentification pour le rendre prévisible. C’est pour cela que le dossier Service a été ajouté.

Implémenter le service de génération de code

Dans le cas des tests, l’utilisation du hasard n’est pas possible. C’est pourquoi nous allons réimplémenter une interface pour définir un service spécifique au fonctionnement des tests.

Ajouter le fichier tests/Service/DefinedCodeGenerator.php avec son contenu :

<?php

declare(strict_types=1);

namespace App\Tests\Service;

use App\Security\CodeGeneratorInterface;

class DefinedCodeGenerator implements CodeGeneratorInterface
{

    public function generate(): string
    {
        return '1234';
    }
}

Ajouter les scenarii de test

Comme les tests utilisent le langage naturel, il est préférable de commencer par l’écriture des tests.

Ajouter le fichier features/Login.feature avec le contenu suivant :

#language: fr
Fonctionnalité: Connexion à l'application

  Contexte:
    Etant donné que l'utilisateur "todo@me.fr" est enregistré avec le mot de passe "MonSuperPassWord"

  Scénario: Connexion
    Etant donné que je suis sur la page de connexion
    Lorsque je me connecte en tant que "todo@me.fr" avec le mot de passe "MonSuperPassWord"
    Alors je dois être sur la page de double authentification
    Lorsque je saisis le code de double authentification
    Alors je dois être sur la liste des todo listes

Supprimer le fichier demo.feature.

Aucune des phrases utilisées dans le scénario n’est connue de Behat.

Contexte d’initialisation

La section Contexte: de la fonctionnalité permet la réalisation d’action avant chaque scénario. Cela permet d’éviter les répétitions en début de scénario.

La seule phrase présente permet d’ajouter un utilisateur et son mot de passe dans la base de données.

Comme il s’agit de mise en place des données utiles aux tests, nous sommes donc dans un contexte Setup et, comme cela concerne un utilisateur, nous allons donc ajouter le UserContext.

Ajouter le fichier tests/Functional/Setup/UserContext.php avec son contenu :

<?php declare(strict_types=1);

namespace App\Tests\Functional\Setup;

use App\Entity\User;
use Behat\Behat\Context\Context;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;

class UserContext implements Context
{
    /**
     * @var ObjectManager
     */
    private $objectManager;
    /**
     * @var EncoderFactoryInterface
     */
    private $encoderFactory;

    public function __construct(ObjectManager $objectManager, EncoderFactoryInterface $encoderFactory)
    {
        $this->objectManager = $objectManager;
        $this->encoderFactory = $encoderFactory;
    }

    /**
     * @Given /^l'utilisateur "([^"]+)" est enregsitré avec le mot de passe "([^"]+)"$/
     */
    public function registerUser($email, $password)
    {
        $user = new User();
        $user->setEmail($email);
        $user->setPassword($this->encoderFactory->getEncoder($user)->encodePassword($password, null));
        $this->objectManager->persist($user);
        $this->objectManager->flush();
    }

}

La phrase est maintenant définie grâce à une expression rationnelle ayant des parenthèses capturant là où nous souhaitons récupérer des informations.

Pour permettre l’utilisation de cette phrase dans les scénarii de test, il est nécessaire d’ajouter le namespace de cette classe dans la configuration de Behat :

default:
    suites:
        default:
            contexts:
                - App\Tests\Functional\Setup\UserContext

Maintenant, l’utilisateur sera ajouté avant l’exécution du scénario.

Contexte d’action

Pour les autres actions, nous allons définir un contexte par page. Cela permet de garder les choses rangées.

Les contextes seront enrichis au fur et à mesure que les tests seront écrits.

Action sur la page de connexion

Dans ce contexte, deux phrases seront définies : celle ouvrant la page de connexion et celle réalisant la connexion d’un utilisateur.

Voici le contenu de ce contexte :

// Fichier : tests/Functional/Context/LoginContext.php
<?php declare(strict_types=1);

namespace App\Tests\Functional\Context;

use App\Tests\Functional\Page\LoginPage;
use Behat\Behat\Context\Context;

class LoginContext implements Context
{
    /**
     * @var LoginPage
     */
    private $loginPage;

    public function __construct(LoginPage $loginPage)
    {
        $this->loginPage = $loginPage;
    }

    /**
     * @Given je suis sur la page de connexion
     */
    public function jeSuisSurLaPageDeConnexion()
    {
        $this->loginPage->open();
    }

    /**
     * @When /^je me connecte en tant que "([^"]+)" avec le mot de passe "([^"]+)"$/
     */
    public function connexion($email, $password)
    {
        $this->loginPage->login($email, $password);
    }
}

Vous voyez ainsi que le contexte ne contient pas beaucoup de code. Il ressemble à un adaptateur.

Voici le service PageObjet qui est implémenté dans la classe LoginPage :

// Fichier : tests/Functional/Page/LoginPage.php
<?php declare(strict_types=1);

namespace App\Tests\Functional\Page;

use FriendsOfBehat\PageObjectExtension\Page\SymfonyPage;

class LoginPage extends SymfonyPage
{
    public function getRouteName(): string
    {
        return 'app_login';
    }

    public function login($user, $password)
    {
        $this->open();
        $this->getDocument()->fillField('email', $user);
        $this->getDocument()->fillField('password', $password);
        $this->getDocument()->pressButton('Sign in');
    }
}

Ici également, il n’y a pas beaucoup de code. La fonction open est gérée par la classe SymfonyPage.

La méthode login ouvre la page de login, renseigne le formulaire de connexion puis le valide.

Pour activer ce contexte, ajoutons-le à la configuration de Behat.

# fichier : behat.yml.dist
default:
    suites:
        default:
            contexts:
                - App\Tests\Functional\Context\LoginContext

Action sur la page du second facteur

Maintenant que nous avons réalisé la prise en charge de la page de connexion, il est maintenant nécessaire de gérer la page de la double authentification.

Comme il s’agit d’une nouvelle page, nous allons donc ajouter un nouveau contexte et un nouveau service PageObjet étendant SymfonyPage.

// Fichier : tests/Functional/Context/TwoFactorContext.php
<?php declare(strict_types=1);

namespace App\Tests\Functional\Context;

use App\Tests\Functional\Page\TwoFactorPage;
use Behat\Behat\Context\Context;

class TwoFactorContext implements Context
{
    /**
     * @var TwoFactorPage
     */
    private $twoFactorPage;

    public function __construct(TwoFactorPage $twoFactorPage)
    {
        $this->twoFactorPage = $twoFactorPage;
    }

    /**
     * @When je saisis le code de double authentification
     */
    public function saisieDoubleAuth()
    {
        $this->twoFactorPage->sendCode('1234');
    }

    /**
     * @Then je dois être sur la page de double authentification
     */
    public function verifPageTwoFactor()
    {
        $this->twoFactorPage->verify();
    }
}
// Fichier : tests/Functional/Page/TwoFactorPage.php
<?php declare(strict_types=1);

namespace App\Tests\Functional\Page;

use FriendsOfBehat\PageObjectExtension\Page\SymfonyPage;

class TwoFactorPage extends SymfonyPage
{
    public function getRouteName(): string
    {
        return 'app_two_factor';
    }

    public function sendCode($code)
    {
        $this->verify();

        $this->getDocument()->fillField('password', $code);
        $this->getDocument()->pressButton('Send code');
    }
}

Également, ce contexte doit être ajouté dans la configuration de Behat pour être utilisable :

# fichier : behat.yml.dist
default:
    suites:
        default:
            contexts:
                - App\Tests\Functional\Context\LoginContext
                - App\Tests\Functional\Context\TwoFactorContext

Action sur la liste des ToDo listes

Maintenant, il reste une phrase non définie dans le scénario. Elle sera définie dans le contexte lié aux listes de ToDo Liste.

Comme pour les deux contextes précédents, nous en ajouterons un troisième IndexContext avec son PageObject IndexPage. Pour ne pas les confondre avec les autres, ils sont placés dans un dossier dédié aux fonctionnalités liées aux ToDo Listes.

// Fichier : tests/Functional/Context/TodoList/IndexContext.php
<?php declare(strict_types=1);

namespace App\Tests\Functional\Context\TodoList;

use App\Tests\Functional\Page\TodoList\IndexPage;
use Behat\Behat\Context\Context;

class IndexContext implements Context
{
    /**
     * @var IndexPage
     */
    private $indexPage;

    public function __construct(IndexPage $indexPage)
    {
        $this->indexPage = $indexPage;
    }

    /**
     * @Then je dois être sur la liste des todo listes
     */
    public function jeDoisEtreSurLaListeDesTodoListes()
    {
        $this->indexPage->verify();
    }
}

Même si l’objet page contient peu de code, il est là pour disposer des fonctions prédéfinies par la classe SymfonyPage.

// Fichier : tests/Functional/Page/TodoList/IndexPage.php
<?php declare(strict_types=1);

namespace App\Tests\Functional\Page\TodoList;

use FriendsOfBehat\PageObjectExtension\Page\SymfonyPage;

class IndexPage extends SymfonyPage
{
    public function getRouteName(): string
    {
        return 'todo_list_index';
    }
}

Également, ce contexte doit être ajouté dans la configuration de Behat pour être utilisable :

# fichier : behat.yml.dist
default:
    suites:
        default:
            contexts:
                - App\Tests\Functional\Context\LoginContext
                - App\Tests\Functional\Context\TwoFactorContext
                - App\Tests\Functional\Context\TodoListIndexContext

Contexte avant chaque scénario

Nous n’avons pour l’instant qu’un seul test. Le besoin de réinitialiser la base de données ne se fera sentir que lors de la prochaine exécution.

En effet, l’utilisateur existant déjà en base de données, une erreur SQL sera émise lors de la tentative d’ajout de l’utilisateur en base.

Pour éviter cela, il est possible d’exécuter du code automatiquement avant chaque scénario.

Ajouter le fichier tests/Functional/Hook/DoctrineContext.php avec son contenu :

<?php

declare(strict_types=1);

namespace App\Tests\Functional\Hook;

use Behat\Behat\Context\Context;
use Doctrine\Common\DataFixtures\Purger\ORMPurger;
use Doctrine\ORM\EntityManagerInterface;

class DoctrineContext implements Context
{
    /** @var EntityManagerInterface */
    private $entityManager;

    public function __construct(EntityManagerInterface $entityManager)
    {
        $this->entityManager = $entityManager;
    }

    /**
     * @BeforeScenario
     */
    public function purgeDatabase()
    {
        $this->entityManager->getConnection()->getConfiguration()->setSQLLogger(null);
        $purger = new ORMPurger($this->entityManager);
        $purger->purge();
        $this->entityManager->clear();
    }
}

Pour l’activer, il est nécessaire d’ajouter ce contexte aux autres dans la configuration de Behat behat.yml.dist.

default:
    suites:
        default:
            contexts:
                - App\Tests\Functional\Hook\DoctrineContext

L’annotation @BeforeScenario indique à Behat qu’il faut exécuter cette fonction avant tous les scénarii.

Conclusion

Nous avons ajouté des contextes, configuré les services utiles pour les tests, configuré Behat, écrit le scénario de test et les définitions de chaque phrase. Il est maintenant possible d’exécuter les tests et de poursuivre l’écriture des scénarii.

Cette petite mise en pratique du design pattern PageObjet ne montre malheureusement pas tout son potentiel. En transposant ce procédé sur vos projets et vos besoins métier, vous pouvez entrevoir le potentiel d’une telle solution.

Je n’exclus pas la réalisation d’un ou plusieurs autres billets de blog sur le sujet pour montrer le potentiel de ce design pattern.

Dites-moi dans les commentaires si vous avez déjà utilisé ce pattern sur vos projets. Je suis à votre service si vous avez des questions.

Mise à jour du 26 janvier 2021: Les antislash des noms de classe ont été remis.