diff --git a/composer.json b/composer.json index 20ade09dbb979..f246838ba1cd6 100644 --- a/composer.json +++ b/composer.json @@ -152,7 +152,8 @@ "symfony/security-acl": "~2.8|~3.0", "twig/cssinliner-extra": "^2.12|^3", "twig/inky-extra": "^2.12|^3", - "twig/markdown-extra": "^2.12|^3" + "twig/markdown-extra": "^2.12|^3", + "bjeavons/zxcvbn-php": "^1.0" }, "conflict": { "ext-psr": "<1.1|>=2", diff --git a/src/Symfony/Component/Validator/CHANGELOG.md b/src/Symfony/Component/Validator/CHANGELOG.md index 93407796bd055..5c1681dcd2459 100644 --- a/src/Symfony/Component/Validator/CHANGELOG.md +++ b/src/Symfony/Component/Validator/CHANGELOG.md @@ -8,6 +8,7 @@ CHANGELOG * Add `Uuid::TIME_BASED_VERSIONS` to match that a UUID being validated embeds a timestamp * Add the `pattern` parameter in violations of the `Regex` constraint * Add a `NoSuspiciousCharacters` constraint to validate a string is not a spoofing attempt + * Add a `PasswordStrength` constraint to check the strength of a password (requires `bjeavons/zxcvbn-php` library) * Add the `countUnit` option to the `Length` constraint to allow counting the string length either by code points (like before, now the default setting `Length::COUNT_CODEPOINTS`), bytes (`Length::COUNT_BYTES`) or graphemes (`Length::COUNT_GRAPHEMES`) * Add the `filenameMaxLength` option to the `File` constraint * Add the `exclude` option to the `Cascade` constraint diff --git a/src/Symfony/Component/Validator/Constraints/PasswordStrength.php b/src/Symfony/Component/Validator/Constraints/PasswordStrength.php new file mode 100644 index 0000000000000..9eb571f7dfb84 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/PasswordStrength.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Constraints; + +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Exception\ConstraintDefinitionException; +use Symfony\Component\Validator\Exception\LogicException; +use ZxcvbnPhp\Zxcvbn; + +/** + * @Annotation + * + * @Target({"PROPERTY", "METHOD", "ANNOTATION"}) + * + * @author Florent Morselli + */ +#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +final class PasswordStrength extends Constraint +{ + public const PASSWORD_STRENGTH_ERROR = '4234df00-45dd-49a4-b303-a75dbf8b10d8'; + public const RESTRICTED_USER_INPUT_ERROR = 'd187ff45-bf23-4331-aa87-c24a36e9b400'; + + protected const ERROR_NAMES = [ + self::PASSWORD_STRENGTH_ERROR => 'PASSWORD_STRENGTH_ERROR', + self::RESTRICTED_USER_INPUT_ERROR => 'RESTRICTED_USER_INPUT_ERROR', + ]; + + public string $lowStrengthMessage = 'The password strength is too low. Please use a stronger password.'; + + public int $minScore = 2; + + public string $restrictedDataMessage = 'The password contains the following restricted data: {{ wordList }}.'; + + /** + * @var array + */ + public array $restrictedData = []; + + public function __construct(mixed $options = null, array $groups = null, mixed $payload = null) + { + if (!class_exists(Zxcvbn::class)) { + throw new LogicException(sprintf('The "%s" class requires the "bjeavons/zxcvbn-php" library. Try running "composer require bjeavons/zxcvbn-php".', self::class)); + } + + if (isset($options['minScore']) && (!\is_int($options['minScore']) || $options['minScore'] < 1 || $options['minScore'] > 4)) { + throw new ConstraintDefinitionException(sprintf('The parameter "minScore" of the "%s" constraint must be an integer between 1 and 4.', static::class)); + } + + if (isset($options['restrictedData'])) { + array_walk($options['restrictedData'], static function (mixed $value): void { + if (!\is_string($value)) { + throw new ConstraintDefinitionException(sprintf('The parameter "restrictedData" of the "%s" constraint must be a list of strings.', static::class)); + } + }); + } + parent::__construct($options, $groups, $payload); + } +} diff --git a/src/Symfony/Component/Validator/Constraints/PasswordStrengthValidator.php b/src/Symfony/Component/Validator/Constraints/PasswordStrengthValidator.php new file mode 100644 index 0000000000000..66a68096fda96 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/PasswordStrengthValidator.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Constraints; + +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; +use Symfony\Component\Validator\Exception\UnexpectedValueException; +use ZxcvbnPhp\Matchers\DictionaryMatch; +use ZxcvbnPhp\Matchers\MatchInterface; +use ZxcvbnPhp\Zxcvbn; + +final class PasswordStrengthValidator extends ConstraintValidator +{ + public function validate(#[\SensitiveParameter] mixed $value, Constraint $constraint): void + { + if (!$constraint instanceof PasswordStrength) { + throw new UnexpectedTypeException($constraint, PasswordStrength::class); + } + + if (null === $value) { + return; + } + + if (!\is_string($value)) { + throw new UnexpectedValueException($value, 'string'); + } + + $zxcvbn = new Zxcvbn(); + $strength = $zxcvbn->passwordStrength($value, $constraint->restrictedData); + + if ($strength['score'] < $constraint->minScore) { + $this->context->buildViolation($constraint->lowStrengthMessage) + ->setCode(PasswordStrength::PASSWORD_STRENGTH_ERROR) + ->addViolation(); + } + $wordList = $this->findRestrictedUserInputs($strength['sequence'] ?? []); + if (0 !== \count($wordList)) { + $this->context->buildViolation($constraint->restrictedDataMessage, [ + '{{ wordList }}' => implode(', ', $wordList), + ]) + ->setCode(PasswordStrength::RESTRICTED_USER_INPUT_ERROR) + ->addViolation(); + } + } + + /** + * @param array $sequence + * + * @return array + */ + private function findRestrictedUserInputs(array $sequence): array + { + $found = []; + + foreach ($sequence as $item) { + if (!$item instanceof DictionaryMatch) { + continue; + } + if ('user_inputs' === $item->dictionaryName) { + $found[] = $item->token; + } + } + + return $found; + } +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/PasswordStrengthTest.php b/src/Symfony/Component/Validator/Tests/Constraints/PasswordStrengthTest.php new file mode 100644 index 0000000000000..aa31d5a9ef999 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/PasswordStrengthTest.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Tests\Constraints; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Validator\Constraints\PasswordStrength; +use Symfony\Component\Validator\Exception\ConstraintDefinitionException; + +class PasswordStrengthTest extends TestCase +{ + public function testConstructor() + { + $constraint = new PasswordStrength(); + $this->assertEquals(2, $constraint->minScore); + $this->assertEquals([], $constraint->restrictedData); + } + + public function testConstructorWithParameters() + { + $constraint = new PasswordStrength([ + 'minScore' => 3, + 'restrictedData' => ['foo', 'bar'], + ]); + + $this->assertEquals(3, $constraint->minScore); + $this->assertEquals(['foo', 'bar'], $constraint->restrictedData); + } + + public function testInvalidScoreOfZero() + { + $this->expectException(ConstraintDefinitionException::class); + $this->expectExceptionMessage('The parameter "minScore" of the "Symfony\Component\Validator\Constraints\PasswordStrength" constraint must be an integer between 1 and 4.'); + new PasswordStrength(['minScore' => 0]); + } + + public function testInvalidScoreOfFive() + { + $this->expectException(ConstraintDefinitionException::class); + $this->expectExceptionMessage('The parameter "minScore" of the "Symfony\Component\Validator\Constraints\PasswordStrength" constraint must be an integer between 1 and 4.'); + new PasswordStrength(['minScore' => 5]); + } + + public function testInvalidRestrictedData() + { + $this->expectException(ConstraintDefinitionException::class); + $this->expectExceptionMessage('The parameter "restrictedData" of the "Symfony\Component\Validator\Constraints\PasswordStrength" constraint must be a list of strings.'); + new PasswordStrength(['restrictedData' => [123]]); + } +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/PasswordStrengthValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/PasswordStrengthValidatorTest.php new file mode 100644 index 0000000000000..33bd0f8809435 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/PasswordStrengthValidatorTest.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Tests\Constraints; + +use Symfony\Component\Validator\Constraints\PasswordStrength; +use Symfony\Component\Validator\Constraints\PasswordStrengthValidator; +use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; + +class PasswordStrengthValidatorTest extends ConstraintValidatorTestCase +{ + protected function createValidator(): PasswordStrengthValidator + { + return new PasswordStrengthValidator(); + } + + /** + * @dataProvider getValidValues + */ + public function testValidValues(string $value) + { + $this->validator->validate($value, new PasswordStrength()); + + $this->assertNoViolation(); + } + + public static function getValidValues(): iterable + { + yield ['This 1s a very g00d Pa55word! ;-)']; + } + + /** + * @dataProvider provideInvalidConstraints + */ + public function testThePasswordIsWeak(PasswordStrength $constraint, string $password, string $expectedMessage, string $expectedCode, array $parameters = []) + { + $this->validator->validate($password, $constraint); + + $this->buildViolation($expectedMessage) + ->setCode($expectedCode) + ->setParameters($parameters) + ->assertRaised(); + } + + public static function provideInvalidConstraints(): iterable + { + yield [ + new PasswordStrength(), + 'password', + 'The password strength is too low. Please use a stronger password.', + PasswordStrength::PASSWORD_STRENGTH_ERROR, + ]; + yield [ + new PasswordStrength([ + 'minScore' => 4, + ]), + 'Good password?', + 'The password strength is too low. Please use a stronger password.', + PasswordStrength::PASSWORD_STRENGTH_ERROR, + ]; + yield [ + new PasswordStrength([ + 'restrictedData' => ['symfony'], + ]), + 'SyMfONY-framework-john', + 'The password contains the following restricted data: {{ wordList }}.', + PasswordStrength::RESTRICTED_USER_INPUT_ERROR, + [ + '{{ wordList }}' => 'SyMfONY', + ], + ]; + } +} diff --git a/src/Symfony/Component/Validator/composer.json b/src/Symfony/Component/Validator/composer.json index 7e90c5238c127..7e99fcfa78ecb 100644 --- a/src/Symfony/Component/Validator/composer.json +++ b/src/Symfony/Component/Validator/composer.json @@ -40,7 +40,8 @@ "symfony/property-info": "^5.4|^6.0", "symfony/translation": "^5.4|^6.0", "doctrine/annotations": "^1.13|^2", - "egulias/email-validator": "^2.1.10|^3|^4" + "egulias/email-validator": "^2.1.10|^3|^4", + "bjeavons/zxcvbn-php": "^1.0" }, "conflict": { "doctrine/annotations": "<1.13",