Passed
Push — master ( ae0a03...686af4 )
by Magnar Ovedal
03:17
created

HaveIBeenPwned   A

Complexity

Total Complexity 30

Size/Duplication

Total Lines 212
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 0
Metric Value
eloc 73
dl 0
loc 212
ccs 80
cts 80
cp 1
rs 10
c 0
b 0
f 0
wmc 30

12 Methods

Rating   Name   Duplication   Size   Complexity  
A getClient() 0 6 2
A getRequestFactory() 0 6 2
A __construct() 0 14 6
A getMax() 0 3 1
A getMin() 0 3 1
A setClient() 0 3 1
A setRequestFactory() 0 3 1
A test() 0 5 1
A getCount() 0 24 4
A getMessage() 0 37 5
A enforce() 0 6 2
A getNoncompliantCount() 0 13 4
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Stadly\PasswordPolice\Rule;
6
7
use Http\Discovery\Exception\NotFoundException;
8
use Http\Discovery\HttpClientDiscovery;
9
use Http\Factory\Discovery\HttpFactory;
10
use InvalidArgumentException;
11
use Psr\Http\Client\ClientExceptionInterface;
12
use Psr\Http\Client\ClientInterface;
13
use Psr\Http\Message\RequestFactoryInterface;
14
use RuntimeException;
15
use Stadly\PasswordPolice\Policy;
16
17
final class HaveIBeenPwned implements RuleInterface
18
{
19
    /**
20
     * @var int Minimum number of appearances in breaches.
21
     */
22
    private $min;
23
24
    /**
25
     * @var int|null Maximum number of appearances in breaches.
26
     */
27
    private $max;
28
29
    /**
30
     * @var ClientInterface|null HTTP client for sending requests.
31
     */
32
    private $client;
33
34
    /**
35
     * @var RequestFactoryInterface|null Request factory for generating HTTP requests.
36
     */
37
    private $requestFactory;
38
39
    /**
40
     * @param int|null $max Maximum number of appearances in breaches.
41
     * @param int $min Minimum number of appearances in breaches.
42
     */
43 7
    public function __construct(?int $max = 0, int $min = 0)
44
    {
45 7
        if ($min < 0) {
46 1
            throw new InvalidArgumentException('Min cannot be negative.');
47
        }
48 6
        if ($max !== null && $max < $min) {
49 1
            throw new InvalidArgumentException('Max cannot be smaller than min.');
50
        }
51 5
        if ($min === 0 && $max === null) {
52 1
            throw new InvalidArgumentException('Min cannot be zero when max is unconstrained.');
53
        }
54
55 4
        $this->min = $min;
56 4
        $this->max = $max;
57 4
    }
58
59
    /**
60
     * @param ClientInterface $client HTTP client for sending requests.
61
     */
62 1
    public function setClient(ClientInterface $client): void
63
    {
64 1
        $this->client = $client;
65 1
    }
66
67
    /**
68
     * @return ClientInterface HTTP client for sending requests.
69
     * @throws RuntimeException If a client could not be found.
70
     */
71 10
    private function getClient(): ClientInterface
72
    {
73 10
        if ($this->client === null) {
74 8
            $this->client = HttpClientDiscovery::find();
75
        }
76 9
        return $this->client;
77
    }
78
79
    /**
80
     * @param RequestFactoryInterface $requestFactory Request factory for generating HTTP requests.
81
     */
82 1
    public function setRequestFactory(RequestFactoryInterface $requestFactory): void
83
    {
84 1
        $this->requestFactory = $requestFactory;
85 1
    }
86
87
    /**
88
     * @return RequestFactoryInterface Request factory for generating HTTP requests.
89
     * @throws RuntimeException If a request factory could not be found.
90
     */
91 11
    private function getRequestFactory(): RequestFactoryInterface
92
    {
93 11
        if ($this->requestFactory === null) {
94 10
            $this->requestFactory = HttpFactory::requestFactory();
95
        }
96 10
        return $this->requestFactory;
97
    }
98
99
    /**
100
     * @return int Minimum number of appearances in breaches.
101
     */
102 1
    public function getMin(): int
103
    {
104 1
        return $this->min;
105
    }
106
107
    /**
108
     * @return int|null Maximum number of appearances in breaches.
109
     */
110 1
    public function getMax(): ?int
111
    {
112 1
        return $this->max;
113
    }
114
115
    /**
116
     * {@inheritDoc}
117
     */
118 9
    public function test($password): bool
119
    {
120 9
        $count = $this->getNoncompliantCount((string)$password);
121
122 6
        return $count === null;
123
    }
124
125
    /**
126
     * {@inheritDoc}
127
     */
128 2
    public function enforce($password): void
129
    {
130 2
        $count = $this->getNoncompliantCount((string)$password);
131
132 2
        if ($count !== null) {
133 1
            throw new RuleException($this, $this->getMessage());
134
        }
135 1
    }
136
137
    /**
138
     * @param string $password Password to count appearances in breaches for.
139
     * @return int Number of appearances in breaches if not in compliance with the rule.
140
     * @throws TestException If an error occurred while using the Have I Been Pwned? service.
141
     */
142 11
    private function getNoncompliantCount(string $password): ?int
143
    {
144 11
        $count = $this->getCount($password);
145
146 8
        if ($count < $this->min) {
147 3
            return $count;
148
        }
149
150 5
        if (null !== $this->max && $this->max < $count) {
151 1
            return $count;
152
        }
153
154 4
        return null;
155
    }
156
157
    /**
158
     * @param string $password Password to check in breaches.
159
     * @return int Number of appearances in breaches.
160
     * @throws TestException If an error occurred while using the Have I Been Pwned? service.
161
     */
162 11
    private function getCount(string $password): int
163
    {
164 11
        $sha1 = strtoupper(sha1($password));
165 11
        $prefix = substr($sha1, 0, 5);
166 11
        $suffix = substr($sha1, 5, 35);
167
168
        try {
169 11
            $requestFactory = $this->getRequestFactory();
170 10
            $request = $requestFactory->createRequest('GET', 'https://api.pwnedpasswords.com/range/'.$prefix);
171
172 10
            $client = $this->getClient();
173
174 9
            $response = $client->sendRequest($request);
175 8
            $body = $response->getBody();
176 8
            $contents = $body->getContents();
177 8
            $lines = explode("\r\n", $contents);
178 8
            foreach ($lines as $line) {
179 8
                if (substr($line, 0, 35) === $suffix) {
180 8
                    return (int)substr($line, 36);
181
                }
182
            }
183 2
            return 0;
184 3
        } catch (ClientExceptionInterface | RuntimeException $exception) {
185 3
            throw new TestException($this, 'An error occurred while using the Have I Been Pwned? service.', $exception);
186
        }
187
    }
188
189
    /**
190
     * {@inheritDoc}
191
     */
192 5
    public function getMessage(): string
193
    {
194 5
        $translator = Policy::getTranslator();
195
196 5
        if ($this->max === null) {
197 1
            return $translator->trans(
198
                'Must appear at least once in breaches.|'.
199 1
                'Must appear at least %count% times in breaches.',
200 1
                ['%count%' => $this->min]
201
            );
202
        }
203
204 4
        if ($this->max === 0) {
205 1
            return $translator->trans(
206 1
                'Must not appear in any breaches.'
207
            );
208
        }
209
210 3
        if ($this->min === 0) {
211 1
            return $translator->trans(
212
                'Must appear at most once in breaches.|'.
213 1
                'Must appear at most %count% times in breaches.',
214 1
                ['%count%' => $this->max]
215
            );
216
        }
217
218 2
        if ($this->min === $this->max) {
219 1
            return $translator->trans(
220
                'Must appear exactly once in breaches.|'.
221 1
                'Must appear exactly %count% times in breaches.',
222 1
                ['%count%' => $this->min]
223
            );
224
        }
225
226 1
        return $translator->trans(
227 1
            'Must appear between %min% and %max% times in breaches.',
228 1
            ['%min%' => $this->min, '%max%' => $this->max]
229
        );
230
    }
231
}
232