Utilisation du validateur de sécurité Symfony "NotCompromisedPassword"

Publié le 05/06/2019 • Actualisé le 05/06/2019

Dans cet article nous allons voir comment utiliser le validateur "NotCompromisedPassword" qui a été ajouté dans Symfony 4.3. Celui-ci permet de vérifier si un mot de passe a déjà été exposé publiquement dans une faille de sécurité et est donc compromis. Nous allons voir comment l'utiliser manuellement mais aussi comment offrir la possibilité aux utilisateurs de l'utiliser dans un formulaire de création de compte. C'est parti ! 😎


English language detected! 🇬🇧

  We noticed that your browser is using English. Do you want to read this post in this language?

Read the english version 🇬🇧 Close

» Publié dans "Une semaine Symfonique 649" (du 3 au 9 juin 2019).

Configuration

C'est parti !

Tout d'abord, créons un formulaire basique afin de tester un mot de passe. Rien de compliqué ici, nous avons un champ de type mot de passe et un bouton afin de valider. Vous pouvez le tester, la valeur entrée est soumise via Ajax et vous aurez le résultat de la validation avec un simple alert JavaScript.


Vérifier si un mot de passe a déjà été compromis.

Pas loggé, envoyé via https.

Jetons un coup d'œil au code utilisé pour tester le mot de passe :

<?php

declare(strict_types=1);

// src/Controller/Post/Post26Trait.php

namespace App\Controller\Post;

use App\Form\AccountCreateType;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Constraints\NotCompromisedPassword;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\Validator\ValidatorInterface;

/**
 * Functions for the 26th post.
 *
 * @property ValidatorInterface   $validator
 * @property FormFactoryInterface $formFactory
 */
trait Post26Trait
{
    #[Route(path: '/26/check-password', name: 'check_password', methods: ['POST'])]
    public function checkPassword(Request $request): JsonResponse
    {
        $painPassword = $request->request->get('password');
        $constraints = [
            new NotCompromisedPassword(),
        ];
        $violations = $this->validator->validate($painPassword, $constraints);
        $messages = [];
        if ($violations->count() > 0) {
            foreach ($violations as $violation) {
                if ($violation instanceof ConstraintViolation) {
                    $message = $violation->getMessage();
                    $message = \is_string($message) ? $message : '';
                    $messages[] = '❌ '.$message.' ❌';
                }
            }
        } else {
            $messages[] = '✅ This password has NOT been leaked in a data breach. ✅';
        }

        return $this->json(['message' => implode(',', $messages)]);
    }

Comme vous pouvez le voir, nous instancions manuellement la contrainte NotCompromisedPassword et nous lui soumettons le mot de passe entré. Même si nous n'utilisons qu'une seule contrainte, nous bouclons pour construire notre propre message de retour. Vous pouvez éviter ceci en castant l'objet violation en chaîne de caractères : $message = '❌ '.$violations;. Dans ce cas le message contiendra le mot de passe qui a été entré. Le validateur fonctionne comme prévu, voyons comment l'utiliser dans un formulaire de création de compte.

Utilisation du validateur dans un formulaire d'inscription

Je pense que ce type de validation ne devrait pas tout le temps être utilisé. Une bonne pratique est de demander la permission à l'utilisateur afin d'activer une fonctionnalité. (Oui, les fameuses lois RGPD !). Pour ce faire, nous allons ajouter une case à cocher pour activer ou pas le test du mot de passe saisi. Celle-ci doit être décochée par défaut pour respecter les directives RGPD. Ici, nous ne nous appuierons pas sur une entité mais nous construirons le formulaire manuellement. Il sera très simple et contiendra un identifiant, un mot de passe et notre case à cocher. Voici ma proposition, vous pouvez tester. Cette fois-ci la page sera rechargée et vous aurez des retours selon les valeurs que vous avez entrées. Seuls les champs identifiant et mot de passe sont donc obligatoires.


Regardons le code du formulaire correspondant, nous ajoutons nos trois champs. La principale différence avec un formulaire basique réside dans le fait qu'une validation conditionnelle est déclenchée si la case est cochée. Pour ce faire, nous utilisons un validateur de type Callback au niveau du formulaire dans la méthode configureOptions(). (contrairement aux contraintes simples qui sont d'habitude associées à chaque champ). Ce validateur a la particularité d'avoir accès à toutes les valeurs ayant étés soumises, ce qui permet de faire une validation plus complexe dépendant de la valeur de plusieurs champs.

