Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.00% covered (warning)
85.00%
68 / 80
90.00% covered (success)
90.00%
9 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
PasswordPolicy
85.00% covered (warning)
85.00%
68 / 80
90.00% covered (success)
90.00%
9 / 10
30.65
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 check
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 pieces
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 run
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 repeat
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 within
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 sequences
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 personal
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 breached
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 request
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\User;
6
7use Closure;
8use Cosray\Config;
9use Psr\Log\LoggerInterface as Logger;
10
11/**
12 * Decides whether a password may be set. Runs of one character, sequences
13 * along the alphabet, the digits or a keyboard row, either way, and parts
14 * repeated from earlier in the password each count as one character, and
15 * so do parts of the account's name, login or email address and of the
16 * site's name and host; at least MIN_REMAINING characters must remain.
17 *
18 * With `password.breach_check`, Have I Been Pwned's range API tells whether
19 * the password appeared in a breach. It only learns the first five
20 * characters of the password's SHA-1; without an answer the password passes.
21 */
22final class PasswordPolicy
23{
24    public const int MIN_LENGTH = 12;
25    public const int MAX_LENGTH = 512;
26
27    /** Characters that must remain once each pattern counts as one. */
28    public const int MIN_REMAINING = 10;
29
30    private const int MIN_PATTERN = 3;
31    private const int MIN_PERSONAL = 4;
32    private const string RANGE_URL = 'https://api.pwnedpasswords.com/range/';
33
34    /** Keyboard rows of the QWERTY, QWERTZ and AZERTY layouts, their columns and walks between two rows. */
35    private const array SEQUENCES = [
36        'abcdefghijklmnopqrstuvwxyz',
37        '01234567890',
38        '!@#$%^&*()',
39        '!"§$%&/()=',
40        'qwertyuiop',
41        'qwertzuiopü',
42        'azertyuiop',
43        'asdfghjkl',
44        'asdfghjklöä',
45        'qsdfghjklm',
46        'zxcvbnm',
47        'yxcvbnm',
48        'wxcvbn',
49        '1qaz2wsx3edc4rfv5tgb6yhn7ujm8ik9ol0p',
50        '1qay2wsx3edc4rfv5tgb6zhn7ujm8ik9ol0p',
51        '1q2w3e4r5t6y7u8i9o0p',
52        '1q2w3e4r5t6z7u8i9o0p',
53        'q1w2e3r4t5y6u7i8o9p0',
54        'q1w2e3r4t5z6u7i8o9p0',
55    ];
56
57    /** @var ?list<string> */
58    private static ?array $sequences = null;
59
60    /** @var Closure(string): ?string */
61    private readonly Closure $fetch;
62
63    /** @param ?Closure(string): ?string $fetch answers a range request, or returns null */
64    public function __construct(
65        private readonly Config $config,
66        private readonly Logger $logger,
67        ?Closure $fetch = null,
68    ) {
69        $this->fetch = $fetch ?? $this->request(...);
70    }
71
72    /** Why the password may not be set, or null if it may; $personal are the account's name, login and email address. */
73    public function check(#[\SensitiveParameter] string $password, ?string ...$personal): ?PasswordProblem
74    {
75        $length = mb_strlen($password);
76
77        if ($length < self::MIN_LENGTH) {
78            return PasswordProblem::Short;
79        }
80
81        if ($length > self::MAX_LENGTH) {
82            return PasswordProblem::Long;
83        }
84
85        $chars = mb_str_split(mb_strtolower($password));
86
87        if (self::pieces($chars) < self::MIN_REMAINING) {
88            return PasswordProblem::Guessable;
89        }
90
91        if (self::pieces($chars, $this->personal($personal)) < self::MIN_REMAINING) {
92            return PasswordProblem::Personal;
93        }
94
95        if ($this->config->password->breachCheck && $this->breached($password)) {
96            return PasswordProblem::Breached;
97        }
98
99        return null;
100    }
101
102    /**
103     * How many pieces the password falls into when each pattern, and each
104     * part of a personal value, counts as one.
105     *
106     * @param list<string> $chars
107     * @param list<string> $personal
108     */
109    private static function pieces(array $chars, array $personal = []): int
110    {
111        $pieces = 0;
112        $at = 0;
113
114        while ($at < count($chars)) {
115            $at += max(
116                1,
117                self::run($chars, $at),
118                self::repeat($chars, $at),
119                self::within($chars, $at, self::sequences(), self::MIN_PATTERN),
120                self::within($chars, $at, $personal, self::MIN_PERSONAL),
121            );
122            $pieces++;
123        }
124
125        return $pieces;
126    }
127
128    /** @param list<string> $chars */
129    private static function run(array $chars, int $at): int
130    {
131        $end = $at + 1;
132
133        while (($chars[$end] ?? null) === $chars[$at]) {
134            $end++;
135        }
136
137        return ($end - $at) >= self::MIN_PATTERN ? $end - $at : 0;
138    }
139
140    /**
141     * The longest part at $at that also begins earlier; it may overlap
142     * itself, as in abababab.
143     *
144     * @param list<string> $chars
145     */
146    private static function repeat(array $chars, int $at): int
147    {
148        $length = 0;
149
150        for ($k = self::MIN_PATTERN; ($at + $k) <= count($chars); $k++) {
151            $part = implode('', array_slice($chars, $at, $k));
152
153            if (!str_contains(implode('', array_slice($chars, 0, $at + $k - 1)), $part)) {
154                break;
155            }
156
157            $length = $k;
158        }
159
160        return $length;
161    }
162
163    /**
164     * The longest part at $at, of at least $min characters, found in one of
165     * the haystacks.
166     *
167     * @param list<string> $chars
168     * @param list<string> $haystacks
169     */
170    private static function within(array $chars, int $at, array $haystacks, int $min): int
171    {
172        $length = 0;
173
174        for ($k = $min; ($at + $k) <= count($chars); $k++) {
175            $part = implode('', array_slice($chars, $at, $k));
176
177            if (!array_any($haystacks, static fn(string $haystack): bool => str_contains($haystack, $part))) {
178                break;
179            }
180
181            $length = $k;
182        }
183
184        return $length;
185    }
186
187    /** @return list<string> */
188    private static function sequences(): array
189    {
190        return self::$sequences ??= [
191            ...self::SEQUENCES,
192            ...array_map(
193                static fn(string $sequence): string => implode('', array_reverse(mb_str_split($sequence))),
194                self::SEQUENCES,
195            ),
196        ];
197    }
198
199    /**
200     * @param array<array-key, ?string> $values
201     * @return list<string>
202     */
203    private function personal(array $values): array
204    {
205        $values[] = $this->config->app->name;
206        $values[] = $this->config->app->title;
207        $values[] = parse_url((string) $this->config->app->url, PHP_URL_HOST);
208
209        return array_values(array_filter(
210            array_map(static fn(mixed $value): string => mb_strtolower(trim((string) $value)), $values),
211            static fn(string $value): bool => mb_strlen($value) >= self::MIN_PERSONAL,
212        ));
213    }
214
215    private function breached(#[\SensitiveParameter] string $password): bool
216    {
217        $hash = strtoupper(sha1($password));
218        $range = ($this->fetch)(self::RANGE_URL . substr($hash, 0, 5));
219
220        if ($range === null) {
221            $this->logger->warning('Password breach check skipped: {url} did not answer', ['url' => self::RANGE_URL]);
222
223            return false;
224        }
225
226        $suffix = substr($hash, 5) . ':';
227
228        foreach (explode("\n", $range) as $line) {
229            if (str_starts_with($line, $suffix)) {
230                // Padding lines have a count of 0.
231                return (int) substr(trim($line), strlen($suffix)) > 0;
232            }
233        }
234
235        return false;
236    }
237
238    private function request(string $url): ?string
239    {
240        $handle = curl_init($url);
241        curl_setopt_array($handle, [
242            CURLOPT_RETURNTRANSFER => true,
243            CURLOPT_CONNECTTIMEOUT => 2,
244            CURLOPT_TIMEOUT => 5,
245            CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
246            CURLOPT_HTTPHEADER => ['Add-Padding: true'],
247            CURLOPT_USERAGENT => 'cosray/cms',
248        ]);
249        $body = curl_exec($handle);
250        $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
251
252        return is_string($body) && $status === 200 ? $body : null;
253    }
254}