8000 feature #49789 [Validator] New `PasswordStrength` constraint (Spomky) · symfony/symfony@c375406 · GitHub
[go: up one dir, main page]

Skip to content

Commit c375406

Browse files
committed
feature #49789 [Validator] New PasswordStrength constraint (Spomky)
This PR was squashed before being merged into the 6.3 branch. Discussion ---------- [Validator] New `PasswordStrength` constraint | Q | A | ------------- | --- | Branch? | 6.3 | Bug fix? | no | New feature? | yes | Deprecations? | no <!-- please update UPGRADE-*.md and src/**/CHANGELOG.md files --> | Tickets | none | License | MIT | Doc PR | symfony/symfony-docs#18124 This PR adds a new constraint `PasswordStrength`. This constraint is able to determine if the password strength (or any other string) fulfils with the threshold. It leverages on [`bjeavons/zxcvbn-php`](https://github.com/bjeavons/zxcvbn-php) which is required when this constraint is used. Example: ```php <?php declare(strict_types=1); namespace App\Form; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\PasswordType; use Symfony\Component\Form\Extension\Core\Type\RepeatedType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Constraints\PasswordStrength; final class ChangePasswordFormType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options): void { $restrictedData = $options['restrictedData'] ?? []; $builder ->add('plainPassword', RepeatedType::class, [ 'type' => PasswordType::class, 'options' => [ 'attr' => [ 'autocomplete' => 'new-password', ], ], 'first_options' => [ 'constraints' => [ new NotBlank(), new PasswordStrength(['restrictedData' => $restrictedData]) ], 'label' => 'New password', ], 'second_options' => [ 'label' => 'Repeat the new password', ], 'mapped' => false, ]) ; } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'restrictedData' => [], ]) ->setAllowedTypes('restrictedData', 'string[]') ; } } ``` Then from e.g. a controller ```php $form = $this->createForm(ChangePasswordFormType::class, null, [ 'restrictedData' => [ $user->getUsername(), $user->getEmail(), $user->getGivenName(), $user->getFamilyName(), 'ApplicationName', // Arbitrary data ], ]); ``` It can be added as a property attribute: ```php <?php declare(strict_types=1); namespace App\Form; use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Constraints\PasswordStrength; final class ChangePasswordFormData { #[NotBlank] #[PasswordStrength] public string $password = ''; } ``` Options: * `lowStrengthMessage`: the message in case of a weak password (default: `The password strength is too low. Please use a stronger password.`) * `minScore`: 0 means a weak password, 4 means a very good password (default: `2`) * `restrictedData`: a list of restricted data e.g. user information such as ID, username, email, given name, last name or application information (default: `[]`) * `restrictedDataMessage`: the message in case of the restricted data in the password (default: `The password contains at least one restricted data: {{ wordList }}.`) Commits ------- 1d93f5c [Validator] New `PasswordStrength` constraint
2 parents 497e966 + 1d93f5c commit c375406

File tree

7 files changed

+287
-2
lines changed

7 files changed

