Passed
Push — master ( 75c423...21d06b )
by Magnar Ovedal
02:54
created

HaveIBeenPwned::getMin()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
rs 10
c 0
b 0
f 0
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 times the password can appear in breaches.
21
     */
22
    private $min;
23
24
    /**
25
     * @var int|null Maximum number of times the password can appear 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 $min Minimum number of times the password can appear in breaches.
41
     * @param int|null $max Maximum number of times the password can appear in breaches.
42
     */
43 7
    public function __construct(int $min = 0, ?int $max = 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 (null === $this->client) {
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 (null === $this->requestFactory) {
94 10
            $this->requestFactory = HttpFactory::requestFactory();
95
        }
96 10
        return $this->requestFactory;
97
    }
98
99
    /**
100
     * @return int Minimum number of times the password can appear 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 times the password can appear 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(string $password): bool
119
    {
120 9
        $count = $this->getCount($password);
121
122 6
        if ($count < $this->min) {
123 2
            return false;
124
        }
125
126 4
        if (null !== $this->max && $this->max < $count) {
127 1
            return false;
128
        }
129
130 3
        return true;
131
    }
132
133
    /**
134
     * {@inheritDoc}
135
     */
136 2
    public function enforce(string $password): void
137
    {
138 2
        if (!$this->test($password)) {
139 1
            throw new RuleException($this, $this->getMessage());
140
        }
141 1
    }
142
143
    /**
144
     * {@inheritDoc}
145
     */
146 5
    public function getMessage(): string
147
    {
148 5
        $translator = Policy::getTranslator();
149
150 5
        if ($this->getMax() === null) {
151 1
            return $translator->transChoice(
152
                'Must appear at least once in breaches.|'.
153 1
                'Must appear at least %count% times in breaches.',
154 1
                $this->getMin()
155
            );
156
        }
157
158 4
        if ($this->getMax() === 0) {
159 1
            return $translator->trans(
160 1
                'Must not appear in any breaches.'
161
            );
162
        }
163
164 3
        if ($this->getMin() === 0) {
165 1
            return $translator->transChoice(
166
                'Must appear at most once in breaches.|'.
167 1
                'Must appear at most %count% times in breaches.',
168 1
                $this->getMax()
169
            );
170
        }
171
172 2
        if ($this->getMin() === $this->getMax()) {
173 1
            return $translator->transChoice(
174
                'Must appear exactly once in breaches.|'.
175 1
                'Must appear exactly %count% times in breaches.',
176 1
                $this->getMin()
177
            );
178
        }
179
180 1
        return $translator->trans(
181 1
            'Must appear between %min% and %max% times in breaches.',
182 1
            ['%min%' => $this->getMin(), '%max%' => $this->getMax()]
183
        );
184
    }
185
186
    /**
187
     * @param string $password Password to check in breaches.
188
     * @return int Number of times the password appears in breaches.
189
     * @throws TestException If an error occurred while using the Have I Been Pwned? service.
190
     */
191 11
    private function getCount(string $password): int
192
    {
193 11
        $sha1 = strtoupper(sha1($password));
194 11
        $prefix = substr($sha1, 0, 5);
195 11
        $suffix = substr($sha1, 5, 35);
196
197
        try {
198 11
            $requestFactory = $this->getRequestFactory();
199 10
            $request = $requestFactory->createRequest('GET', 'https://api.pwnedpasswords.com/range/'.$prefix);
200
201 10
            $client = $this->getClient();
202
203 9
            $response = $client->sendRequest($request);
204 8
            $body = $response->getBody();
205 8
            $contents = $body->getContents();
206 8
            $lines = explode("\r\n", $contents);
207 8
            foreach ($lines as $line) {
208 8
                if (substr($line, 0, 35) === $suffix) {
209 8
                    return (int)substr($line, 36);
210
                }
211
            }
212 2
            return 0;
213 3
        } catch (ClientExceptionInterface | RuntimeException $exception) {
214 3
            throw new TestException($this, 'An error occurred while using the Have I Been Pwned? service.', $exception);
215
        }
216
    }
217
}
218