Skip to content

Commit 4687a45

Browse files
committed
Initial commit
0 parents  commit 4687a45

12 files changed

+386
-0
lines changed

Api/ValidatorInterface.php

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
namespace Timpack\PwnedValidator\Api;
4+
5+
interface ValidatorInterface
6+
{
7+
/**
8+
* @param $password
9+
* @return bool
10+
*/
11+
public function isValid($password): bool;
12+
}

Model/Validator.php

+114
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<?php
2+
3+
namespace Timpack\PwnedValidator\Model;
4+
5+
use Magento\Framework\App\CacheInterface;
6+
use Magento\Framework\App\Config\ScopeConfigInterface;
7+
use Magento\Framework\HTTP\ClientInterface;
8+
use Magento\Framework\Serialize\SerializerInterface;
9+
use Timpack\PwnedValidator\Api\ValidatorInterface;
10+
11+
class Validator implements ValidatorInterface
12+
{
13+
const PWNED_BASE_URL = 'https://api.pwnedpasswords.com';
14+
const CONFIG_PWNED_MINIMUM_MATCHES = 'customer/pwned/minimum_matches';
15+
16+
/**
17+
* @var ClientInterface
18+
*/
19+
private $httpClient;
20+
21+
/**
22+
* @var CacheInterface
23+
*/
24+
private $cache;
25+
26+
/**
27+
* @var SerializerInterface
28+
*/
29+
private $serializer;
30+
31+
/**
32+
* @var ScopeConfigInterface
33+
*/
34+
private $scopeConfig;
35+
36+
/**
37+
* Validator constructor.
38+
* @param ClientInterface $httpClient
39+
* @param CacheInterface $cache
40+
* @param SerializerInterface $serializer
41+
* @param ScopeConfigInterface $scopeConfig
42+
*/
43+
public function __construct(
44+
ClientInterface $httpClient,
45+
CacheInterface $cache,
46+
SerializerInterface $serializer,
47+
ScopeConfigInterface $scopeConfig
48+
) {
49+
$this->httpClient = $httpClient;
50+
$this->cache = $cache;
51+
$this->serializer = $serializer;
52+
$this->scopeConfig = $scopeConfig;
53+
}
54+
55+
/**
56+
* @param $password
57+
* @return bool
58+
*/
59+
public function isValid($password): bool
60+
{
61+
$passwordHash = strtoupper(sha1($password));
62+
$prefix = substr($passwordHash, 0, 5);
63+
$suffix = substr($passwordHash, 5);
64+
65+
$minimumMatches = $this->getMinimumMatches();
66+
$hashes = $this->query($prefix);
67+
$count = $hashes[$suffix] ?? 0;
68+
69+
return $count < $minimumMatches;
70+
}
71+
72+
/**
73+
* @param $prefix
74+
* @return array
75+
*/
76+
private function query($prefix): array
77+
{
78+
$cacheKey = 'PWNED_HASH_RANGE_' . $prefix;
79+
80+
$cacheEntry = $this->cache->load($cacheKey);
81+
if ($cacheEntry) {
82+
return $this->serializer->unserialize($cacheEntry);
83+
}
84+
85+
$hashes = [];
86+
87+
$this->httpClient->get(self::PWNED_BASE_URL . '/range/' . $prefix);
88+
89+
if ($this->httpClient->getStatus() !== 200) {
90+
return $hashes;
91+
}
92+
93+
$body = $this->httpClient->getBody();
94+
$results = explode("\n", $body);
95+
96+
foreach ($results as $value) {
97+
list($hash, $count) = explode(':', $value);
98+
$hashes[$hash] = (int)$count;
99+
}
100+
101+
$serialized = $this->serializer->serialize($hashes);
102+
$this->cache->save($serialized, $cacheKey, [], 3600 * 8);
103+
104+
return $hashes;
105+
}
106+
107+
/**
108+
* @return int
109+
*/
110+
private function getMinimumMatches(): int
111+
{
112+
return (int)$this->scopeConfig->getValue(self::CONFIG_PWNED_MINIMUM_MATCHES, 'stores');
113+
}
114+
}

Observer/Validate.php

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
namespace Timpack\PwnedValidator\Observer;
4+
5+
use Magento\Framework\Event\Observer;
6+
use Magento\Framework\Event\ObserverInterface;
7+
use Magento\Framework\Exception\InputException;
8+
use Timpack\PwnedValidator\Api\ValidatorInterface;
9+
10+
class Validate implements ObserverInterface
11+
{
12+
/**
13+
* @var ValidatorInterface
14+
*/
15+
private $validator;
16+
17+
/**
18+
* Validate constructor.
19+
* @param ValidatorInterface $validator
20+
*/
21+
public function __construct(ValidatorInterface $validator)
22+
{
23+
$this->validator = $validator;
24+
}
25+
26+
/**
27+
* @param Observer $observer
28+
* @return void
29+
* @throws InputException
30+
*/
31+
public function execute(Observer $observer)
32+
{
33+
$password = $observer->getData('password');
34+
if (!$this->validator->isValid($password)) {
35+
throw new InputException(__('The password was found in public databases.'));
36+
}
37+
}
38+
}

