Passed
Push — master ( 2db401...bad944 )
by Magnar Ovedal
02:58
created

HaveIBeenPwned::enforce()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 4
nc 2
nop 1
dl 0
loc 7
ccs 5
cts 5
cp 1
crap 2
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\Factory\Discovery\HttpFactory;
8
use Http\Factory\Discovery\HttpClient;
9
use InvalidArgumentException;
10
use Psr\Http\Client\ClientExceptionInterface;
11
use Psr\Http\Client\ClientInterface;
12
use Psr\Http\Message\RequestFactoryInterface;
13
use RuntimeException;
14
use StableSort\StableSort;
15
use Stadly\PasswordPolice\Constraint\Count;
16
use Stadly\PasswordPolice\Policy;
17
18
final class HaveIBeenPwned implements RuleInterface
19
{
20
    /**
21
     * @var Count[] Rule constraints.
22
     */
23
    private $constraints;
24
25
    /**
26
     * @var ClientInterface|null HTTP client for sending requests.
27
     */
28
    private $client;
29
30
    /**
31
     * @var RequestFactoryInterface|null Request factory for generating HTTP requests.
32
     */
33
    private $requestFactory;
34
35
    /**
36
     * @param int|null $max Maximum number of appearances in breaches.
37
     * @param int $min Minimum number of appearances in breaches.
38
     * @param int $weight Constraint weight.
39
     */
40 7
    public function __construct(?int $max = 0, int $min = 0, int $weight = 1)
41
    {
42 7
        $this->addConstraint($max, $min, $weight);
43 5
    }
44
45
    /**
46
     * @param int|null $max Maximum number of appearances in breaches.
47
     * @param int $min Minimum number of appearances in breaches.
48
     * @param int $weight Constraint weight.
49
     * @return $this
50
     */
51 2
    public function addConstraint(?int $max = 0, int $min = 0, int $weight = 1): self
52
    {
53 2
        $this->constraints[] = new Count($min, $max, $weight);
54
55
        StableSort::usort($this->constraints, function (Count $a, Count $b): int {
56 2
            return $b->getWeight() <=> $a->getWeight();
57 2
        });
58
59 2
        return $this;
60
    }
61
62
    /**
63
     * @param ClientInterface $client HTTP client for sending requests.
64
     */
65 1
    public function setClient(ClientInterface $client): void
66
    {
67 1
        $this->client = $client;
68 1
    }
69
70
    /**
71
     * @return ClientInterface HTTP client for sending requests.
72
     * @throws RuntimeException If a client could not be found.
73
     */
74 15
    private function getClient(): ClientInterface
75
    {
76 15
        if ($this->client === null) {
77 13
            $client = HttpClient::client();
78 12
            $this->client = $client;
79
        }
80 14
        return $this->client;
81
    }
82
83
    /**
84
     * @param RequestFactoryInterface $requestFactory Request factory for generating HTTP requests.
85
     */
86 1
    public function setRequestFactory(RequestFactoryInterface $requestFactory): void
87
    {
88 1
        $this->requestFactory = $requestFactory;
89 1
    }
90
91
    /**
92
     * @return RequestFactoryInterface Request factory for generating HTTP requests.
93
     * @throws RuntimeException If a request factory could not be found.
94
     */
95 16
    private function getRequestFactory(): RequestFactoryInterface
96
    {
97 16
        if ($this->requestFactory === null) {
98 15
            $this->requestFactory = HttpFactory::requestFactory();
99
        }
100 15
        return $this->requestFactory;
101
    }
102
103
    /**
104
     * {@inheritDoc}
105
     */
106 9
    public function test($password): bool
107
    {
108 9
        $count = $this->getCount((string)$password);
109 6
        $constraint = $this->getViolation($count);
110
111 6
        return $constraint === null;
112
    }
113
114
    /**
115
     * {@inheritDoc}
116
     */
117 7
    public function enforce($password): void
118
    {
119 7
        $count = $this->getCount((string)$password);
120 7
        $constraint = $this->getViolation($count);
121
122 7
        if ($constraint !== null) {
123 6
            throw new RuleException($this, $this->getMessage($constraint, $count));
124
        }
125 1
    }
126
127
    /**
128
     * @param int $count Number of appearances in breaches.
129
     * @return Count|null Constraint violated by the count.
130
     */
131 13
    private function getViolation(int $count): ?Count
132
    {
133 13
        foreach ($this->constraints as $constraint) {
134 13
            if (!$constraint->test($count)) {
135 13
                return $constraint;
136
            }
137
        }
138
139 4
        return null;
140
    }
141
142
    /**
143
     * @param string $password Password to check in breaches.
144
     * @return int Number of appearances in breaches.
145
     * @throws TestException If an error occurred while using the Have I Been Pwned? service.
146
     */
147 16
    private function getCount(string $password): int
148
    {
149 16
        $sha1 = strtoupper(sha1($password));
150 16
        $prefix = substr($sha1, 0, 5);
151 16
        $suffix = substr($sha1, 5, 35);
152
153
        try {
154 16
            $requestFactory = $this->getRequestFactory();
155 15
            $request = $requestFactory->createRequest('GET', 'https://api.pwnedpasswords.com/range/'.$prefix);
156
157 15
            $client = $this->getClient();
158
159 14
            $response = $client->sendRequest($request);
160 13
            $body = $response->getBody();
161 13
            $contents = $body->getContents();
162 13
            $lines = explode("\r\n", $contents);
163 13
            foreach ($lines as $line) {
164 13
                if (substr($line, 0, 35) === $suffix) {
165 13
                    return (int)substr($line, 36);
166
                }
167
            }
168 2
            return 0;
169 3
        } catch (ClientExceptionInterface | RuntimeException $exception) {
170 3
            throw new TestException(
171 3
                $this,
172 3
                'An error occurred while using the Have I Been Pwned? service: '.$exception->getMessage(),
173 3
                $exception
174
            );
175
        }
176
    }
177
178
    /**
179
     * @param Count $constraint Constraint that is violated.
180
     * @param int $count Count that violates the constraint.
181
     * @return string Message explaining the violation.
182
     */
183 6
    private function getMessage(Count $constraint, int $count): string
0 ignored issues
show
Unused Code introduced by
The parameter $count is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

183
    private function getMessage(Count $constraint, /** @scrutinizer ignore-unused */ int $count): string

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
184
    {
185 6
        $translator = Policy::getTranslator();
186
187 6
        if ($constraint->getMax() === null) {
188 2
            return $translator->trans(
189
                'Must appear at least once in breaches.|'.
190 2
                'Must appear at least %count% times in breaches.',
191 2
                ['%count%' => $constraint->getMin()]
192
            );
193
        }
194
195 4
        if ($constraint->getMax() === 0) {
196 1
            return $translator->trans(
197 1
                'Must not appear in any breaches.'
198
            );
199
        }
200
201 3
        if ($constraint->getMin() === 0) {
202 1
            return $translator->trans(
203
                'Must appear at most once in breaches.|'.
204 1
                'Must appear at most %count% times in breaches.',
205 1
                ['%count%' => $constraint->getMax()]
206
            );
207
        }
208
209 2
        if ($constraint->getMin() === $constraint->getMax()) {
210 1
            return $translator->trans(
211
                'Must appear exactly once in breaches.|'.
212 1
                'Must appear exactly %count% times in breaches.',
213 1
                ['%count%' => $constraint->getMin()]
214
            );
215
        }
216
217 1
        return $translator->trans(
218 1
            'Must appear between %min% and %max% times in breaches.',
219 1
            ['%min%' => $constraint->getMin(), '%max%' => $constraint->getMax()]
220
        );
221
    }
222
}
223