8000 [Validator] Add a HaveIBeenPwned password validator · symfony/symfony@f252fdd · GitHub
[go: up one dir, main page]

Skip to content

Commit f252fdd

Browse files
committed
[Validator] Add a HaveIBeenPwned password validator
1 parent 1a3d445 commit f252fdd

File tree

4 files changed

+258
-0
lines changed

4 files changed

+258
-0
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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 <dunglas@gmail.com>
23+
*/
24+
class Pwned extends Constraint
25+
{
26+
const PWNED_ERROR = 'd9bcdbfe-a9d6-4bfa-a8ff-da5fd93e0f6d';
27+
28+
protected static $errorNames = array(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 $maxCount = 1;
32+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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+
use Symfony\Component\Validator\ConstraintValidator;
16+
use Symfony\Component\Validator\Exception\RuntimeException;
17+
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
18+
19+
/**
20+
* Checks if a password has been leaked in a data breach using haveibeenpwned.com's API.
21+
* Use a k-anonymity model to protect the password being searched for.
22+
*
23+
* @see https://haveibeenpwned.com/API/v2#SearchingPwnedPasswordsByRange
24+
* @author Kévin Dunglas <dunglas@gmail.com>
25+
*/
26+
class PwnedValidator extends ConstraintValidator
27+
{
28+
private const RANGE_API = 'https://api.pwnedpasswords.com/range/%s';
29+
private $httpClient;
30+
/**
31+
* @param callable|null $httpClient Must mimic the file_get_contents's signature: function(string $url): string|false (false is returned in case of error)
32+
*/
33+
public function __construct(?callable $httpClient = null)
34+
{
35+
$this->httpClient = $httpClient;
36+
}
37+
38+
/**
39+
* {@inheritdoc}
40+
*/
41+
public function validate($value, Constraint $constraint)
42+
{
43+
if (!$constraint instanceof Pwned) {
44+
throw new UnexpectedTypeException($constraint, Pwned::class);
45+
}
46+
47+
if (null === $value || '' === $value) {
48+
return;
49+
}
50+
51+
if (\is_array($value) || (\is_object($value) && !method_exists($value, '__toString'))) {
52+
throw new UnexpectedTypeException($value, 'string');
53+
}
54+
55+
$httpClient = $this->httpClient;
56+
57+
$hash = strtoupper(sha1($value));
58+
$hashPrefix = substr($hash, 0, 5);
59+
$url = sprintf(self::RANGE_API, $hashPrefix);
60+
61+
$result = $httpClient ? $httpClient($url) : @file_get_contents($url);
62+
if (false === $result) {
63+
throw new RuntimeException('Problem contacting the Have I been Pwned API.');
64+
}
65+
66+
foreach (explode("\r\n", $result) as $line) {
67+
list($hashSuffix, $count) = explode(':', $line);
68+
69+
if ($hashPrefix.$hashSuffix === $hash && (int) $count >= $constraint->maxCount) {
70+
$this->context->buildViolation($constraint->message)
71+
->setCode(Pwned::PWNED_ERROR)
72+
->addViolation();
73+
return;
74+
}
75+
}
76+
}
77+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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\Pwned;
16+
17+
/**
18+
* @author Kévin Dunglas <dunglas@gmail.com>
19+
*/
20+
class PwnedTest extends TestCase
21+
{
22+
public function testDefaultValues()
23+
{
24+
$constraint = new Pwned();
25+
$this->assertSame(1, $constraint->maxCount);
26+
}
27+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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\Pwned;
16+
use Symfony\Component\Validator\Constraints\PwnedValidator;
17+
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
18+
19+
/**
20+
* @author Kévin Dunglas <dunglas@gmail.com>
21+
*/
22+
class PwnedValidatorTest extends ConstraintValidatorTestCase
23+
{
24+
private const RETURN = array(
25+
'35E033023A46402F94CFB4F654C5BFE44A1:1',
26+
'35F079CECCC31812288257CD770AA7968D7:53',
27+
'36039744C253F9B2A4E90CBEDB02EBFB82D:5', // this is the matching line, password: maman
28+
'3686792BBC66A72D40D928ED15621124CFE:7',
29+
'36EEC709091B810AA240179A44317ED415C:2',
30+
);
31+
32+
protected function createValidator()
33+
{
34+
$httpClient = function (string $url) {
35+
if ('https://api.pwnedpasswords.com/range/3EF27' === $url) {
36+
// Simulate a connection error
37+
// https://api.pwnedpasswords.com/range/3EF27 is the range for the value "apiError"
38+
return false;
39+
}
40+
41+
return implode("\r\n", self::RETURN);
42+
};
43+
44+
// Pass null instead of this mock to run the tests against the real API
45+
return new PwnedValidator($httpClient);
46+
}
47+
48+
public function testNullIsValid()
49+
{
50+
$this->validator->validate(null, new Pwned());
51+
52+
$this->assertNoViolation();
53+
}
54+
55+
public function testEmptyStringIsValid()
56+
{
57+
$this->validator->validate('', new Pwned());
58+
59+
$this->assertNoViolation();
60+
}
61+
62+
public function testInvalidPassword()
63+
{
64+
$constraint = new Pwned();
65+
$this->validator->validate('maman', $constraint);
66+
67+
$this->buildViolation($constraint->message)
68+
->setCode(Pwned::PWNED_ERROR)
69+
->assertRaised();
70+
}
71+
72+
public function testMaxCountReached()
73+
{
74+
$constraint = new Pwned(array('maxCount' => 3));
75+
$this->validator->validate('maman', $constraint);
76+
77+
$this->buildViolation($constraint->message)
78+
->setCode(Pwned::PWNED_ERROR)
79+
->assertRaised();
80+
}
81+
82+
public function testMaxCountNotReached()
83+
{
84+
$constraint = new Pwned(array('maxCount' => 10));
85+
$this->validator->validate('maman', $constraint);
86+
87+
$this->assertNoViolation();
88+
}
89+
90+
public function testValidPassword()
91+
{
92+
$constraint = new Pwned();
93+
$this->validator->validate(']<0585"%sb^5aa$w6!b38",,72?dp3r4\45b28Hy', $constraint);
94+
95+
$this->assertNoViolation();
96+
}
97+
98+
/**
99+
* @expectedException \Symfony\Component\Validator\Exception\UnexpectedTypeException
100+
*/
101+
public function testInvalidConstraint()
102+
{
103+
$this->validator->validate(null, new Luhn());
104+
}
105+
106+
/**
107+
* @expectedException \Symfony\Component\Validator\Exception\UnexpectedTypeException
108+
*/
109+
public function testInvalidValue()
110+
{
111+
$this->validator->validate(array(), new Pwned());
112+
}
113+
114+
/**
115+
* @expectedException \Symfony\Component\Validator\Exception\RuntimeException
116+
* @expectedExceptionMessage Problem contacting the Have I been Pwned API.
117+
*/
118+
public function testApiError()
119+
{
120+
$this->validator->validate('apiError', new Pwned());
121+
}
122+
}

0 commit comments

Comments
 (0)
0