Archives de catégorie : Symfony

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.

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.

Et vous,

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

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

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:
                - AppBehatDemoContext

    extensions:
        BehatMinkExtension:
            base_url: "https://localhost/"
            default_session: symfony
            sessions:
                symfony:
                    symfony: ~
        FriendsOfBehatSymfonyExtension: 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

    DoctrinePersistenceObjectManager: '@doctrine.orm.default_entity_manager'

    AppSecurityCodeGeneratorInterface: '@AppTestsServiceDefinedCodeGenerator'

    AppTestsService:
        resource: '../tests/Service/*'

    AppTestsFunctional:
        resource: '../tests/Functional/*'
        exclude: '../tests/Functional/{Page}'

    AppTestsFunctionalPage:
        resource: '../tests/Functional/Page/*'
        parent: 'FriendsOfBehatPageObjectExtensionPageSymfonyPage'
        autoconfigure: false
        public: false
        autowire: true

    FriendsOfBehatPageObjectExtensionPagePage:
        abstract: true
        autoconfigure: false
        public: false
        autowire: true
        arguments:
            - '@behat.mink.default_session'
            - '@behat.mink.parameters'

    FriendsOfBehatPageObjectExtensionPageSymfonyPage:
        abstract: true
        autoconfigure: false
        public: false
        autowire: true
        parent: 'FriendsOfBehatPageObjectExtensionPagePage'
        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 AppTestsService;

use AppSecurityCodeGeneratorInterface;

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 AppTestsFunctionalSetup;

use AppEntityUser;
use BehatBehatContextContext;
use DoctrinePersistenceObjectManager;
use SymfonyComponentSecurityCoreEncoderEncoderFactoryInterface;

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:
                - AppTestsFunctionalSetupUserContext

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 AppTestsFunctionalContext;

use AppTestsFunctionalPageLoginPage;
use BehatBehatContextContext;

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 AppTestsFunctionalPage;

use FriendsOfBehatPageObjectExtensionPageSymfonyPage;

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:
                - AppTestsFunctionalContextLoginContext

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 AppTestsFunctionalContext;

use AppTestsFunctionalPageTwoFactorPage;
use BehatBehatContextContext;

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 AppTestsFunctionalPage;

use FriendsOfBehatPageObjectExtensionPageSymfonyPage;

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:
                - AppTestsFunctionalContextLoginContext
                - AppTestsFunctionalContextTwoFactorContext

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 AppTestsFunctionalContextTodoList;

use AppTestsFunctionalPageTodoListIndexPage;
use BehatBehatContextContext;

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 AppTestsFunctionalPageTodoList;

use FriendsOfBehatPageObjectExtensionPageSymfonyPage;

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:
                - AppTestsFunctionalContextLoginContext
                - AppTestsFunctionalContextTwoFactorContext
                - AppTestsFunctionalContextTodoListIndexContext

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 AppTestsFunctionalHook;

use BehatBehatContextContext;
use DoctrineCommonDataFixturesPurgerORMPurger;
use DoctrineORMEntityManagerInterface;

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:
                - AppTestsFunctionalHookDoctrineContext

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.

Behat et SymfonyPage

Behat et Mink

Behat est un outil de test fonctionnel dont les scénarii sont écrits en langage commun grâce à Gerkin.
Le concept est simple, le test est divisé en 3 parties « Étant donné que » pour donner les conditions préalables, « Lorsque » pour indiquer les actions réalisées pour le test, puis « Alors » pour vérifier que les actions réalisées ont produit le résultat souhaité.

Chaque ligne du test est une phrase complète qui commence par un mot-clé. Dans cette phrase, des éléments peuvent servir de données utiles pour le test.

Chaque phrase est associée à une méthode PHP grâce aux classes de contexte. La phrase est située dans une annotation au-dessus de la fonction à appeler. L’annotation peut contenir une expression rationnelle ou une phrase avec des placeholders commençant par deux points. Chaque parenthèse de capture de l’expression rationnelle ou placeholder sera un argument de la méthode PHP appelée pour exécuter la phrase du test.

Ex :

/**
 * @Then je dois voir le texte :texte
 */
public function jeDoisVoirLeTexte($texte)
{
}

C’est ainsi que les tests peuvent être exécutés.

Afin de ne pas se répéter, les contextes sont réutilisables.

Pour tester des pages Web HTML, l’utilisation de Mink est nécessaire. Il dispose de pilote (driver) pour différentes situations.

En voici quelques-uns, chacun avec ses avantages et inconvénients :

  • Sans serveur Web et Navigateur (extension Symfony) : Simule un objet Request et récupère l’objet Response.
  • Avec un serveur Web, mais sans Navigateur (extension Goutte) : Réalise de vraies requêtes HTTP via un client HTTP sur un serveur Web et obtient une réponse HTTP. Seul le JavaScript n’est pas exécuté.
  • Avec un serveur Web, mais avec un Navigateur (extension Selenium) : Pilote un navigateur Web sans fenêtre pour réaliser les actions sur le site Web et vérifier le résultat. Ces tests sont particulièrement longs à exécuter.

Pour éviter l’écriture de fonctions complexes dans les contextes, Mink abstrait les drivers. Le cas des tests avec JavaScript est particulier, car le pilote est impatient et il génère de faux échecs de test. L’ajout de délais dans les fonctions du contexte est nécessaire.

C’est ici que les choses se compliquent. Eviter l’écriture de contexte est possible grâce au contexte proposé par Mink. Cependant, vos tests ne ressembleront pas à de vrais tests fonctionnels, car ils contiendront des classes CSS, des xPath dans vos phrases.

Un autre point est qu’une phrase ne peut être déclarée qu’une seule fois et chaque contexte est indépendant des autres. Cela bloque l’héritage des contextes par défaut de Mink pour un seul contexte. Votre contexte gonflera pour contenir tous les cas d’actions sur les pages.

Concept de PageObject

Comment diviser cet énorme contexte en contextes plus petits et surtout mieux organisés ? Comment permettre l’écriture d’un contexte par page de l’application ou par concept ?

La première approche serait d’utiliser le RawMinkContext qui apporte le strict minimum des dépendances nécessaires pour écrire des cas de test et d’actions.
Cependant, mutualiser du code utile pour plusieurs phrases ne sera pas possible. Pour cela, l’utilisation des services externes au contexte est nécessaire.

Le pattern PageObject est venu résoudre ce problème. Nous pouvons en profiter dans Behat soit par l’extension SensioLab soit par l’extension du groupe Friend of Behat.

Mais qu’est-ce donc ?

Ce sont des services qui ont une dépendance vers Mink et qui permettront de réaliser des actions sur la page actuellement chargée. Un service PageObject permet de regrouper toutes les actions possibles d’une page ou d’un élément commun du site (le concept d’Element est plus adapté dans ce cas).

Le service PageObject expose les actions disponibles et les données à récupérer sur la page pour laquelle il est dédié. Il ne fait pas d’assertion (ou très très peu).

Ce service est ensuite injecté dans le service de contexte afin de pouvoir interagir avec la page.

L’extension proposée par Friends Of Behat propose un concept de SymfonyPage qui utilise le routeur Symfony pour appeler la page (donc, générer l’URL) et vérifier que le service PageObject va réaliser une action sur la bonne page.

C’est donc dans ce service PageObject que nous allons manipuler les éléments techniques de la page Web.

Conséquence dans les Context

La première conséquence est la possibilité de séparer les définitions des phrases en plusieurs contextes indépendants.

La seconde conséquence est la possibilité d’organiser les contextes en fonction des modules de l’application et sortir ceux relatifs à l’infrastructure telle que les hook Behat et les contextes liés à la préparation de la base de données pour le test.

Ainsi, voici une architecture possible pour le dossier de tests fonctionnels :

