Skip to content

Commit 6b2bf22

Browse files
committed
Remove bjeavons/zxcvbn-php in favor of a builtin solution
1 parent 4faf38a commit 6b2bf22

File tree

7 files changed

+51
-96
lines changed

7 files changed

+51
-96
lines changed

composer.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,7 @@
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",
156-
"bjeavons/zxcvbn-php": "^1.0"
155+
"twig/markdown-extra": "^2.12|^3"
157156
},
158157
"conflict": {
159158
"ext-psr": "<1.1|>=2",

src/Symfony/Component/Validator/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +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)
11+
* Add a `PasswordStrength` constraint to check the strength of a password
1212
* 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`)
1313
* Add the `filenameMaxLength` option to the `File` constraint
1414
* Add the `exclude` option to the `Cascade` constraint

src/Symfony/Component/Validator/Constraints/PasswordStrength.php

Lines changed: 11 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@
1313

1414
use Symfony\Component\Validator\Constraint;
1515
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
16-
use Symfony\Component\Validator\Exception\LogicException;
17-
use ZxcvbnPhp\Zxcvbn;
1816

1917
/**
2018
* @Annotation
@@ -26,42 +24,28 @@
2624
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
2725
final class PasswordStrength extends Constraint
2826
{
27+
public const STRENGTH_VERY_WEAK = 0;
28+
public const STRENGTH_WEAK = 1;
29+
public const STRENGTH_REASONABLE = 2;
30+
public const STRENGTH_STRONG = 3;
31+
public const STRENGTH_VERY_STRONG = 4;
32+
2933
public const PASSWORD_STRENGTH_ERROR = '4234df00-45dd-49a4-b303-a75dbf8b10d8';
30-
public const RESTRICTED_USER_INPUT_ERROR = 'd187ff45-bf23-4331-aa87-c24a36e9b400';
3134

3235
protected const ERROR_NAMES = [
3336
self::PASSWORD_STRENGTH_ERROR => 'PASSWORD_STRENGTH_ERROR',
34-
self::RESTRICTED_USER_INPUT_ERROR => 'RESTRICTED_USER_INPUT_ERROR',
3537
];
3638

37-
public string $lowStrengthMessage = 'The password strength is too low. Please use a stronger password.';
38-
39-
public int $minScore = 2;
39+
public string $message = 'The password strength is too low. Please use a stronger password.';
4040

41-
public string $restrictedDataMessage = 'The password contains the following restricted data: {{ wordList }}.';
41+
public int $minScore;
4242

43-
/**
44-
* @var array<string>
45-
*/
46-
public array $restrictedData = [];
47-
48-
public function __construct(mixed $options = null, array $groups = null, mixed $payload = null)
43+
public function __construct(int $minScore = self::STRENGTH_REASONABLE, mixed $options = null, array $groups = null, mixed $payload = null)
4944
{
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)) {
45+
if (isset($minScore) && (!\is_int($minScore) || $minScore < 1 || $minScore > 4)) {
5546
throw new ConstraintDefinitionException(sprintf('The parameter "minScore" of the "%s" constraint must be an integer between 1 and 4.', static::class));
5647
}
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-
}
48+
$options['minScore'] = $minScore;
6549
parent::__construct($options, $groups, $payload);
6650
}
6751
}

src/Symfony/Component/Validator/Constraints/PasswordStrengthValidator.php

Lines changed: 25 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,17 @@
1515
use Symfony\Component\Validator\ConstraintValidator;
1616
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
1717
use Symfony\Component\Validator\Exception\UnexpectedValueException;
18-
use ZxcvbnPhp\Matchers\DictionaryMatch;
19-
use ZxcvbnPhp\Matchers\MatchInterface;
20-
use ZxcvbnPhp\Zxcvbn;
2118

2219
final class PasswordStrengthValidator extends ConstraintValidator
2320
{
21+
/**
22+
* @param (\Closure(string):PasswordStrength::STRENGTH_*)|null $passwordStrengthEstimator
23+
*/
24+
public function __construct(
25+
private readonly ?\Closure $passwordStrengthEstimator = null,
26+
) {
27+
}
28+
2429
public function validate(#[\SensitiveParameter] mixed $value, Constraint $constraint): void
2530
{
2631
if (!$constraint instanceof PasswordStrength) {
@@ -34,43 +39,33 @@ public function validate(#[\SensitiveParameter] mixed $value, Constraint $constr
3439
if (!\is_string($value)) {
3540
throw new UnexpectedValueException($value, 'string');
3641
}
42+
$passwordStrengthEstimator = $this->passwordStrengthEstimator ?? self::estimateStrength(...);
43+
$strength = $passwordStrengthEstimator($value);
3744

38-
$zxcvbn = new Zxcvbn();
39-
$strength = $zxcvbn->passwordStrength($value, $constraint->restrictedData);
40-
41-
if ($strength['score'] < $constraint->minScore) {
42-
$this->context->buildViolation($constraint->lowStrengthMessage)
45+
if ($strength < $constraint->minScore) {
46+
$this->context->buildViolation($constraint->message)
4347
->setCode(PasswordStrength::PASSWORD_STRENGTH_ERROR)
4448
->addViolation();
4549
}
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-
}
5450
}
5551

5652
/**
57-
* @param array<MatchInterface> $sequence
53+
* Returns the estimated strength of a password.
54+
*
55+
* The higher the value, the stronger the password.
5856
*
59-
* @return array<string>
57+
* @return PasswordStrength::STRENGTH_*
6058
*/
61-
private function findRestrictedUserInputs(array $sequence): array
59+
private static function estimateStrength(#[\SensitiveParameter] string $password): int
6260
{
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-
}
61+
$entropy = log(\strlen(count_chars($password, 3)) ** \strlen($password), 2);
7362

74-
return $found;
63+
return match (true) {
64+
$entropy >= 120 => PasswordStrength::STRENGTH_VERY_STRONG,
65+
$entropy >= 100 => PasswordStrength::STRENGTH_STRONG,
66+
$entropy >= 80 => PasswordStrength::STRENGTH_REASONABLE,
67+
$entropy >= 60 => PasswordStrength::STRENGTH_WEAK,
68+
default => PasswordStrength::STRENGTH_VERY_WEAK,
69+
};
7570
}
7671
}

src/Symfony/Component/Validator/Tests/Constraints/PasswordStrengthTest.php

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,39 +20,27 @@ class PasswordStrengthTest extends TestCase
2020
public function testConstructor()
2121
{
2222
$constraint = new PasswordStrength();
23-
$this->assertEquals(2, $constraint->minScore);
24-
$this->assertEquals([], $constraint->restrictedData);
23+
$this->assertSame(2, $constraint->minScore);
2524
}
2625

2726
public function testConstructorWithParameters()
2827
{
29-
$constraint = new PasswordStrength([
30-
'minScore' => 3,
31-
'restrictedData' => ['foo', 'bar'],
32-
]);
28+
$constraint = new PasswordStrength(minScore: PasswordStrength::STRENGTH_STRONG);
3329

34-
$this->assertEquals(3, $constraint->minScore);
35-
$this->assertEquals(['foo', 'bar'], $constraint->restrictedData);
30+
$this->assertSame(PasswordStrength::STRENGTH_STRONG, $constraint->minScore);
3631
}
3732

3833
public function testInvalidScoreOfZero()
3934
{
4035
$this->expectException(ConstraintDefinitionException::class);
4136
$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]);
37+
new PasswordStrength(minScore: PasswordStrength::STRENGTH_VERY_WEAK);
4338
}
4439

4540
public function testInvalidScoreOfFive()
4641
{
4742
$this->expectException(ConstraintDefinitionException::class);
4843
$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]]);
44+
new PasswordStrength(minScore: 5);
5745
}
5846
}

src/Symfony/Component/Validator/Tests/Constraints/PasswordStrengthValidatorTest.php

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,19 @@ protected function createValidator(): PasswordStrengthValidator
2525
/**
2626
* @dataProvider getValidValues
2727
*/
28-
public function testValidValues(string $value)
28+
public function testValidValues(string $value, int $expectedStrength)
2929
{
30-
$this->validator->validate($value, new PasswordStrength());
30+
$this->validator->validate($value, new PasswordStrength(minScore: $expectedStrength));
3131

3232
$this->assertNoViolation();
3333
}
3434

3535
public static function getValidValues(): iterable
3636
{
37-
yield ['This 1s a very g00d Pa55word! ;-)'];
37+
yield ['How-is this 🤔?!', PasswordStrength::STRENGTH_WEAK];
38+
yield ['Reasonable-pwd-❤️', PasswordStrength::STRENGTH_REASONABLE];
39+
yield ['This 1s a very g00d Pa55word! ;-)', PasswordStrength::STRENGTH_VERY_STRONG];
40+
yield ['pudding-smack-👌🏼-fox-😎', PasswordStrength::STRENGTH_VERY_STRONG];
3841
}
3942

4043
/**
@@ -59,23 +62,10 @@ public static function provideInvalidConstraints(): iterable
5962
PasswordStrength::PASSWORD_STRENGTH_ERROR,
6063
];
6164
yield [
62-
new PasswordStrength([
63-
'minScore' => 4,
64-
]),
65+
new PasswordStrength(minScore: PasswordStrength::STRENGTH_VERY_STRONG),
6566
'Good password?',
6667
'The password strength is too low. Please use a stronger password.',
6768
PasswordStrength::PASSWORD_STRENGTH_ERROR,
6869
];
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-
];
8070
}
8171
}

src/Symfony/Component/Validator/composer.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,7 @@
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",
44-
"bjeavons/zxcvbn-php": "^1.0"
43+
"egulias/email-validator": "^2.1.10|^3|^4"
4544
},
4645
"conflict": {
4746
"doctrine/annotations": "<1.13",

0 commit comments

Comments
 (0)