README.md

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Magento 2 Have I Been Pwned Validator
2+
This module adds a validator which checks if the submitted password is found in public databases using the `Have I Been Pwned?` service.
3+
4+
## Security
5+
There are no security drawbacks, because there are no actual passwords being submitted over the internet. This is possible by hashing the password using the `SHA-1` algorithm and request all hashes in the `Have I been Pwned?` databases starting with the first 5 characters of the password hash. This resultset contains a list of hashes and the amount of occurrences.
6+
7+
This way the password stays inside the Magento process.
8+
9+
## Installation
10+
```
11+
composer require timpack/magento2-module-pwned-validator
12+
bin/magento setup:upgrade
13+
```
14+
15+
## Configuration
16+
You can configure the threshold of the validator, at which count of occurrences in the resultset the password should be considered insecure/invalid.
17+
This configuration can be found at:
18+
19+
`Stores -> Configuration -> Customer -> Customer Configuration -> Pwned Validator -> Minimum amount of matches`
20+
21+
## Credits
22+
This module was heavily inspired by Valorin's Pwned validator written for Laravel: [valorin/pwned-validator](https://github.com/valorin/pwned-validator)

Rewrite/Model/AccountManagement.php

+115
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<?php
2+
3+
namespace Timpack\PwnedValidator\Rewrite\Model;
4+
5+
use Magento\Customer\Api\AddressRepositoryInterface;
6+
use Magento\Customer\Api\CustomerMetadataInterface;
7+
use Magento\Customer\Api\CustomerRepositoryInterface;
8+
use Magento\Customer\Api\Data\ValidationResultsInterfaceFactory;
9+
use Magento\Customer\Helper\View as CustomerViewHelper;
10+
use Magento\Customer\Model\Config\Share as ConfigShare;
11+
use Magento\Customer\Model\Customer as CustomerModel;
12+
use Magento\Customer\Model\Customer\CredentialsValidator;
13+
use Magento\Customer\Model\CustomerFactory;
14+
use Magento\Customer\Model\CustomerRegistry;
15+
use Magento\Customer\Model\Metadata\Validator;
16+
use Magento\Customer\Model\ResourceModel\Visitor\CollectionFactory;
17+
use Magento\Framework\Api\ExtensibleDataObjectConverter;
18+
use Magento\Framework\App\Config\ScopeConfigInterface;
19+
use Magento\Framework\DataObjectFactory as ObjectFactory;
20+
use Magento\Framework\Encryption\EncryptorInterface as Encryptor;
21+
use Magento\Framework\Event\ManagerInterface;
22+
use Magento\Framework\Intl\DateTimeFactory;
23+
use Magento\Framework\Mail\Template\TransportBuilder;
24+
use Magento\Framework\Math\Random;
25+
use Magento\Framework\Reflection\DataObjectProcessor;
26+
use Magento\Framework\Registry;
27+
use Magento\Framework\Session\SaveHandlerInterface;
28+
use Magento\Framework\Session\SessionManagerInterface;
29+
use Magento\Framework\Stdlib\DateTime;
30+
use Magento\Framework\Stdlib\StringUtils as StringHelper;
31+
use Magento\Store\Model\StoreManagerInterface;
32+
use Psr\Log\LoggerInterface as PsrLogger;
33+
34+
class AccountManagement extends \Magento\Customer\Model\AccountManagement
35+
{
36+
/**
37+
* @var ManagerInterface
38+
*/
39+
private $eventManager;
40+
41+
public function __construct(
42+
CustomerFactory $customerFactory,
43+
ManagerInterface $eventManager,
44+
StoreManagerInterface $storeManager,
45+
Random $mathRandom,
46+
Validator $validator,
47+
ValidationResultsInterfaceFactory $validationResultsDataFactory,
48+
AddressRepositoryInterface $addressRepository,
49+
CustomerMetadataInterface $customerMetadataService,
50+
CustomerRegistry $customerRegistry,
51+
PsrLogger $logger,
52+
Encryptor $encryptor,
53+
ConfigShare $configShare,
54+
StringHelper $stringHelper,
55+
CustomerRepositoryInterface $customerRepository,
56+
ScopeConfigInterface $scopeConfig,
57+
TransportBuilder $transportBuilder,
58+
DataObjectProcessor $dataProcessor,
59+
Registry $registry,
60+
CustomerViewHelper $customerViewHelper,
61+
DateTime $dateTime,
62+
CustomerModel $customerModel,
63+
ObjectFactory $objectFactory,
64+
ExtensibleDataObjectConverter $extensibleDataObjectConverter,
65+
CredentialsValidator $credentialsValidator = null,
66+
DateTimeFactory $dateTimeFactory = null,
67+
SessionManagerInterface $sessionManager = null,
68+
SaveHandlerInterface $saveHandler = null,
69+
CollectionFactory $visitorCollectionFactory = null
70+
)
71+
{
72+
parent::__construct(
73+
$customerFactory,
74+
$eventManager,
75+
$storeManager,
76+
$mathRandom,
77+
$validator,
78+
$validationResultsDataFactory,
79+
$addressRepository,
80+
$customerMetadataService,
81+
$customerRegistry,
82+
$logger,
83+
$encryptor,
84+
$configShare,
85+
$stringHelper,
86+
$customerRepository,
87+
$scopeConfig,
88+
$transportBuilder,
89+
$dataProcessor,
90+
$registry,
91+
$customerViewHelper,
92+
$dateTime,
93+
$customerModel,
94+
$objectFactory,
95+
$extensibleDataObjectConverter,
96+
$credentialsValidator,
97+
$dateTimeFactory,
98+
$sessionManager,
99+
$saveHandler,
100+
$visitorCollectionFactory
101+
);
102+
$this->eventManager = $eventManager;
103+
}
104+
105+
/**
106+
* @param string $password
107+
* @throws \Magento\Framework\Exception\InputException
108+
* @return void
109+
*/
110+
protected function checkPasswordStrength($password)
111+
{
112+
parent::checkPasswordStrength($password);
113+
$this->eventManager->dispatch('timpack_pwnedvalidator_check_password_strength', ['password' => $password]);
114+
}
115+
}

