Skip to content

Commit

Permalink
feature #27738 [Validator] Add a HaveIBeenPwned password validator (d…
Browse files Browse the repository at this point in the history
…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
fabpot committed Apr 1, 2019
2 parents 1fcc994 + ec1ded8 commit b01fd5f
Show file tree
Hide file tree
Showing 5 changed files with 297 additions and 0 deletions.
33 changes: 33 additions & 0 deletions src/Symfony/Component/Validator/Constraints/NotPwned.php
@@ -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 src/Symfony/Component/Validator/Constraints/NotPwnedValidator.php
@@ -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 src/Symfony/Component/Validator/Tests/Constraints/NotPwnedTest.php
@@ -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
}
}
1 change: 1 addition & 0 deletions src/Symfony/Component/Validator/composer.json
Expand Up @@ -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",
Expand Down

0 comments on commit b01fd5f

Please sign in to comment.