<?php

declare(strict_types=1);

// src/Form/AccountCreateType.php

namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Form;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Callback;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\NotCompromisedPassword;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\Context\ExecutionContextInterface;

/**
 * Fake account creation form.
 */
final class AccountCreateType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('login', TextType::class, ['constraints' => [new NotBlank()]]);
        $builder->add('password', PasswordType::class, ['constraints' => [new NotBlank()]]);
        $builder->add('check_password', CheckboxType::class, ['required' => false]);
    }

    /**
     * Conditional validation depending on the checkbox.
     *
     * @param array<string,mixed> $data
     */
    public function validate(array $data, ExecutionContextInterface $context): void
    {
        // Not checked so continue.
        if (\is_bool($data['check_password']) && !$data['check_password']) {
            return;
        }

        $violations = $context->getValidator()->validate($data['password'], [
            new NotCompromisedPassword(),
        ]);

        // If compromised assign the error to the password field
        if ($violations instanceof ConstraintViolationList && $violations->count() > 0) {
            /** @var Form $root */
            $root = $context->getRoot();
            $password = $root->get('password');
            if ($password instanceof Form) {
                $password->addError(new FormError((string) $violations));
            }
        }
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'constraints' => [
                new Callback([$this, 'validate']),
            ],
        ]);
    }
}

Un peu plus d'explications à propos de cette fonction validate(). Le premier argument contient toutes les données soumises par le formulaire. Il est important de noter ici que les valeurs ont déjà été validées unitairement. L'identifiant et le mot de passe ne peuvent donc pas être vides. Le deuxième argument est le contexte global du formulaire qui nous permet d'accéder à tous les champs. Tout d'abord nous vérifions si la case a été cochée, nous sommes sûrs que la valeur est un booléen puisque le widget associé est un CheckBoxType. Si faux nous n'avons pas de validation supplémentaire à effectuer. Dans le cas contraire, nous récupérons la valeur en clair du mot de passe que nous validons manuellement avec la contrainte NotCompromisedPassword. Il est retourné un objet ConstraintViolationList qui implémente les interfaces Traversable et Countable. La méthode count() nous permet donc de savoir s'il y a au moins une violation. Si c'est le cas nous extrayons le message d'erreur en castant en chaîne cet objet. Enfin nous assignons le message d'erreur au champ "mot de passe" pour qu'il soit affiché juste en dessous dans le formulaire html.

Et voilà ! J'espère que vous avez aimé. Découvrez d'autres informations en rapport à cet article avec les liens ci-dessous. Comme toujours, retours, likes et retweets sont les bienvenus. (voir la boîte ci-dessous) À bientôt ! COil. 😊

 La documentation  Plus sur le web

Ils m'ont donné leurs retours et m'ont aidé à corriger des erreurs et typos dans cet article, un grand merci à : danabrey, greg0ire, jmsche. 👍

Bonus : Le code vue.js utilise dans le premier formulaire de test. (Je suis un tag html <details> ! 🙃)
/* global post26 */
import axios from 'axios'

/**
 * Global variables are coming from _symfony_to_js.html.twig.
 *
 * @see templates/tools/_check_password_form.html.twig
 * @see templates/tools/passwordCompromised.html.twig
 */
export default {
  data: {
    post26: post26
  },
  methods: {
    checkPassword: function () {
      const formData = new FormData()
      formData.append('password', this.post26.password)
      axios.post(this.post26.checkPasswordPath, formData).then(response => {
        alert(response.data.message)
      }).catch(error => alert(error))
      this.post26.password = ''
    }
  }
}

  Travaillez avec moi !


A vous de jouer !

Ces articles vous ont été utiles ? Vous pouvez m'aider à votre tour de plusieurs manières : (cf le tweet à droite pour me contacter )

  • Me remonter des erreurs ou typos.
  • Me remonter des choses qui pourraient être améliorées.
  • Aimez et retweetez !
  • Suivez moi sur Twitter
  • Inscrivez-vous au flux RSS.
  • Cliquez sur les boutons Plus sur Stackoverflow pour me faire gagner des badges "annonceur" 🏅.

Merci d'avoir tenu jusque ici et à très bientôt sur Strangebuzz ! 😉

COil