8000 [Validator] New `PasswordStrength` constraint · symfony/symfony@1d93f5c · GitHub
[go: up one dir, main page]

Skip to content

Commit 1d93f5c

Browse files
Spomkyfabpot
authored andcommitted
[Validator] New PasswordStrength constraint
1 parent 497e966 commit 1d93f5c

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
39
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+
+
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+ 10000
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