Skip to content

Commit

Permalink
[Validator] New PasswordStrength constraint
Browse files Browse the repository at this point in the history
  • Loading branch information
Spomky authored and fabpot committed Mar 26, 2023
1 parent 497e966 commit 1d93f5c
Show file tree
Hide file tree
Showing 7 changed files with 287 additions and 2 deletions.
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/Symfony/Component/Validator/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 67 additions & 0 deletions src/Symfony/Component/Validator/Constraints/PasswordStrength.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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 <[email protected]>
*/
#[\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<string>
*/
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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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<MatchInterface> $sequence
*
* @return array<string>
*/
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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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]]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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',
],
];
}
}
3 changes: 2 additions & 1 deletion src/Symfony/Component/Validator/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down

0 comments on commit 1d93f5c

Please sign in to comment.