Skip to content

Commit

Permalink
feature #15 Initial P0wnedPassword api checker (gnat42, sstok)
Browse files Browse the repository at this point in the history
This PR was merged into the 1.0-dev branch.

Discussion
----------

| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets |
| License       | MIT

PwnedPassword api checker. Fails validation if the user's password was found in the 500 million compromised password database.


Commits
-------

326b004 initial P0wnedPassword api checker
3236291 Fix style
  • Loading branch information
sstok authored Mar 10, 2018
2 parents df1da96 + 3236291 commit 6943d80
Show file tree
Hide file tree
Showing 8 changed files with 394 additions and 1 deletion.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ But building your own is also possible.
__Documentation on this is currently missing,
see current providers for more information.__

### PwnedPassword

Validates that the requested password was not found in a trove of compromised passwords found at <https://haveibeenpwned.com/>.

To enable this you must install the suggested package "guzzlehttp/psr7" as well as a HttpClient such as "php-http/guzzle6-adapter".

## Versioning

For transparency and insight into the release cycle, and for striving
Expand Down
5 changes: 4 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,16 @@
"require": {
"php": "^5.6 || ^7.0",
"psr/container": "^1.0",
"psr/log": "^1.0",
"symfony/polyfill-mbstring": "^1.5.0",
"symfony/validator": "^2.8.9 || ^3.3.6 || ^4.0"
},
"require-dev": {
"symfony/config": "^3.3.6 || ^4.0",
"symfony/console": "^3.3.6 || ^4.0",
"symfony/phpunit-bridge": "^3.3.6 || ^4.0"
"symfony/phpunit-bridge": "^3.3.6 || ^4.0",
"php-http/httplug": "^1.1",
"guzzlehttp/psr7": "^1.4"
},
"autoload": {
"psr-4": {
Expand Down
69 changes: 69 additions & 0 deletions src/P0wnedPassword/Request/Client.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

/*
* This file is part of the RollerworksPasswordStrengthValidator package.
*
* (c) Sebastiaan Stok <[email protected]>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/

namespace Rollerworks\Component\PasswordStrength\P0wnedPassword\Request;

use GuzzleHttp\Psr7\Request;
use Http\Client\Exception\HttpException;
use Http\Client\Exception as HttpException2;
use Http\Client\HttpClient;
use Psr\Log\LoggerInterface;

class Client
{
/** @var HttpClient */
private $client;

/** @var LoggerInterface */
private $logger;

public function __construct(HttpClient $client, LoggerInterface $logger)
{
$this->client = $client;
$this->logger = $logger;
}

/**
* @param $password
*
* @return Result
*
* @throws \Http\Client\Exception
*/
public function check($password)
{
$hashedPassword = strtoupper(sha1($password));
$checkHash = substr($hashedPassword, 0, 5);

try {
$response = $this->client->sendRequest(new Request('GET', 'https://api.pwnedpasswords.com/range/'.$checkHash));
if ($response->getStatusCode() === 200) {
$rowResults = explode("\n", (string) $response->getBody());
$searchHash = substr($hashedPassword, 5);
foreach ($rowResults as $result) {
if (strpos($result, $searchHash) !== false) {
$res = explode(':', $result);

return new Result(trim($res[1]));
}
}
}
} catch (HttpException $exception) {
$this->logger->error('HTTP Exception: '.$exception->getMessage());
} catch (HttpException2 $exception) {
$this->logger->error('HTTP Exception: '.$exception->getMessage());
} catch (\Exception $exception) {
$this->logger->error('Exception: '.$exception->getMessage());
}

return new Result(0);
}
}
46 changes: 46 additions & 0 deletions src/P0wnedPassword/Request/Result.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

/*
* This file is part of the RollerworksPasswordStrengthValidator package.
*
* (c) Sebastiaan Stok <[email protected]>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/

namespace Rollerworks\Component\PasswordStrength\P0wnedPassword\Request;

class Result
{
/**
* @var int
*/
private $useCount = 0;

/**
* Result constructor.
*
* @param int $useCount
*/
public function __construct($useCount)
{
$this->useCount = (int) $useCount;
}

/**
* @return int
*/
public function getUseCount()
{
return $this->useCount;
}

/**
* @return bool
*/
public function wasFound()
{
return $this->useCount > 0;
}
}
27 changes: 27 additions & 0 deletions src/Validator/Constraints/P0wnedPassword.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

/*
* This file is part of the RollerworksPasswordStrengthValidator package.
*
* (c) Sebastiaan Stok <[email protected]>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/

namespace Rollerworks\Component\PasswordStrength\Validator\Constraints;

use Symfony\Component\Validator\Constraint;

class P0wnedPassword extends Constraint
{
public $message = 'This password was found in a database of compromised passwords. It has been used {{ used }} times. For security purposes you must use something else.';

/**
* {@inheritdoc}
*/
public function getTargets()
{
return self::PROPERTY_CONSTRAINT;
}
}
57 changes: 57 additions & 0 deletions src/Validator/Constraints/P0wnedPasswordValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

/*
* This file is part of the RollerworksPasswordStrengthValidator package.
*
* (c) Sebastiaan Stok <[email protected]>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/

namespace Rollerworks\Component\PasswordStrength\Validator\Constraints;

use Rollerworks\Component\PasswordStrength\P0wnedPassword\Request\Client;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;

class P0wnedPasswordValidator extends ConstraintValidator
{
/** @var Client */
private $client;

