diff --git a/.circleci/config.yml b/.circleci/config.yml index 6696b22..93e9660 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -77,4 +77,4 @@ jobs: - run: name: Execute mutation testing - command: ./vendor/bin/infection --configuration=infection.json.dist --coverage=build/logs/coverage + command: ./vendor/bin/infection --configuration=infection.json.dist --coverage=build/logs/coverage --only-covered diff --git a/composer.json b/composer.json index 71f5107..b05b418 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,11 @@ "type": "library", "require": { "php": ">= 7.2", - "guzzlehttp/guzzle": "^6.3" + "guzzlehttp/guzzle": "^6.3", + "psr/http-message": "^1.0", + "psr/http-client": "^1.0", + "ricardofiorani/guzzle-psr18-adapter": "^1.0", + "psr/http-factory": "^1.0" }, "autoload": { "psr-4": { @@ -28,5 +32,17 @@ "email": "dragonbe+github@gmail.com" } ], - "minimum-stability": "stable" + "minimum-stability": "stable", + "scripts": { + "check": [ + "@cs-check", + "@test", + "@infection" + ], + "cs-check": "phpcs", + "cs-fix": "phpcbf", + "test": "phpunit --colors=always", + "test-coverage": "phpunit --colors=always --coverage-clover clover.xml", + "infection": "infection --only-covered" + } } diff --git a/composer.lock b/composer.lock index 1c671b6..974f3ad 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c317ead1b5a78777df764d1d4a8c3b83", + "content-hash": "f92dd5bec8f0701992a6e12390dbf559", "packages": [ { "name": "guzzlehttp/guzzle", @@ -187,6 +187,107 @@ ], "time": "2017-03-20T17:10:46+00:00" }, + { + "name": "psr/http-client", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "496a823ef742b632934724bf769560c2a5c7c44e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/496a823ef742b632934724bf769560c2a5c7c44e", + "reference": "496a823ef742b632934724bf769560c2a5c7c44e", + "shasum": "" + }, + "require": { + "php": "^7.0", + "psr/http-message": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "time": "2018-10-30T23:29:13+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "378bfe27931ecc54ff824a20d6f6bfc303bbd04c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/378bfe27931ecc54ff824a20d6f6bfc303bbd04c", + "reference": "378bfe27931ecc54ff824a20d6f6bfc303bbd04c", + "shasum": "" + }, + "require": { + "php": ">=7.0.0", + "psr/http-message": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "time": "2018-07-30T21:54:04+00:00" + }, { "name": "psr/http-message", "version": "1.0.1", @@ -236,6 +337,63 @@ "response" ], "time": "2016-08-06T14:39:51+00:00" + }, + { + "name": "ricardofiorani/guzzle-psr18-adapter", + "version": "v1.0.2", + "source": { + "type": "git", + "url": "https://github.com/ricardofiorani/guzzle-psr18-adapter.git", + "reference": "380e1ee4a4efcfd31f5defd2f21217cc3def6f59" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ricardofiorani/guzzle-psr18-adapter/zipball/380e1ee4a4efcfd31f5defd2f21217cc3def6f59", + "reference": "380e1ee4a4efcfd31f5defd2f21217cc3def6f59", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^6.3", + "php": "^7.1", + "psr/http-client": "^1.0" + }, + "provide": { + "psr/http-client": "^1.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.3", + "spryker/code-sniffer": "^0.12.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "RicardoFiorani\\GuzzlePsr18Adapter\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ricardo Fiorani", + "email": "ricardo.fiorani@gmail.com", + "homepage": "https://github.com/ricardofiorani", + "role": "Developer" + } + ], + "description": "A Guzzle PSR-18 adapter", + "homepage": "https://github.com/ricardofiorani/guzzle-psr18-adapter", + "keywords": [ + "Guzzle", + "psr-18" + ], + "time": "2018-12-11T09:10:08+00:00" } ], "packages-dev": [ diff --git a/examples/hibp-count.php b/examples/hibp-count.php index a8e5aeb..aabf6c9 100644 --- a/examples/hibp-count.php +++ b/examples/hibp-count.php @@ -1,8 +1,25 @@ isPwnedPassword($password); diff --git a/examples/hibp-psr18.php b/examples/hibp-psr18.php new file mode 100644 index 0000000..ee726ed --- /dev/null +++ b/examples/hibp-psr18.php @@ -0,0 +1,44 @@ +isPwnedPassword('password') ? 'Pwned' : 'OK') . PHP_EOL; +echo 'Password "NVt3MpvQ": ' . ($hibp->isPwnedPassword('NVt3MpvQ') ? 'Pwned' : 'OK') . PHP_EOL; diff --git a/examples/hibp.php b/examples/hibp.php index c44a348..92de7e9 100644 --- a/examples/hibp.php +++ b/examples/hibp.php @@ -1,8 +1,22 @@ isPwnedPassword('password') ? 'Pwned' : 'OK') . PHP_EOL; echo 'Password "NVt3MpvQ": ' . ($hibp->isPwnedPassword('NVt3MpvQ') ? 'Pwned' : 'OK') . PHP_EOL; diff --git a/src/Hibp.php b/src/Hibp.php index 35a9eca..11d6879 100644 --- a/src/Hibp.php +++ b/src/Hibp.php @@ -3,11 +3,13 @@ namespace Dragonbe\Hibp; -use GuzzleHttp\ClientInterface; -use GuzzleHttp\Exception\ClientException; -use GuzzleHttp\Exception\ConnectException; +use GuzzleHttp\Psr7\Request; +use Psr\Http\Client\ClientInterface; +use Psr\Http\Client\ClientExceptionInterface; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; -class Hibp implements \Countable +class Hibp implements HibpInterface, \Countable { const HIBP_API_URI = 'https://api.pwnedpasswords.com'; const HIBP_API_TIMEOUT = 300; @@ -16,12 +18,23 @@ class Hibp implements \Countable const HIBP_RANGE_LENGTH = 5; const HIBP_RANGE_BASE = 0; const HIBP_COUNT_BASE = 0; + const SHA1_LENGTH = 40; /** * @var ClientInterface */ protected $client; + /** + * @var RequestInterface + */ + protected $request; + + /** + * @var ResponseInterface + */ + protected $response; + /** * @var int */ @@ -31,33 +44,42 @@ class Hibp implements \Countable * Hibp constructor. * * @param ClientInterface $client + * @param RequestInterface $request + * @param ResponseInterface $response */ - public function __construct(ClientInterface $client) - { + public function __construct( + ClientInterface $client, + RequestInterface $request, + ResponseInterface $response + ) { $this->client = $client; + $this->request = $request; + $this->response = $response; } /** - * Checks a password against HIBP service and checks - * if the password is matching in the resultset - * - * @param string $password - * @param bool $isShaHash - * @return bool + * @inheritDoc */ public function isPwnedPassword(string $password, bool $isShaHash = false): bool { if (! $isShaHash) { $password = sha1($password); } + if (self::SHA1_LENGTH !== strlen($password) && $isShaHash) { + throw new \InvalidArgumentException( + 'Password does not appear to be a SHA1 hashed password, please verify your input' + ); + } $password = strtoupper($password); $range = $this->getHashRange($password); + + $requestClass = get_class($this->request); + $request = new $requestClass('GET', '/range/' . $range); + try { - $response = $this->client->get('/range/' . $range); - } catch (ConnectException $connectException) { + $response = $this->client->sendRequest($request); + } catch (ClientExceptionInterface $connectException) { throw $this->exception(\RuntimeException::class, 'Cannot connect to HIBP API'); - } catch (ClientException $clientException) { - throw $this->exception(\DomainException::class, $clientException->getMessage()); } $resultStream = (string) $response->getBody(); return $this->passwordInResponse($password, $resultStream); diff --git a/src/HibpFactory.php b/src/HibpFactory.php index 9a4ff87..9010bb3 100644 --- a/src/HibpFactory.php +++ b/src/HibpFactory.php @@ -3,9 +3,11 @@ namespace Dragonbe\Hibp; -use GuzzleHttp\Client; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; +use GuzzleHttp\Psr7\Request; +use GuzzleHttp\Psr7\Response; +use RicardoFiorani\GuzzlePsr18Adapter\Client; class HibpFactory { @@ -22,6 +24,26 @@ public static function create(array $config = []): Hibp return self::createRealClient($config); } + /** + * Factory method to create a basic configuration with the + * option to provide your own settings to override default + * configuration options. + * + * @param array $config + * @return array + */ + public static function createConfig(array $config = []): array + { + return array_replace_recursive([ + 'base_uri' => Hibp::HIBP_API_URI, + 'timeout' => Hibp::HIBP_API_TIMEOUT, + 'headers' => [ + 'User-Agent' => Hibp::HIBP_CLIENT_UA, + 'Accept' => Hibp::HIBP_CLIENT_ACCEPT, + ] + ], $config); + } + /** * Creates a real HTTP client for using in your applications * and make calls to the outside world. @@ -32,15 +54,17 @@ public static function create(array $config = []): Hibp */ private static function createRealClient(array $config): Hibp { - $client = new Client(array_replace_recursive([ - 'base_uri' => Hibp::HIBP_API_URI, - 'timeout' => Hibp::HIBP_API_TIMEOUT, - 'headers' => [ + $client = new Client(self::createConfig($config)); + $request = new Request( + 'GET', + Hibp::HIBP_API_URI, + [ 'User-Agent' => Hibp::HIBP_CLIENT_UA, 'Accept' => Hibp::HIBP_CLIENT_ACCEPT, ] - ], $config)); - return new Hibp($client); + ); + $response = new Response(); + return new Hibp($client, $request, $response); } /** @@ -56,6 +80,11 @@ public static function createTestClient(array $mockArray = []): Hibp $mock = new MockHandler($mockArray); $handler = HandlerStack::create($mock); $client = new Client(['handler' => $handler]); - return new Hibp($client); + $request = new Request( + 'GET', + '/' + ); + $response = new Response(); + return new Hibp($client, $request, $response); } } diff --git a/src/HibpInterface.php b/src/HibpInterface.php new file mode 100644 index 0000000..ea52091 --- /dev/null +++ b/src/HibpInterface.php @@ -0,0 +1,17 @@ +assertInstanceOf(Hibp::class, $hibp); } + + /** + * Testing that we can generate our default configuration + * with expected results. + * + * @covers \Dragonbe\Hibp\HibpFactory::createConfig() + */ + public function testFactoryCreationOfDefaultConfig() + { + $hibpConfig = HibpFactory::createConfig(); + $expectedConfig = [ + 'base_uri' => Hibp::HIBP_API_URI, + 'timeout' => Hibp::HIBP_API_TIMEOUT, + 'headers' => [ + 'User-Agent' => Hibp::HIBP_CLIENT_UA, + 'Accept' => Hibp::HIBP_CLIENT_ACCEPT, + ] + ]; + $this->assertSame($expectedConfig, $hibpConfig); + } + + /** + * Testing that we can generate our custom configuration + * when providing different configuration settings + * + * @covers \Dragonbe\Hibp\HibpFactory::createConfig() + */ + public function testFactoryCreationWithOverridingConfiguration() + { + $hibpConfig = HibpFactory::createConfig([ + 'timeout' => 250, + 'headers' => [ + 'User-Agent' => 'phpunit/7.3.5', + ], + ]); + $expectedConfig = [ + 'base_uri' => Hibp::HIBP_API_URI, + 'timeout' => 250, + 'headers' => [ + 'User-Agent' => 'phpunit/7.3.5', + 'Accept' => Hibp::HIBP_CLIENT_ACCEPT, + ] + ]; + $this->assertSame($expectedConfig, $hibpConfig); + } + + /** + * Testing that we can add additional configuration settings + * by just providing the "new" configuration options. + * + * @covers \Dragonbe\Hibp\HibpFactory::createConfig() + */ + public function testFactoryConfigAddsNotDefinedConfigurationOptions() + { + $hibpConfig = HibpFactory::createConfig(['foo' => 'bar']); + $expectedConfig = [ + 'base_uri' => Hibp::HIBP_API_URI, + 'timeout' => Hibp::HIBP_API_TIMEOUT, + 'headers' => [ + 'User-Agent' => Hibp::HIBP_CLIENT_UA, + 'Accept' => Hibp::HIBP_CLIENT_ACCEPT, + ], + 'foo' => 'bar', + ]; + $this->assertSame($expectedConfig, $hibpConfig); + } } diff --git a/tests/HibpTest.php b/tests/HibpTest.php index d867d0f..3fb8921 100644 --- a/tests/HibpTest.php +++ b/tests/HibpTest.php @@ -5,14 +5,16 @@ use Dragonbe\Hibp\Hibp; use Dragonbe\Hibp\HibpFactory; -use GuzzleHttp\Client; -use GuzzleHttp\ClientInterface; -use GuzzleHttp\Exception\ConnectException; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\TestCase; +use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use RicardoFiorani\GuzzlePsr18Adapter\Client; +use RicardoFiorani\GuzzlePsr18Adapter\Exception\ClientException; class HibpTest extends TestCase { @@ -54,14 +56,20 @@ public function testClassThrowsTypeErrorWhenWrongArgumentIsProvided() public function testExceptionIsThrownWhenServiceNotAvailable() { $mockHandler = new MockHandler([ - new ConnectException("Error Communicating with Server", new Request('GET', 'test')) + new ClientException( + 'Error Communicating with Server', + new Request('GET', 'test'), + new Response() + ) ]); $handlerStack = HandlerStack::create($mockHandler); $client = new Client(['handler' => $handlerStack]); + $request = new Request('GET', 'test'); + $response = new Response(); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Cannot connect to HIBP API'); - $hibp = new Hibp($client); + $hibp = new Hibp($client, $request, $response); $hibp->isPwnedPassword('foo'); $this->fail('Expected exception was not thrown'); } @@ -79,7 +87,7 @@ public function testExceptionIsThrownWhenRateLimitIsReached() $passwordFile = 'hit_rate_limit.txt'; $password = 'password'; $hibp = $this->createHibpWithMockedClientResponse(__DIR__ . '/_files/' . $passwordFile); - $this->expectException(\DomainException::class); + $this->expectException(\RuntimeException::class); $hibp->isPwnedPassword($password); $this->fail('Expected exception for hit rate was not triggered'); } @@ -97,7 +105,7 @@ public function testExceptionIsThrownWhenApiNotFound() $passwordFile = 'not_found.txt'; $password = 'password'; $hibp = $this->createHibpWithMockedClientResponse(__DIR__ . '/_files/' . $passwordFile); - $this->expectException(\DomainException::class); + $this->expectException(\RuntimeException::class); $hibp->isPwnedPassword($password); $this->fail('Expected exception for hit rate was not triggered'); } @@ -146,9 +154,15 @@ public function testPasswordHashRangeReturnsString() $client = $this->getMockBuilder(ClientInterface::class) ->getMockForAbstractClass(); + $request = $this->getMockBuilder(RequestInterface::class) + ->getMockForAbstractClass(); + + $response = $this->getMockBuilder(ResponseInterface::class) + ->getMockForAbstractClass(); + $password = 'foobar'; $hash = sha1($password); - $range = $getHashRange->invokeArgs(new Hibp($client), [$hash]); + $range = $getHashRange->invokeArgs(new Hibp($client, $request, $response), [$hash]); $this->assertTrue(is_string($range)); $this->assertSame(Hibp::HIBP_RANGE_LENGTH, strlen($range)); $this->assertSame( @@ -285,6 +299,21 @@ public function testCanNotFindGoodPasswordAsSha1Hash(string $strongPassword, str $this->assertFalse($resultSet); } + /** + * Testing that we cannot send a plain text password while we have set the + * hash flag to TRUE + * + * @covers \Dragonbe\Hibp\Hibp::isPwnedPassword() + */ + public function testErrorIsThrownWhenPlainPasswordIsPassedWithHashFlagEnabled() + { + $this->expectException(\InvalidArgumentException::class); + $hibp = HibpFactory::createTestClient([]); + $password = 'foo'; + $hibp->isPwnedPassword($password, true); + $this->fail('Expected Exception Was Not thrown for providing a plain text password with hash flag on'); + } + /** * Creates a mock response for GuzzleHttp Client and creates * a Hibp instance with these mocked responses.