[Validator] Add a HaveIBeenPwned password validator
This commit is contained in:
parent
af28965c24
commit
ec1ded898a
33
src/Symfony/Component/Validator/Constraints/NotPwned.php
Normal file
33
src/Symfony/Component/Validator/Constraints/NotPwned.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* 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 <dunglas@gmail.com>
|
||||
*/
|
||||
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;
|
||||
}
|
@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* 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 <dunglas@gmail.com>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* 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 <dunglas@gmail.com>
|
||||
*/
|
||||
class NotPwnedTest extends TestCase
|
||||
{
|
||||
public function testDefaultValues()
|
||||
{
|
||||
$constraint = new NotPwned();
|
||||
$this->assertSame(1, $constraint->threshold);
|
||||
$this->assertFalse($constraint->skipOnError);
|
||||
}
|
||||
}
|
@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* 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 <dunglas@gmail.com>
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
@ -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",
|
||||
|
Reference in New Issue
Block a user