+287
-2
lines changed

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,8 @@
152152
"symfony/security-acl": "~2.8|~3.0",
153153
"twig/cssinliner-extra": "^2.12|^3",
154154
"twig/inky-extra": "^2.12|^3",
155-
"twig/markdown-extra": "^2.12|^3"
155+
"twig/markdown-extra": "^2.12|^3",
156+
"bjeavons/zxcvbn-php": "^1.0"
156157
},
157158
"conflict": {
158159
"ext-psr": "<1.1|>=2",

src/Symfony/Component/Validator/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ CHANGELOG
88
* Add `Uuid::TIME_BASED_VERSIONS` to match that a UUID being validated embeds a timestamp
99
* Add the `pattern` parameter in violations of the `Regex` constraint
1010
* Add a `NoSuspiciousCharacters` constraint to validate a string is not a spoofing attempt
11+
* Add a `PasswordStrength` constraint to check the strength of a password (requires `bjeavons/zxcvbn-php` library)
1112
* 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`)
1213
* Add the `filenameMaxLength` option to the `File` constraint
1314
* Add the `exclude` option to the `Cascade` constraint
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Validator\Constraints;
13+
14+
use Symfony\Component\Validator\Constraint;
15+
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
16+
use Symfony\Component\Validator\Exception\LogicException;
17+
use ZxcvbnPhp\Zxcvbn;
18+
19+
/**
20+
* @Annotation
21+
*
22+
* @Target({"PROPERTY", "METHOD", "ANNOTATION"})
23+
*
24+
* @author Florent Morselli <florent.morselli@spomky-labs.com>
25+
*/
26+
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
27+
final class PasswordStrength extends Constraint
28+
{
29+
public const PASSWORD_STRENGTH_ERROR = '4234df00-45dd-49a4-b303-a75dbf8b10d8';
30+
public const RESTRICTED_USER_INPUT_ERROR = 'd187ff45-bf23-4331-aa87-c24a36e9b400';
31+
32+
protected const ERROR_NAMES = [
33+
self::PASSWORD_STRENGTH_ERROR => 'PASSWORD_STRENGTH_ERROR',
34+
self::RESTRICTED_USER_INPUT_ERROR => 'RESTRICTED_USER_INPUT_ERROR',
35+
];
36+
37+
public string $lowStrengthMessage = 'The password strength is too low. Please use a stronger password.';
38+
39+
public int $minScore = 2;
40+
41+
public string $restrictedDataMessage = 'The password contains the following restricted data: {{ wordList }}.';
42+
43+
/**
44+
* @var array<string>
45+
*/
46+
public array $restrictedData = [];
47+
48+
public function __construct(mixed $options = null, array $groups = null, mixed $payload = null)
49+
{
50+
if (!class_exists(Zxcvbn::class)) {
51+
throw new LogicException(sprintf('The "%s" class requires the "bjeavons/zxcvbn-php" library. Try running "composer require bjeavons/zxcvbn-php".', self::class));
52+
}
53+
54+
if (isset($options['minScore']) && (!\is_int($options['minScore']) || $options['minScore'] < 1 || $options['minScore'] > 4)) {
55+
throw new ConstraintDefinitionException(sprintf('The parameter "minScore" of the "%s" constraint must be an integer between 1 and 4.', static::class));
56+
}
57+
58+
if (isset($options['restrictedData'])) {
59+
array_walk($options['restrictedData'], static function (mixed $value): void {
60+
if (!\is_string($value)) {
61+
throw new ConstraintDefinitionException(sprintf('The parameter "restrictedData" of the "%s" constraint must be a list of strings.', static::class));
62+
}
63+
});
64+
}
65+
parent::__construct($options, $groups, $payload);
66+
}
67+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Validator\Constraints;
13+
14+
use Symfony\Component\Validator\Constraint;
15+
use Symfony\Component\Validator\ConstraintValidator;
16+
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
17+
use Symfony\Component\Validator\Exception\UnexpectedValueException;
18+
use ZxcvbnPhp\Matchers\DictionaryMatch;
19+
use ZxcvbnPhp\Matchers\MatchInterface;
20+
use ZxcvbnPhp\Zxcvbn;
21+
22+
final class PasswordStrengthValidator extends ConstraintValidator
23+
{
24+
public function validate(#[\SensitiveParameter] mixed $value, Constraint $constraint): void
25+
{
26+
if (!$constraint instanceof PasswordStrength) {
27+
throw new UnexpectedTypeException($constraint, PasswordStrength::class);
28+
}
29+
30+
if (null === $value) {
31+
return;
32+
}
33+
34+
if (!\is_string($value)) {
35+
throw new UnexpectedValueException($value, 'string');
36+
}
37+
38+
$zxcvbn = new Zxcvbn();
39+
$strength = $zxcvbn->passwordStrength($value, $constraint->restrictedData);
40+
41+
if ($strength['score'] < $constraint->minScore) {
42+
$this->context->buildViolation($constraint->lowStrengthMessage)
43+
->setCode(PasswordStrength::PASSWORD_STRENGTH_ERROR)
44+
->addViolation();
45+
}
46+
$wordList = $this->findRestrictedUserInputs($strength['sequence'] ?? []);
47+
if (0 !== \count($wordList)) {
48+
$this->context->buildViolation($constraint->restrictedDataMessage, [
49+
'{{ wordList }}' => implode(', ', $wordList),
50+
])
51+
->setCode(PasswordStrength::RESTRICTED_USER_INPUT_ERROR)
52+
->addViolation();
53+
}
54+
}
55+
56+
/**
57+
* @param array<MatchInterface> $sequence
58+
*
59+
* @return array<string>
60+
*/
61+
private function findRestrictedUserInputs(array $sequence): array
62+
{
63+
$found = [];
64+
65+
foreach ($sequence as $item) {
66+
if (!$item instanceof DictionaryMatch) {
67+
continue;
68+
}
69+
if ('user_inputs' === $item->dictionaryName) {
70+
$found[] = $item->token;
71+
}
72+
}
73+
74+
return $found;
75+
}
76+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Validator\Tests\Constraints;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Validator\Constraints\PasswordStrength;
16+
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
17+
18+
class PasswordStrengthTest extends TestCase
19+
{
20+
public function testConstructor()
21+
{
22+
$constraint = new PasswordStrength();
23+
$this->assertEquals(2, $constraint->minScore);
24+
$this->assertEquals([], $constraint->restrictedData);
25+
}
26+
27+
public function testConstructorWithParameters()
28+
{
29+
$constraint = new PasswordStrength([
30+
'minScore' => 3,
31+
'restrictedData' => ['foo', 'bar'],
32+
]);
33+
34+
$this->assertEquals(3, $constraint->minScore);
35+
$this->assertEquals(['foo', 'bar'], $constraint->restrictedData);
36+
}
37+
38+
public function testInvalidScoreOfZero()
39+
{
40+
$this->expectException(ConstraintDefinitionException::class);
41+
$this->expectExceptionMessage('The parameter "minScore" of the "Symfony\Component\Validator\Constraints\PasswordStrength" constraint must be an integer between 1 and 4.');
42+
new PasswordStrength(['minScore' => 0]);
43+
}
44+
45+
public function testInvalidScoreOfFive()
46+
{
47+
$this->expectException(ConstraintDefinitionException::class);
48+
$this->expectExceptionMessage('The parameter "minScore" of the "Symfony\Component\Validator\Constraints\PasswordStrength" constraint must be an integer between 1 and 4.');
49+
new PasswordStrength(['minScore' => 5]);
50+
}
51+
52+
public function testInvalidRestrictedData()
53+
{
54+
$this->expectException(ConstraintDefinitionException::class);
55+
$this->expectExceptionMessage('The parameter "restrictedData" of the "Symfony\Component\Validator\Constraints\PasswordStrength" constraint must be a list of strings.');
56+
new PasswordStrength(['restrictedData' => [123]]);
57+
}
58+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Validator\Tests\Constraints;
13+
14+
use Symfony\Component\Validator\Constraints\PasswordStrength;
15+
use Symfony\Component\Validator\Constraints\PasswordStrengthValidator;
16+
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
17+
18+
class PasswordStrengthValidatorTest extends ConstraintValidatorTestCase
19+
{
20+
protected function createValidator(): PasswordStrengthValidator
21+
{
22+
return new PasswordStrengthValidator();
23+
}
24+
25+
/**
26+
* @dataProvider getValidValues
27+
*/
28+
public function testValidValues(string $value)
29+
{
30+
$this->validator->validate($value, new PasswordStrength());
31+
32+
$this->assertNoViolation();
33+
}
34+
35+
public static function getValidValues(): iterable
36+
{
37+
yield ['This 1s a very g00d Pa55word! ;-)'];
38+
}
39+
40+
/**
41+
* @dataProvider provideInvalidConstraints
42+
*/
43+
public function testThePasswordIsWeak(PasswordStrength $constraint, string $password, string $expectedMessage, string $expectedCode, array $parameters = [])
44+
{
45+
$this->validator->validate($password, $constraint);
46+
47+
$this->buildViolation($expectedMessage)
48+
->setCode($expectedCode)
49+
->setParameters($parameters)
50+
->assertRaised();
51+
}
52+
53+
public static function provideInvalidConstraints(): iterable
54+
{
55+
yield [
56+
new PasswordStrength(),
57+
'password',
58+
'The password strength is too low. Please use a stronger password.',
59+
PasswordStrength::PASSWORD_STRENGTH_ERROR,
60+
];
61+
yield [
62+
new PasswordStrength([
63+
'minScore' => 4,
64+
]),
65+
'Good password?',
66+
'The password strength is too low. Please use a stronger password.',
67+
PasswordStrength::PASSWORD_STRENGTH_ERROR,
68+
];
69+
yield [
70+
new PasswordStrength([
71+
'restrictedData' => ['symfony'],
72+
]),
73+
'SyMfONY-framework-john',
74+
'The password contains the following restricted data: {{ wordList }}.',
75+
PasswordStrength::RESTRICTED_USER_INPUT_ERROR,
76+
[
77+
'{{ wordList }}' => 'SyMfONY',
78+
],
79+
];
80+
}
81+
}

src/Symfony/Component/Validator/composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@
4040
"symfony/property-info": "^5.4|^6.0",
4141
"symfony/translation": "^5.4|^6.0",
4242
"doctrine/annotations": "^1.13|^2",
43-
"egulias/email-validator": "^2.1.10|^3|^4"
43+
"egulias/email-validator": "^2.1.10|^3|^4",
44+
"bjeavons/zxcvbn-php": "^1.0"
4445
},
4546
"conflict": {
4647
"doctrine/annotations": "<1.13",

0 commit comments

Comments
 (0)
0