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