diff --git a/src/Symfony/Component/Validator/Constraints/NotPwned.php b/src/Symfony/Component/Validator/Constraints/NotPwned.php new file mode 100644 index 0000000000..9872076b73 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/NotPwned.php @@ -0,0 +1,33 @@ + + * + * 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; + +/** + * Checks if a password has been leaked in a data breach. + * + * @Annotation + * @Target({"PROPERTY", "METHOD", "ANNOTATION"}) + * + * @author Kévin Dunglas + */ +class NotPwned extends Constraint +{ + const PWNED_ERROR = 'd9bcdbfe-a9d6-4bfa-a8ff-da5fd93e0f6d'; + + protected static $errorNames = [self::PWNED_ERROR => 'PWNED_ERROR']; + + public $message = 'This password has been leaked in a data breach, it must not be used. Please use another password.'; + public $threshold = 1; + public $skipOnError = false; +} diff --git a/src/Symfony/Component/Validator/Constraints/NotPwnedValidator.php b/src/Symfony/Component/Validator/Constraints/NotPwnedValidator.php new file mode 100644 index 0000000000..d0beffb4f2 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/NotPwnedValidator.php @@ -0,0 +1,90 @@ + + * + * 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\HttpClient\HttpClient; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; +use Symfony\Contracts\HttpClient\Exception\ExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * Checks if a password has been leaked in a data breach using haveibeenpwned.com's API. + * Use a k-anonymity model to protect the password being searched for. + * + * @see https://haveibeenpwned.com/API/v2#SearchingPwnedPasswordsByRange + * + * @author Kévin Dunglas + */ +class NotPwnedValidator extends ConstraintValidator +{ + private const RANGE_API = 'https://api.pwnedpasswords.com/range/%s'; + + private $httpClient; + + public function __construct(HttpClientInterface $httpClient = null) + { + if (null === $httpClient && !class_exists(HttpClient::class)) { + throw new \LogicException(sprintf('The "%s" class requires the "HttpClient" component. Try running "composer require symfony/http-client".', self::class)); + } + + $this->httpClient = $httpClient ?? HttpClient::create(); + } + + /** + * {@inheritdoc} + * + * @throws ExceptionInterface + */ + public function validate($value, Constraint $constraint) + { + if (!$constraint instanceof NotPwned) { + throw new UnexpectedTypeException($constraint, NotPwned::class); + } + + if (null !== $value && !is_scalar($value) && !(\is_object($value) && method_exists($value, '__toString'))) { + throw new UnexpectedTypeException($value, 'string'); + } + + $value = (string) $value; + if ('' === $value) { + return; + } + + $hash = strtoupper(sha1($value)); + $hashPrefix = substr($hash, 0, 5); + $url = sprintf(self::RANGE_API, $hashPrefix); + + try { + $result = $this->httpClient->request('GET', $url)->getContent(); + } catch (ExceptionInterface $e) { + if ($constraint->skipOnError) { + return; + } + + throw $e; + } + + foreach (explode("\r\n", $result) as $line) { + list($hashSuffix, $count) = explode(':', $line); + + if ($hashPrefix.$hashSuffix === $hash && $constraint->threshold <= (int) $count) { + $this->context->buildViolation($constraint->message) + ->setCode(NotPwned::PWNED_ERROR) + ->addViolation(); + + return; + } + } + } +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/NotPwnedTest.php b/src/Symfony/Component/Validator/Tests/Constraints/NotPwnedTest.php new file mode 100644 index 0000000000..7d312d8281 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/NotPwnedTest.php @@ -0,0 +1,28 @@ + + * + * 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\NotPwned; + +/** + * @author Kévin Dunglas + */ +class NotPwnedTest extends TestCase +{ + public function testDefaultValues() + { + $constraint = new NotPwned(); + $this->assertSame(1, $constraint->threshold); + $this->assertFalse($constraint->skipOnError); + } +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/NotPwnedValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/NotPwnedValidatorTest.php new file mode 100644 index 0000000000..845ddbde39 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/NotPwnedValidatorTest.php @@ -0,0 +1,145 @@ + + * + * 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\Luhn; +use Symfony\Component\Validator\Constraints\NotPwned; +use Symfony\Component\Validator\Constraints\NotPwnedValidator; +use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; +use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Kévin Dunglas + */ +class NotPwnedValidatorTest extends ConstraintValidatorTestCase +{ + private const PASSWORD_TRIGGERING_AN_ERROR = 'apiError'; + private const PASSWORD_TRIGGERING_AN_ERROR_RANGE_URL = 'https://api.pwnedpasswords.com/range/3EF27'; // https://api.pwnedpasswords.com/range/3EF27 is the range for the value "apiError" + private const PASSWORD_LEAKED = 'maman'; + private const PASSWORD_NOT_LEAKED = ']<0585"%sb^5aa$w6!b38",,72?dp3r4\45b28Hy'; + + private const RETURN = [ + '35E033023A46402F94CFB4F654C5BFE44A1:1', + '35F079CECCC31812288257CD770AA7968D7:53', + '36039744C253F9B2A4E90CBEDB02EBFB82D:5', // this is the matching line, password: maman + '3686792BBC66A72D40D928ED15621124CFE:7', + '36EEC709091B810AA240179A44317ED415C:2', + ]; + + protected function createValidator() + { + $httpClientStub = $this->createMock(HttpClientInterface::class); + $httpClientStub->method('request')->will( + $this->returnCallback(function (string $method, string $url): ResponseInterface { + if (self::PASSWORD_TRIGGERING_AN_ERROR_RANGE_URL === $url) { + throw new class('Problem contacting the Have I been Pwned API.') extends \Exception implements ServerExceptionInterface { + public function getResponse(): ResponseInterface + { + throw new \RuntimeException('Not implemented'); + } + }; + } + + $responseStub = $this->createMock(ResponseInterface::class); + $responseStub + ->method('getContent') + ->willReturn(implode("\r\n", self::RETURN)); + + return $responseStub; + }) + ); + + // Pass HttpClient::create() instead of this mock to run the tests against the real API + return new NotPwnedValidator($httpClientStub); + } + + public function testNullIsValid() + { + $this->validator->validate(null, new NotPwned()); + + $this->assertNoViolation(); + } + + public function testEmptyStringIsValid() + { + $this->validator->validate('', new NotPwned()); + + $this->assertNoViolation(); + } + + public function testInvalidPassword() + { + $constraint = new NotPwned(); + $this->validator->validate(self::PASSWORD_LEAKED, $constraint); + + $this->buildViolation($constraint->message) + ->setCode(NotPwned::PWNED_ERROR) + ->assertRaised(); + } + + public function testThresholdReached() + { + $constraint = new NotPwned(['threshold' => 3]); + $this->validator->validate(self::PASSWORD_LEAKED, $constraint); + + $this->buildViolation($constraint->message) + ->setCode(NotPwned::PWNED_ERROR) + ->assertRaised(); + } + + public function testThresholdNotReached() + { + $this->validator->validate(self::PASSWORD_LEAKED, new NotPwned(['threshold' => 10])); + + $this->assertNoViolation(); + } + + public function testValidPassword() + { + $this->validator->validate(self::PASSWORD_NOT_LEAKED, new NotPwned()); + + $this->assertNoViolation(); + } + + /** + * @expectedException \Symfony\Component\Validator\Exception\UnexpectedTypeException + */ + public function testInvalidConstraint() + { + $this->validator->validate(null, new Luhn()); + } + + /** + * @expectedException \Symfony\Component\Validator\Exception\UnexpectedTypeException + */ + public function testInvalidValue() + { + $this->validator->validate([], new NotPwned()); + } + + /** + * @expectedException \Symfony\Contracts\HttpClient\Exception\ExceptionInterface + * @expectedExceptionMessage Problem contacting the Have I been Pwned API. + */ + public function testApiError() + { + $this->validator->validate(self::PASSWORD_TRIGGERING_AN_ERROR, new NotPwned()); + } + + public function testApiErrorSkipped() + { + $this->validator->validate(self::PASSWORD_TRIGGERING_AN_ERROR, new NotPwned(['skipOnError' => true])); + $this->assertTrue(true); // No exception have been thrown + } +} diff --git a/src/Symfony/Component/Validator/composer.json b/src/Symfony/Component/Validator/composer.json index c17fd098f5..8cba722fc1 100644 --- a/src/Symfony/Component/Validator/composer.json +++ b/src/Symfony/Component/Validator/composer.json @@ -22,6 +22,7 @@ "symfony/polyfill-mbstring": "~1.0" }, "require-dev": { + "symfony/http-client": "^4.3", "symfony/http-foundation": "~4.1", "symfony/http-kernel": "~3.4|~4.0", "symfony/var-dumper": "~3.4|~4.0",