composer.json

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"name": "timpack/magento2-module-pwned-validator",
3+
"description": "Add 'Have I been pwned?' validator to Magento 2.",
4+
"license": "MIT",
5+
"authors": [
6+
{
7+
"name": "Timon de Groot",
8+
"email": "[email protected]"
9+
}
10+
],
11+
"require": {
12+
"magento/framework": "^101.0",
13+
"magento/module-customer": "^101.0"
14+
},
15+
"autoload": {
16+
"psr-4": {
17+
"Timpack\\PwnedValidator\\": ""
18+
},
19+
"files": [
20+
"registration.php"
21+
]
22+
}
23+
}

etc/adminhtml/system.xml

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?xml version="1.0" ?>
2+
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Config:etc/system_file.xsd">
4+
<system>
5+
<section id="customer">
6+
<group id="pwned" showInDefault="1" showInWebsite="1" showInStore="1" translate="label" sortOrder="30">
7+
<label>Pwned Validator</label>
8+
<field id="minimum_matches" showInDefault="1" showInWebsite="1" showInStore="1" translate="label"
9+
sortOrder="10" canRestore="1">
10+
<label>Minimum amount of matches</label>
11+
<comment>Enter the minimum amount of matches needed to consider password unsafe/invalid.</comment>
12+
<validate>number</validate>
13+
</field>
14+
</group>
15+
</section>
16+
</system>
17+
</config>

etc/config.xml

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?xml version="1.0" ?>
2+
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd">
4+
<default>
5+
<customer>
6+
<pwned>
7+
<minimum_matches>1</minimum_matches>
8+
</pwned>
9+
</customer>
10+
</default>
11+
</config>

etc/di.xml

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
4+
<preference for="Timpack\PwnedValidator\Api\ValidatorInterface" type="Timpack\PwnedValidator\Model\Validator"/>
5+
<preference for="Magento\Customer\Model\AccountManagement"
6+
type="Timpack\PwnedValidator\Rewrite\Model\AccountManagement"/>
7+
<type name="Timpack\PwnedValidator\Api\ValidatorInterface">
8+
<arguments>
9+
<argument name="httpClient" xsi:type="object">Magento\Framework\HTTP\Client\Curl</argument>
10+
</arguments>
11+
</type>
12+
</config>

etc/events.xml

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?xml version="1.0" encoding="UTF-8" ?>
2+
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
4+
<event name="timpack_pwnedvalidator_check_password_strength">
5+
<observer name="timpack_pwnedvalidator_validate_pwned" instance="Timpack\PwnedValidator\Observer\Validate"/>
6+
</event>
7+
</config>

etc/module.xml

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?xml version="1.0"?>
2+
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
4+
<module name="Timpack_PwnedValidator" setup_version="1.0.0">
5+
<sequence>
6+
<module name="Magento_Customer"/>
7+
</sequence>
8+
</module>
9+
</config>

0 commit comments

Comments
 (0)