-
-
Notifications
You must be signed in to change notification settings - Fork 9.6k
[Validator] Add a HaveIBeenPwned password validator #27738
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 <[email protected]> | ||
* | ||
* 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 <[email protected]> | ||
*/ | ||
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.'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should be added in not sure we'll do another round of good first issues for the remaining locales :} |
||
public $threshold = 1; | ||
fabpot marked this conversation as resolved.
Show resolved
Hide resolved
|
||
public $skipOnError = false; | ||
} |
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 <[email protected]> | ||
* | ||
* 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 <[email protected]> | ||
*/ | ||
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'))) { | ||
fabpot marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I still don't think that throwing here by default makes sense. What should be configurable is whether a HTTP failure will make the validator create a constraint violation or just skip. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The system must be as secure as possible by default. If there is an outage for the service, I prefer to retry latter to create the account of my company user than letting using something like "mum", or one that already has leaked. Now this behavior can be change using a simple attribute.
It's exactly what the new attribute does, or am I missing something? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Throwing an exception will not create a constraint violation, but will lead in a server error. From the user's point of view that's the worst that could happen as they won't get any feedback of what went wrong and if there is anything they could do. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. But if de don’t throw, how the monitoring system will detect the ongoing issue? It should be very exceptional and should probably trigger an alert. Alternatively I can change the attribute to accept three value: throw (default), skip or fail. wdyt? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we should log by default and add a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If there is an exception it basically means that 3rd party is down, which means that there is an unrecoverable error. If you (as a system) decided that whatever password entered should NOT have been "pwned" then at this point we should throw an exception here, not log something. Now, if you "prefer" it to not have been "pwned" just set There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm on the same side as @sroze |
||
} | ||
|
||
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; | ||
} | ||
} | ||
} | ||
} |
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 <[email protected]> | ||
* | ||
* 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 <[email protected]> | ||
*/ | ||
class NotPwnedTest extends TestCase | ||
{ | ||
public function testDefaultValues() | ||
{ | ||
$constraint = new NotPwned(); | ||
$this->assertSame(1, $constraint->threshold); | ||
$this->assertFalse($constraint->skipOnError); | ||
} | ||
} |
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 <[email protected]> | ||
* | ||
* 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 <[email protected]> | ||
*/ | ||
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 | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
perhaps clarify the password should NOT be leaked :/