feature #31060 [Validator] Make API endpoint for NotCompromisedPasswordValidator configurable (xelan)
This PR was squashed before being merged into the 4.3-dev branch (closes #31060).
Discussion
----------
[Validator] Make API endpoint for NotCompromisedPasswordValidator configurable
| Q | A
| ------------- | ---
| Branch? | master
| Bug fix? | no
| New feature? | yes
| BC breaks? | yes, but acceptable [1]
| Deprecations? | no [1]
| Tests pass? | yes
| Fixed tickets | #30871, #31054
| License | MIT
| Doc PR | symfony/symfony-docs#... (TODO)
Makes the API endpoint for the `NotCompromisedPasswordValidator` configurable. The endpoint includes the placeholder which will be replaced with the first digits of the password hash for k-anonymity.
The endpoint can either be set via constructor injection of the validator if the component is used standalone, or via the framework configuration of symfony/framework-bundle.
[1] As discussed in #31054, the validator is not in a stable release yet, therefore the BC break is considered acceptable. No deprecation / BC layer is necessary.
Commits
-------
f6a80c214d
[Validator] Make API endpoint for NotCompromisedPasswordValidator configurable
This commit is contained in:
commit
ecfccc6ef0
@ -834,9 +834,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')
|
||||||
|
@ -1262,7 +1262,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'])
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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',
|
||||||
|
@ -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();
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user