On using the Symfony NotCompromisedPassword security validator

Published on 2019-06-05 • Modified on 2019-06-05

In this post, we will see how to use the NotCompromisedPassword validator which was introduced in Symfony 4.3. This validator allows us to check if a given password was publicly exposed in a data breach and is therefore compromised. We will see how to use it manually and how to offer the ability to the user to test their password with this validation. Let's go! 😎

» Published in "A week of Symfony 649" (3-9 June 2019).

Configuration

Let's go!

First let's create a basic form to test a password. Nothing complicated here, we have a password input and a button to submit the form. You can test it, the value is submitted via Ajax and you will get the validation result with a simple JavaScript alert.


Check if a password has been leaked in a data breach.

Not logged, sent via https.

Let's have a look at the code used to test the password:

<?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)]);
    }

As you can see we are manually instantiating the NotCompromisedPassword constraint to test the password against it. Even we are using only one constraint, we are looping to get the error message in order to build our own message. You can avoid this by casting violations to string. $message = '❌ '.$violations;. In this case the message will contain the password you entered. The validator works as expected, now let's see how to use it in a standard account creation form.

Using the validator in a subscribe form

I personally think this type of validation shouldn't be done every time and in every case. A good practice can be to ask the user for their permission to enable a feature (yes, the famous GDPR laws!) To do this, we will add a checkbox to enable the feature, this checkbox should be unchecked by default to respect GDPR directives. Here, we won't rely on an entity but we will build a form type manually. It will be very simple and will contain a login, a password and our checkbox. Here is my proposition. You can test it, this time the page will get reloaded and you will see the errors depending on the values you have entered. Login and password fields are mandatory.


Let's have a look at the corresponding form type, nothing complicated here, we add the three fields. The main difference with a basic form resides in the fact that an additional validation is triggered if the box was checked. In order to do this, we had a Callback validator at the type level in the configureOptions() method. (unlike other constaints how are usasually bound to each field). This special validator will have access to all submitted form values so we can do more complex validation.

<?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']),
            ],
        ]);
    }
}

More explanation about this validate method. The first argument is an array containing all the submitted values of the form. What is important here is that these values have already been validated individually. The second argument is the global context of the form type, we can access all fields through it. First, we test if the box was checked, we are sure the value is a boolean as the associated type is a CheckBoxType. If false, we don't have to do additional process. If true, we get the clean password value and we manually validate it against the NotCompromisedPassword constraint. This returns a ConstraintViolationList object which implements the Traversable and Countable interfaces. So the count() function will tell us if there is at least one violation. If this is the case, we extract the error message by casting this object to a string. Then we assign this error to the password field so the message is displayed just below the corresponding form field in our html form.

That's it! I hope you like it. Check out the links below to have additional information related to the post. As always, feedback, likes and retweets are welcome. (see the box below) See you! COil. 😊

 The Symfony doc  More on the web

They gave feedback and helped me to fix errors and typos in this article; many thanks to danabrey, greg0ire, jmsche. 👍

Bonus: The vue.js code used by the first test box. (I am a <details> html tag! 🙃)
/* 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 = ''
    }
  }
}

  Work with me!


Call to action

Did you like this post? You can help me back in several ways: (use the Tweet on the right to comment or to contact me )

Thank you for reading! And see you soon on Strangebuzz! 😉

COil