Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit ec1ded8

Browse files
dunglasfabpot
authored andcommitted
[Validator] Add a HaveIBeenPwned password validator
1 parent af28965 commit ec1ded8

File tree

5 files changed

+297
-0
lines changed

5 files changed

+297
-0
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Validator\Constraints;
13+
14+
use Symfony\Component\Validator\Constraint;
15+
16+
/**
17+
* Checks if a password has been leaked in a data breach.
18+
*
19+
* @Annotation
20+
* @Target({"PROPERTY", "METHOD", "ANNOTATION"})
21+
*
22+
* @author Kévin Dunglas <[email protected]>
23+
*/
24+
class NotPwned extends Constraint
25+
{
26+
const PWNED_ERROR = 'd9bcdbfe-a9d6-4bfa-a8ff-da5fd93e0f6d';
27+
28+
protected static $errorNames = [self::PWNED_ERROR => 'PWNED_ERROR'];
29+
30+
public $message = 'This password has been leaked in a data breach, it must not be used. Please use another password.';
31+
public $threshold = 1;
32+
public $skipOnError = false;
33+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Validator\Constraints;
13+
14+
use Symfony\Component\HttpClient\HttpClient;
15+
use Symfony\Component\Validator\Constraint;
16+
use Symfony\Component\Validator\ConstraintValidator;
17+
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
18+
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
19+
use Symfony\Contracts\HttpClient\HttpClientInterface;
20+
21+
/**
22+
* Checks if a password has been leaked in a data breach using haveibeenpwned.com's API.
23+
* Use a k-anonymity model to protect the password being searched for.
24+
*
25+
* @see https://haveibeenpwned.com/API/v2#SearchingPwnedPasswordsByRange
26+
*
27+
* @author Kévin Dunglas <[email protected]>
28+
*/
29+
class NotPwnedValidator extends ConstraintValidator
30+
{
31+
private const RANGE_API = 'https://api.pwnedpasswords.com/range/%s';
32+
33+
private $httpClient;
34+
35+
public function __construct(HttpClientInterface $httpClient = null)
36+
{
37+
if (null === $httpClient && !class_exists(HttpClient::class)) {
38+
throw new \LogicException(sprintf('The "%s" class requires the "HttpClient" component. Try running "composer require symfony/http-client".', self::class));
39+
}
40+
41+
$this->httpClient = $httpClient ?? HttpClient::create();
42+
}
43+
44+
/**
45+
* {@inheritdoc}
46+
*
47+
* @throws ExceptionInterface
48+
*/
49+
public function validate($value, Constraint $constraint)
50+
{
51+
if (!$constraint instanceof NotPwned) {
52+
throw new UnexpectedTypeException($constraint, NotPwned::class);
53+
}
54+
55+
if (null !== $value && !is_scalar($value) && !(\is_object($value) && method_exists($value, '__toString'))) {
56+
throw new UnexpectedTypeException($value, 'string');
57+
}
58+
59+
$value = (string) $value;
60+
if ('' === $value) {
61+
return;
62+
}
63+
64+
$hash = strtoupper(sha1($value));
65+
$hashPrefix = substr($hash, 0, 5);
66+
$url = sprintf(self::RANGE_API, $hashPrefix);
67+
68+
try {
69+
$result = $this->httpClient->request('GET', $url)->getContent();
70+
} catch (ExceptionInterface $e) {
71+
if ($constraint->skipOnError) {
72+
return;
73+
}
74+
75+
throw $e;
76+
}
77+
78+
foreach (explode("\r\n", $result) as $line) {
79+
list($hashSuffix, $count) = explode(':', $line);
80+
81+
if ($hashPrefix.$hashSuffix === $hash && $constraint->threshold <= (int) $count) {
82+
$this->context->buildViolation($constraint->message)
83+
->setCode(NotPwned::PWNED_ERROR)
84+
->addViolation();
85+
86+
return;
87+
}
88+
}
89+
}
90+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Validator\Tests\Constraints;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Validator\Constraints\NotPwned;
16+
17+
/**
18+
* @author Kévin Dunglas <[email protected]>
19+
*/
20+
class NotPwnedTest extends TestCase
21+
{
22+
public function testDefaultValues()
23+
{
24+
$constraint = new NotPwned();
25+
$this->assertSame(1, $constraint->threshold);
26+
$this->assertFalse($constraint->skipOnError);
27+
}
28+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Validator\Tests\Constraints;
13+
14+
use Symfony\Component\Validator\Constraints\Luhn;
15+
use Symfony\Component\Validator\Constraints\NotPwned;
16+
use Symfony\Component\Validator\Constraints\NotPwnedValidator;
17+
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
18+
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
19+
use Symfony\Contracts\HttpClient\HttpClientInterface;
20+
use Symfony\Contracts\HttpClient\ResponseInterface;
21+
22+
/**
23+
* @author Kévin Dunglas <[email protected]>
24+
*/
25+
class NotPwnedValidatorTest extends ConstraintValidatorTestCase
26+
{
27+
private const PASSWORD_TRIGGERING_AN_ERROR = 'apiError';
28+
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"
29+
private const PASSWORD_LEAKED = 'maman';
30+
private const PASSWORD_NOT_LEAKED = ']<0585"%sb^5aa$w6!b38",,72?dp3r4\45b28Hy';
31+
32+
private const RETURN = [
33+
'35E033023A46402F94CFB4F654C5BFE44A1:1',
34+
'35F079CECCC31812288257CD770AA7968D7:53',
35+
'36039744C253F9B2A4E90CBEDB02EBFB82D:5', // this is the matching line, password: maman
36+
'3686792BBC66A72D40D928ED15621124CFE:7',
37+
'36EEC709091B810AA240179A44317ED415C:2',
38+
];
39+
40+
protected function createValidator()
41+
{
42+
$httpClientStub = $this->createMock(HttpClientInterface::class);
43+
$httpClientStub->method('request')->will(
44+
$this->returnCallback(function (string $method, string $url): ResponseInterface {
45+
if (self::PASSWORD_TRIGGERING_AN_ERROR_RANGE_URL === $url) {
46+
throw new class('Problem contacting the Have I been Pwned API.') extends \Exception implements ServerExceptionInterface {
47+
public function getResponse(): ResponseInterface
48+
{
49+
throw new \RuntimeException('Not implemented');
50+
}
51+
};
52+
}
53+
54+
$responseStub = $this->createMock(ResponseInterface::class);
55+
$responseStub
56+
->method('getContent')
57+
->willReturn(implode("\r\n", self::RETURN));
58+
59+
return $responseStub;
60+
})
61+
);
62+
63+
// Pass HttpClient::create() instead of this mock to run the tests against the real API
64+
return new NotPwnedValidator($httpClientStub);
65+
}
66+
67+
public function testNullIsValid()
68+
{
69+
$this->validator->validate(null, new NotPwned());
70+
71+
$this->assertNoViolation();
72+
}
73+
74+
public function testEmptyStringIsValid()
75+
{
76+
$this->validator->validate('', new NotPwned());
77+
78+
$this->assertNoViolation();
79+
}
80+
81+
public function testInvalidPassword()
82+
{
83+
$constraint = new NotPwned();
84+
$this->validator->validate(self::PASSWORD_LEAKED, $constraint);
85+
86+
$this->buildViolation($constraint->message)
87+
->setCode(NotPwned::PWNED_ERROR)
88+
->assertRaised();
89+
}
90+
91+
public function testThresholdReached()
92+
{
93+
$constraint = new NotPwned(['threshold' => 3]);
94+
$this->validator->validate(self::PASSWORD_LEAKED, $constraint);
95+
96+
$this->buildViolation($constraint->message)
97+
->setCode(NotPwned::PWNED_ERROR)
98+
->assertRaised();
99+
}
100+
101+
public function testThresholdNotReached()
102+
{
103+
$this->validator->validate(self::PASSWORD_LEAKED, new NotPwned(['threshold' => 10]));
104+
105+
$this->assertNoViolation();
106+
}
107+
108+
public function testValidPassword()
109+
{
110+
$this->validator->validate(self::PASSWORD_NOT_LEAKED, new NotPwned());
111+
112+
$this->assertNoViolation();
113+
}
114+
115+
/**
116+
* @expectedException \Symfony\Component\Validator\Exception\UnexpectedTypeException
117+
*/
118+
public function testInvalidConstraint()
119+
{
120+
$this->validator->validate(null, new Luhn());
121+
}
122+
123+
/**
124+
* @expectedException \Symfony\Component\Validator\Exception\UnexpectedTypeException
125+
*/
126+
public function testInvalidValue()
127+
{
128+
$this->validator->validate([], new NotPwned());
129+
}
130+
131+
/**
132+
* @expectedException \Symfony\Contracts\HttpClient\Exception\ExceptionInterface
133+
* @expectedExceptionMessage Problem contacting the Have I been Pwned API.
134+
*/
135+
public function testApiError()
136+
{
137+
$this->validator->validate(self::PASSWORD_TRIGGERING_AN_ERROR, new NotPwned());
138+
}
139+
140+
public function testApiErrorSkipped()
141+
{
142+
$this->validator->validate(self::PASSWORD_TRIGGERING_AN_ERROR, new NotPwned(['skipOnError' => true]));
143+
$this->assertTrue(true); // No exception have been thrown
144+
}
145+
}

src/Symfony/Component/Validator/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"symfony/polyfill-mbstring": "~1.0"
2323
},
2424
"require-dev": {
25+
"symfony/http-client": "^4.3",
2526
"symfony/http-foundation": "~4.1",
2627
"symfony/http-kernel": "~3.4|~4.0",
2728
"symfony/var-dumper": "~3.4|~4.0",

0 commit comments

Comments
 (0)