tests
└── Functional
    ├── Context
    │   ├── Admin
    │   │   ├── LoginContext.php
    │   │   ├── NotificationContext.php
    │   │   ├── Module1
    │   │   │   └── Module1Context.php
    │   │   └── Module2
    │   │       ├── IndexModule2Context.php
    │   │       └── ShowModule2Context.php
    │   └── Module2
    │       ├── DashboardContext.php
    │       └── LoginContext.php
    ├── Hook
    │   └── FixtureContext.php
    ├── Page
    │   ├── Admin
    │   │   ├── Account
    │   │   │   └── LoginPage.php
    │   │   ├── Module1
    │   │   │   └── IndexPage.php
    │   │   └── Module2
    │   │       └── ShowPage.php
    │   └── Module2
    ├── Setup
    │   └── MonEntiteContext.php
    └── Transform
        └── QuantityContext.php

Certains auront peut-être trouvé la source d’inspiration de cette organisation. C’est celle utilisée dans les tests du projet de e-commerce Sylius.

Enfin, une conséquence non négligeable est la nécessité d’écrire plus de fichiers PHP et de code pour réaliser les tests. Mais cette conséquence disparaît vite avec la mise en pratique.

Bénéfices

Les utilisateurs réguliers des tests fonctionnels avec Behat et Mink seront sûrement septiques. Mais n’avez-vous jamais eu besoin de dupliquer du code pour vérifier qu’une notification est bien présente et contient le texte souhaité ?

Cette façon de faire répond exactement à ce cas. Vous pouvez définir un service PageObject (ou plutôt Element dans ce cas) qui se concentrera sur la vérification des différentes notifications possibles. Vous l’injecterez dans tous les contextes qui en ont besoin dans l’exécution d’une phrase. Cela évitera des phrases comme « je dois voir la notification… » qui ne sont pas forcément utiles.

Un autre bénéfice des PageObject est de pouvoir injecter le service dans plusieurs contextes et ainsi éviter la duplication de code.

à vous de jouer !

Qu’en pensez-vous ? Comment réalisez-vous vos tests fonctionnels ?

Win32Service Bundle

Avec la sortie de la version 0.4 de l’extension Win32Service pour PHP, un nouveau bundle a été rendu disponible. Le but de ce bundle est de faciliter l’intégration de la librairie PHP « service-library » dans Symfony.

Ce bundle permet de limiter le travail nécessaire pour utiliser l’extension et ainsi rendre le travail plus rapide.

Voici un exemple. Vous disposez actuellement d’une commande Symfony exécutée via les tâches planifiées toutes les 2 minutes. Cette commande contient une boucle qui répète x fois le même traitement.

Voici les étapes pour transformer votre commande en service:
1 – ajouter le bundle à votre projet.
2 – ajouter la configuration pour votre service dans le fichier ‘config/package/win32_service.yaml’
3 – écrire une classe qui étant la classe abstraite « Win32Service\Model\AbstractServiceRunner »
4 – placer dans la fonction « run » le code nécessaire pour l’exécution d’un traitement.
5 – définir votre classe comme un service dans la configuration de Symfony avec le tag « win32service.runner » et un alias contenant la même valeur que la clé ‘service_id’ définit dans la configuration du bundle.

Afin de vous guider, voici un exemple concret d’un projet Symfony configuré.
1 – Le bundle a été ajouté dans le fichier ‘composer.json‘ et activé dans le fichier ‘config/bundles.php‘.
2 – Le fichier de configuration ‘config/packages/win32_service.yaml‘ a été ajouté avec un service.
3 – La classe ‘App\WindowsService\FirstService‘ étant la classe abstraite.
4 – La classe est présente dans un dossier dont toutes les classes seront considérées comme des services par Symfony. Il est uniquement nécessaire d’ajouter le tag pour ce service dans le fichier ‘config/services.yaml‘.

Il ne reste plus qu’à écrire le code et faire en sorte que la durée d’exécution de la méthode ‘run’ ne dépasse pas 30s. Cette limite est imposée par Microsoft pour toutes les actions d’un service. Le délai est également de 30 secondes pour le démarrage, la mise en pause, la reprise et l’arrêt d’un service.

Si vous avez des questions ou des suggestions, ou tout simplement un commentaire, je serai content de vous lire.

[Updated] DDD with Broadway and the Design Pattern State

Note : This article is the translate of this article. I use Google Translate for help me to write in english. Please, if you read wrong phrase send me the correct by one comment.

In a refactoring sprint, I found that my main aggregate class took much overweight.

I had exceeded 750 lines of code with, in many actions, a « switch » or a dozen « IF ». This did not please me very much because, if a change was required with this level, the amendment would be difficult.

Continuer la lecture de [Updated] DDD with Broadway and the Design Pattern State

[MàJ] DDD avec Broadway et le Design pattern State

[English version]

Au cours d’un petit sprint de refactorisation, j’ai constaté que la classe de mon principal agrégat prenait beaucoup d’embonpoint.

J’avais dépassé les 750 lignes de code avec, dans beaucoup d’actions, soit un “switch » soit une petite dizaine de “IF”. Cela ne me plaisait pas beaucoup car, si une modification était demandée à ce niveau-là, la modification serait délicate.

Continuer la lecture de [MàJ] DDD avec Broadway et le Design pattern State

[Updated] Symfony, Broadway and the replay event

Note : This article is the translate of this article. I use Google Translate for help me to write in english. Please, if you read wrong phrase send me the correct by one comment.

On of the main advantages of EventSourcing is the replay event for build a new view database or sync the an old view.

Do have you ever wondered how to go about not send emails on events issued by the aggregates ?

In my case, the dilemma is rather important because many things are based on events. If they are replayed, all treatments are rerun.

In my project, I use Broadway (by QandidateLabs) for implementing the CQRS/ES.

For use the default event bus, you must tag any services with `broadway.domain.event_listener` and extend this class `Broadway\Processor\Processor`.
All services will be injected into the event bus by a compiler pass. The latter takes 3 arguments: the event bus service identifier, the tag used for get all services to inject her, and the interface name that services must implement to be injected.

You can reuse the code to add a new event bus without write more code.

3 steps for separate into new bus all services necessary for refresh view.

1) Add a new event bus

# src/Vendor/Bundle/NameBundle/Ressources/config/services.yml
service:
     mon_bundle.event_handling.event_bus:
          class: Broadway\EventHandling\SimpleEventBus
          lazy: true

2) Add new tag for all services to update the view. By exemple `mon_bundle.domain.event_listener`.
3) Run new compiler pass with new parameters

// src/Vendor/Bundle/NameBundle/NameBundle.php
use Broadway\Bundle\BroadwayBundle\DependencyInjection\RegisterBusSubscribersCompilerPass;
$container->addCompilerPass(
     new RegisterBusSubscribersCompilerPass(
          'mon_bundle.event_handling.event_bus',
          'mon_bundle.domain.event_listener',
          'Broadway\EventHandling\EventListenerInterface'
     )
);

This is a SOLID code.

Your event listener must also be. One listener one responsibility.

Finally, during the retransmission of events, simply send them on the bus dedicated to replay events to perform the update only the view.
That’s done! You can retransmit all the events to update the view without having to change the configuration of the production application to prevent the resend of the emails.

Get the source code on github.

Now you can share your experiences in the comments.

[MàJ] Symfony, Broadway et le replay d’event

[English version]

L’un des principaux atouts qui reviennent souvent lorsque l’on parle de l’EventSourcing est la réémission des évènements afin de reconstruire la vue (par exemple). Cela peut être pour la construction d’une nouvelle base de vue ou le rafraichissement de la vue désynchronisée.

Ne vous est-il jamais arrivé de vous demander comment s’y prendre pour ne pas renvoyer les emails basés sur les évènements émis par les agrégats ?

Continuer la lecture de [MàJ] Symfony, Broadway et le replay d’event

Mettre à jour un projet Symfony 2

Dans cet article qui me servira d’aide mémoire, je vais tenter de vous présenter ma méthode de mise à jour d’un projet Symfony 2.

Cette méthode vaut ce qu’elle vaut mais je n’ai rien trouvé de tel sur internet ! Même en anglais.

Plantons le décor

Nous avons un projet qui fonctionne avec Symfony 2.2.11 et nous souhaitons le faire passer sur la version LTS de Symfony 2 soit la version 2.3.x. En effet, la branche 2.2 n’est plus maintenue.

Comme de nombreux projets, j’utilise des librairies et bundles tiers gérés via l’utilitaire Composer.

