[Validator] Make API endpoint for NotCompromisedPasswordValidator configurable

This commit is contained in:
Andreas Erhard 2019-04-10 15:29:36 +02:00 committed by Fabien Potencier
parent b09dfd9d8e
commit f6a80c214d
5 changed files with 68 additions and 13 deletions

View File

@ -835,9 +835,18 @@ class Configuration implements ConfigurationInterface
->end() ->end()
->end() ->end()
->end() ->end()
->booleanNode('disable_not_compromised_password') ->arrayNode('not_compromised_password')
->defaultFalse() ->canBeDisabled()
->info('Disable NotCompromisedPassword Validator: the value will always be valid.') ->children()
->booleanNode('enabled')
->defaultTrue()
->info('When disabled, compromised passwords will be accepted as valid.')
->end()
->scalarNode('endpoint')
->defaultNull()
->info('API endpoint for the NotCompromisedPassword Validator.')
->end()
->end()
->end() ->end()
->arrayNode('auto_mapping') ->arrayNode('auto_mapping')
->useAttributeAsKey('namespace') ->useAttributeAsKey('namespace')

View File

@ -1249,7 +1249,8 @@ class FrameworkExtension extends Extension
$container $container
->getDefinition('validator.not_compromised_password') ->getDefinition('validator.not_compromised_password')
->setArgument(2, $config['disable_not_compromised_password']) ->setArgument(2, $config['not_compromised_password']['enabled'])
->setArgument(3, $config['not_compromised_password']['endpoint'])
; ;
} }

View File

@ -235,7 +235,10 @@ class ConfigurationTest extends TestCase
'paths' => [], 'paths' => [],
], ],
'auto_mapping' => [], 'auto_mapping' => [],
'disable_not_compromised_password' => false, 'not_compromised_password' => [
'enabled' => true,
'endpoint' => null,
],
], ],
'annotations' => [ 'annotations' => [
'cache' => 'php_array', 'cache' => 'php_array',

View File

@ -29,13 +29,14 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
*/ */
class NotCompromisedPasswordValidator extends ConstraintValidator class NotCompromisedPasswordValidator extends ConstraintValidator
{ {
private const RANGE_API = 'https://api.pwnedpasswords.com/range/%s'; private const DEFAULT_API_ENDPOINT = 'https://api.pwnedpasswords.com/range/%s';
private $httpClient; private $httpClient;
private $charset; private $charset;
private $disabled; private $enabled;
private $endpoint;
public function __construct(HttpClientInterface $httpClient = null, string $charset = 'UTF-8', bool $disabled = false) public function __construct(HttpClientInterface $httpClient = null, string $charset = 'UTF-8', bool $enabled = true, string $endpoint = null)
{ {
if (null === $httpClient && !class_exists(HttpClient::class)) { 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)); throw new \LogicException(sprintf('The "%s" class requires the "HttpClient" component. Try running "composer require symfony/http-client".', self::class));
@ -43,7 +44,8 @@ class NotCompromisedPasswordValidator extends ConstraintValidator
$this->httpClient = $httpClient ?? HttpClient::create(); $this->httpClient = $httpClient ?? HttpClient::create();
$this->charset = $charset; $this->charset = $charset;
$this->disabled = $disabled; $this->enabled = $enabled;
$this->endpoint = $endpoint ?? self::DEFAULT_API_ENDPOINT;
} }
/** /**
@ -57,7 +59,7 @@ class NotCompromisedPasswordValidator extends ConstraintValidator
throw new UnexpectedTypeException($constraint, NotCompromisedPassword::class); throw new UnexpectedTypeException($constraint, NotCompromisedPassword::class);
} }
if ($this->disabled) { if (!$this->enabled) {
return; return;
} }
@ -76,7 +78,7 @@ class NotCompromisedPasswordValidator extends ConstraintValidator
$hash = strtoupper(sha1($value)); $hash = strtoupper(sha1($value));
$hashPrefix = substr($hash, 0, 5); $hashPrefix = substr($hash, 0, 5);
$url = sprintf(self::RANGE_API, $hashPrefix); $url = sprintf($this->endpoint, $hashPrefix);
try { try {
$result = $this->httpClient->request('GET', $url)->getContent(); $result = $this->httpClient->request('GET', $url)->getContent();

View File

@ -62,9 +62,9 @@ class NotCompromisedPasswordValidatorTest extends ConstraintValidatorTestCase
public function testInvalidPasswordButDisabled() public function testInvalidPasswordButDisabled()
{ {
$r = new \ReflectionProperty($this->validator, 'disabled'); $r = new \ReflectionProperty($this->validator, 'enabled');
$r->setAccessible(true); $r->setAccessible(true);
$r->setValue($this->validator, true); $r->setValue($this->validator, false);
$this->validator->validate(self::PASSWORD_LEAKED, new NotCompromisedPassword()); $this->validator->validate(self::PASSWORD_LEAKED, new NotCompromisedPassword());
@ -128,6 +128,29 @@ class NotCompromisedPasswordValidatorTest extends ConstraintValidatorTestCase
->assertRaised(); ->assertRaised();
} }
public function testInvalidPasswordCustomEndpoint()
{
$endpoint = 'https://password-check.internal.example.com/range/%s';
// 50D74 - first 5 bytes of uppercase SHA1 hash of self::PASSWORD_LEAKED
$expectedEndpointUrl = 'https://password-check.internal.example.com/range/50D74';
$constraint = new NotCompromisedPassword();
$this->context = $this->createContext();
$validator = new NotCompromisedPasswordValidator(
$this->createHttpClientStubCustomEndpoint($expectedEndpointUrl),
'UTF-8',
true,
$endpoint
);
$validator->initialize($this->context);
$validator->validate(self::PASSWORD_LEAKED, $constraint);
$this->buildViolation($constraint->message)
->setCode(NotCompromisedPassword::COMPROMISED_PASSWORD_ERROR)
->assertRaised();
}
/** /**
* @expectedException \Symfony\Component\Validator\Exception\UnexpectedTypeException * @expectedException \Symfony\Component\Validator\Exception\UnexpectedTypeException
*/ */
@ -184,4 +207,21 @@ class NotCompromisedPasswordValidatorTest extends ConstraintValidatorTestCase
return $httpClientStub; return $httpClientStub;
} }
private function createHttpClientStubCustomEndpoint($expectedEndpoint): HttpClientInterface
{
$httpClientStub = $this->createMock(HttpClientInterface::class);
$httpClientStub->method('request')->with('GET', $expectedEndpoint)->will(
$this->returnCallback(function (string $method, string $url): ResponseInterface {
$responseStub = $this->createMock(ResponseInterface::class);
$responseStub
->method('getContent')
->willReturn(implode("\r\n", self::RETURN));
return $responseStub;
})
);
return $httpClientStub;
}
} }