/**
* P0wnedPasswordValidator constructor.
*
* @param Client $client
*/
public function __construct(Client $client)
{
$this->client = $client;
}

/**
* {@inheritdoc}
*/
public function validate($password, Constraint $constraint)
{
if (null === $password) {
return;
}

if (!is_scalar($password) && !(is_object($password) && method_exists($password, '__toString'))) {
throw new UnexpectedTypeException($password, 'string');
}

$password = (string) $password;

$result = $this->client->check($password);
if ($result->wasFound()) {
$this->context
->buildViolation($constraint->message)
->setParameter('{{ used }}', number_format($result->getUseCount()))
->addViolation();
}
}
}
118 changes: 118 additions & 0 deletions tests/P0wnedPassword/Request/ClientTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php

/*
* This file is part of the RollerworksPasswordStrengthValidator package.
*
* (c) Sebastiaan Stok <[email protected]>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/

namespace Rollerworks\Component\PasswordStrength\Tests\P0wnedPassword\Request;

use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use Http\Client\HttpClient;
use Rollerworks\Component\PasswordStrength\P0wnedPassword\Request\Client;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Rollerworks\Component\PasswordStrength\P0wnedPassword\Request\Result;

class ClientTest extends TestCase
{
/** @var HttpClient|MockObject */
private $client;

/** @var Client */
private $checker;

private $foundResult = '00659D6178E2DAF3D6BE70358A1EB54883A:6
032471D9B185979579C773FDA622293BFFC:24
0371D53107275E34091E29220622B62ED72:2
0593916D0FE39C1CBF870BDA44AA1A6F9A2:15
06ACBE8716FFA0D70E8453AC0CF560B9B22:3
06C866D7AEE7CBB84255BBF5529520D878B:3
08403CDA0395608552144349EB96D30DD5F:28
08E1C1E99DE892ED16CDED45306D8DEDDBF:3
0955820A68920AF7559F89FE2D12EE83357:2
09F74D1DC15112328C8A8ACC90D394AFBE4:7
5F74FD0522862127A00BDEF879C4D9A1A02:4031
0A517529767483361432D4F3663F89BAA7E:2
0C341F894BD4EE961AE874ACD3BC8157825:4
';
private $notFoundResult = '00659D6178E2DAF3D6BE70358A1EB54883A:6
032471D9B185979579C773FDA622293BFFC:24
0371D53107275E34091E29220622B62ED72:2
0593916D0FE39C1CBF870BDA44AA1A6F9A2:15
06ACBE8716FFA0D70E8453AC0CF560B9B22:3
06C866D7AEE7CBB84255BBF5529520D878B:3
08403CDA0395608552144349EB96D30DD5F:28
08E1C1E99DE892ED16CDED45306D8DEDDBF:3
0955820A68920AF7559F89FE2D12EE83357:2
09F74D1DC15112328C8A8ACC90D394AFBE4:7
0A517529767483361432D4F3663F89BAA7E:2
0C341F894BD4EE961AE874ACD3BC8157825:4
';

public function setUp()
{
$this->client = $this->createMock(HttpClient::class);
$this->checker = new Client($this->client, new NullLogger());
}

public function testResponseWithFoundResult()
{
$password = 'correctbatteryhorse';
$responseMock = $this->createMock(Response::class);
$responseMock->expects($this->once())->method('getStatusCode')->willReturn(200);
$responseMock->expects($this->once())->method('getBody')->willReturn($this->foundResult);
$request = new Request('GET', 'https://api.pwnedpasswords.com/range/C4FA0');
$this->client->expects($this->once())
->method('sendRequest')
->with($request)
->willReturn($responseMock);

$result = $this->checker->check($password);
$this->assertInstanceOf(Result::class, $result);
$this->assertTrue($result->wasFound());
$this->assertEquals(4031, $result->getUseCount());
}

public function testResponseWithoutFoundResult()
{
$password = 'correctbatteryhorse';
$responseMock = $this->createMock(Response::class);
$responseMock->expects($this->once())->method('getStatusCode')->willReturn(200);
$responseMock->expects($this->once())->method('getBody')->willReturn($this->notFoundResult);
$request = new Request('GET', 'https://api.pwnedpasswords.com/range/C4FA0');
$this->client->expects($this->once())
->method('sendRequest')
->with($request)
->willReturn($responseMock);

$result = $this->checker->check($password);
$this->assertInstanceOf(Result::class, $result);
$this->assertFalse($result->wasFound());
$this->assertEquals(0, $result->getUseCount());
}

public function testNon200Response()
{
$password = 'correctbatteryhorse';
$responseMock = $this->createMock(Response::class);
$responseMock->expects($this->once())->method('getStatusCode')->willReturn(404);
$responseMock->expects($this->never())->method('getBody');
$request = new Request('GET', 'https://api.pwnedpasswords.com/range/C4FA0');
$this->client->expects($this->once())
->method('sendRequest')
->with($request)
->willReturn($responseMock);

$result = $this->checker->check($password);
$this->assertInstanceOf(Result::class, $result);
$this->assertFalse($result->wasFound());
$this->assertEquals(0, $result->getUseCount());
}
}
Loading

0 comments on commit 6943d80

Please sign in to comment.