-
-
Notifications
You must be signed in to change notification settings - Fork 9.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feature #27738 [Validator] Add a HaveIBeenPwned password validator (d…
…unglas) This PR was squashed before being merged into the 4.3-dev branch (closes #27738). Discussion ---------- [Validator] Add a HaveIBeenPwned password validator | Q | A | ------------- | --- | Branch? | master | Bug fix? | no | New feature? | yes <!-- don't forget to update src/**/CHANGELOG.md files --> | BC breaks? | no <!-- see https://symfony.com/bc --> | Deprecations? | no <!-- don't forget to update UPGRADE-*.md and src/**/CHANGELOG.md files --> | Tests pass? | yes <!-- please add some, will be required by reviewers --> | Fixed tickets | n/a <!-- #-prefixed issue number(s), if any --> | License | MIT | Doc PR | todo This PR adds a new `Pwned` validation constraint to prevent users to choose passwords that have been leaked in public data breaches. The validator uses the https://haveibeenpwned.com/ API. The implementation is similar to the one used by [Firefox Monitor](https://blog.mozilla.org/futurereleases/2018/06/25/testing-firefox-monitor-a-new-security-tool/). It allows to not expose the password hash using a k-anonymity model. The specific implementation for HaveIBeenPwned has been [described in depth by Cloudflare](https://blog.cloudflare.com/validating-leaked-passwords-with-k-anonymity/). Usage: ```php // Rejects the password if is present in any number of times in any data breach class User { /** @pwned */ public $plainPassword; } // Rejects the password if is present more than 5 times in data breaches class User { /** @pwned(maxCount=5) */ public $plainPassword; } // Customize the error message class User { /** @pwned(message='Please select another password, this one has already been hacked.') */ public $plainPassword; } ``` Commits ------- ec1ded8 [Validator] Add a HaveIBeenPwned password validator
- Loading branch information
Showing
5 changed files
with
297 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} |
90 changes: 90 additions & 0 deletions
90
src/Symfony/Component/Validator/Constraints/NotPwnedValidator.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} | ||
} | ||
} | ||
} |
28 changes: 28 additions & 0 deletions
28
src/Symfony/Component/Validator/Tests/Constraints/NotPwnedTest.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
} | ||
} |
145 changes: 145 additions & 0 deletions
145
src/Symfony/Component/Validator/Tests/Constraints/NotPwnedValidatorTest.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters