Extended implementation of PasswordStrengthValidator for Symfony

Extended implementation of PasswordStrengthValidator for Symfony

NOTE

The following code does not claim to be the best solution. It is just one way to solve the problem. Feel free to suggest improvements, rewrite, etc.

Introduction

While building https://kit.domains, I focused heavily on security. One of the most important parts of security is password strength.

Symfony has the PasswordStrengthValidator, which is a great constraint for checking the strength of a password.

However, it does not meet all my needs. For example, you can't add requirements like:

  • Password must be at least 8 characters long
  • Password must contain at least one uppercase letter
  • Password must contain at least one lowercase letter
  • Password must contain at least one number
  • Password must contain at least one special character

Let's fix this.

Source code

Github Gist

Requirements

My approach has dependencies on the php-intl extension. See the installation documentation: https://www.php.net/manual/en/intl.installation.php

Code

Constraint

src/Validator/Constraints/PasswordStrengthExtended.php

<?php

namespace App\Validator\Constraints;

use Symfony\Component\Validator\Constraint;

/**
 * @Annotation
 * @Target({"PROPERTY", "METHOD", "ANNOTATION"})
 */
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class PasswordStrengthExtended extends Constraint
{
    public string $message = 'The password is too weak. Use at least {{ length }} characters, including {{ minUppercase }} uppercase letters, {{ minLowercase }} lowercase letters, {{ minNumbers }} numbers and {{ minSpec }} special characters.';

    public function __construct(
        public int $minUppercase = 1,
        public int $minLowercase = 1,
        public int $minNumbers = 1,
        public int $minSpec = 1,
        public int $minLength = 8,
        ?array $options = null,
        ?array $groups = null,
        mixed $payload = null,
        ?string $message = null
    ) {
        parent::__construct($options, $groups, $payload);

        $this->message = $message ?? $this->message;
    }
}

Validator

Add the validator class. There is no need to set it up in services.yaml; Symfony will find it automatically.

src/Validator/Constraints/PasswordStrengthExtendedValidator.php
<?php

namespace App\Validator\Constraints;

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;

class PasswordStrengthExtendedValidator extends ConstraintValidator
{
    public function validate(mixed $value, Constraint $constraint): void
    {
        if (!$constraint instanceof PasswordStrengthExtended) {
            throw new UnexpectedTypeException($constraint, PasswordStrengthExtended::class);
        }

        if (null === $value || '' === $value) {
            return;
        }

        if (!\is_scalar($value) && !$value instanceof \Stringable) {
            throw new UnexpectedValueException($value, 'string');
        }

        $arrayValue = str_split($value);

        $upper = count(array_filter($arrayValue, fn($l) => \IntlChar::isupper($l)));
        $lower = count(array_filter($arrayValue, fn($l) => \IntlChar::islower($l)));
        $isDigit = count(array_filter($arrayValue, fn($l) => \IntlChar::isdigit($l)));
        $isSymbol = count(array_filter($arrayValue, fn($l) => !\IntlChar::isalpha($l) && !\IntlChar::isdigit($l)));

        if ($constraint->minUppercase > $upper
            || $constraint->minLowercase > $lower
            || $constraint->minNumbers > $isDigit
            || $constraint->minSpec > $isSymbol
        ) {
            $this->context->buildViolation($constraint->message)
                ->setParameter('{{ length }}', $constraint->minLength)
                ->setParameter('{{ minUppercase }}', $constraint->minUppercase)
                ->setParameter('{{ minLowercase }}', $constraint->minLowercase)
                ->setParameter('{{ minNumbers }}', $constraint->minNumbers)
                ->setParameter('{{ minSpec }}', $constraint->minSpec)
                ->addViolation();
        }
    }
}

That's it! Hope it was helpful for you.