Avant de rentrer dans le vif du sujet et, comme je compte pouvoir revenir en arrière, je commence par installer mon application dans un nouveau dossier. L’application doit fonctionner parfaitement avant la mise à jour.

Maintenant que nous avons notre application installée, voyons comment mettre à jour les librairies.

Récupération de la version cible

 

Sur le site de l’éditeur de Symfony, je commence par télécharger la version souhaitée du framework sans les vendors (without vendors). Ou je télécharge depuis le dépôt GitHub de la standard edition pour Symfony jusqu’à 3.4 ou le dépôt GitHub du Website Skeleton pour Symfony 4 et plus.

Dans l’archive, je récupère le fichier « composer.json » afin de le comparer à celui du projet.

Lors de la comparaison, je modifie le fichier « composer.json » de mon projet et modifie les versions des packages.

Mise à jour des librairies tierces

Pour les librairies tierces, j’utilise le site packagist pour vérifier la compatibilité de chaque version utilisée. Au besoin, je recherche la version compatible et je change également la version dans le fichier « composer.json » du projet.

A chaque changement de version pour une librairie tierce, je vérifie que le fonctionnement n’a pas changé. Si c’est le cas, il faudra mettre à jour mon code.

Téléchargement des mises à jour

 

Dans un terminal, je me place à la racine du site et j’exécute la commande qui peut faire peur :

$ php composer.phar update

Et oui, cette commande va chercher à mettre à jour toutes les librairies. L’exécution est longue et lorsque le téléchargement commence, un soulagement se fait sentir. Aucun conflit n’a été trouvé; ça ne veut pas dire qu’il n’y aura pas d’erreur(s) lors de l’exécution.

Test des nouvelles librairies

 

Une fois les nouvelles versions téléchargées et installées, commence la phase de débug !

La première chose que je fais est de vider les caches en supprimant les dossiers présents dans le dossier app/cache (var/cache depuis Symfony 3). Puis, dans la console, j’exécute les commandes suivantes :

$ php app/console cache:warmup

$ php app/console cache:warmup –env=prod

 

Si aucune erreur n’est présente à ce moment-là, vous n’avez pas d’erreur au niveau des annotations.

Voici celle que j’ai eue :

@Assert\MaxLength(100) est remplacé par @Assert\Length(max=100)

 

Si vous utilisez le bundle JMS/SerializerBundle et si vous êtes passés de la version 1.x à la version 2.0, les annotations ont changé de place. Il faut donc changer toutes les instructions « use » de vos fichiers.

 

Maintenant que le kernel démarre, passons aux choses sérieuses.

 

Avez-vous écrit vos tests unitaires et fonctionnels ? Non, c’est une mauvaise idée mais il n’est pas trop tard pour le faire.

 

Pour détecter toutes les erreurs, je teste le logiciel dans les moindres détails. A chaque erreur rencontrée, je commence par écrire les tests unitaires et fonctionnels permettant de générer l’erreur. Le test échouera bien sûr mais sera concluant une fois la correction apportée.

 

Pourquoi perdre du temps à écrire des tests ? Et bien pour en gagner plus tard. Un test écrit pour un bug permet d’éviter son retour !

 

Car une fois que tous ces tests seront écrits, vous les exécuterez avant chaque livraison pour vérifier que tout fonctionne bien. Il n’est pas exclu qu’un bug passe au travers du filet.

 

Mise en production

 

Après quelques heures de travail, le logiciel fonctionne bien. Que reste-il à faire ?

Sauvegarder les corrections du code dans le gestionnaire de codes sources ainsi que les fichiers « composer.json » et « composer.lock ».

Ce dernier est le précieux sésame pour la mise en production. C’est grâce à lui et la commande suivante que l’installation se passera sans problème en production.

$ php composer.phar install –prefer-dist

 

Mon petit truc pour la mise en production : installer un nouveau site et une fois le nouveau site fonctionnel, réaliser la bascule. Ainsi, la coupure est limitée dans le temps.

D’autre part, j’évite de livrer de nouvelles fonctionnalités avec un changement de version du framework, surtout si elles modifient la base de données. Mais là chacun fait selon ses circonstances.

 

Bonne mise à jour ! Contactez moi si vous avez des difficultés !