StrengthChecker::calculateLengthScore()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 5
nc 1
nop 1
dl 0
loc 8
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace PasswordHelper;
6
7
/**
8
 * Evaluates and rates the strength of passwords using a comprehensive scoring system.
9
 *
10
 * This class provides methods to assess password strength based on various factors
11
 * including length, character variety, complexity, and entropy. The strength is
12
 * scored on a scale from 1 to 100, with higher scores indicating stronger passwords.
13
 *
14
 * @package PasswordHelper
15
 */
16
class StrengthChecker
17
{
18
    /**
19
     * Minimum password length for a strong password.
20
     */
21
    private const MIN_LENGTH = 8;
22
23
    /**
24
     * Maximum length to consider for scoring.
25
     */
26
    private const MAX_LENGTH = 20;
27
28
    /**
29
     * Common password patterns to check against.
30
     *
31
     * @var array<int, string>
32
     */
33
    private const COMMON_PATTERNS = [
34
        '123456', 'password', 'qwerty', 'admin', 'welcome',
35
        'monkey', 'letmein', 'dragon', 'baseball', 'iloveyou',
36
        'trustno1', 'sunshine', 'master', 'hello', 'shadow',
37
        'ashley', 'football', 'jesus', 'michael', 'ninja',
38
        'mustang', 'password1', '12345678', 'qwerty123', 'admin123'
39
    ];
40
41
    /**
42
     * Common dictionary words to check against.
43
     *
44
     * @var array<int, string>
45
     */
46
    private const COMMON_WORDS = [
47
        'password', 'admin', 'user', 'login', 'welcome',
48
        'hello', 'world', 'test', 'guest', 'default'
49
    ];
50
51
    /**
52
     * Creates a new password strength checker.
53
     */
54
    public function __construct()
55
    {
56
    }
57
58
    /**
59
     * Evaluates and returns the strength score of a given password.
60
     *
61
     * The strength is calculated based on several factors:
62
     * - Length (up to 30 points)
63
     * - Character variety (up to 30 points)
64
     * - Complexity (up to 20 points)
65
     * - Entropy (up to 20 points)
66
     *
67
     * The final score is normalized to a 1-100 scale.
68
     *
69
     * @param string $password The password to evaluate
70
     * @return int A score from 1 to 100 indicating password strength
71
     */
72
    public function checkStrength(string $password): int
73
    {
74
        if (empty($password) || strlen($password) < self::MIN_LENGTH) {
75
            return 1;
76
        }
77
78
        $score = 0;
79
80
        $score += $this->calculateLengthScore($password);
81
        $score += $this->calculateVarietyScore($password);
82
        $score += $this->calculateComplexityScore($password);
83
        $score += $this->calculateEntropyScore($password);
84
85
        return min(100, max(1, $score));
86
    }
87
88
    /**
89
     * Calculates the length-based score component.
90
     *
91
     * @param string $password The password to evaluate
92
     * @return int Score from 0 to 30
93
     */
94
    private function calculateLengthScore(string $password): int
95
    {
96
        $length = strlen($password);
97
98
        return match (true) {
99
            $length < self::MIN_LENGTH => 0,
100
            $length >= self::MAX_LENGTH => 30,
101
            default => (int) (($length - self::MIN_LENGTH) / (self::MAX_LENGTH - self::MIN_LENGTH) * 30)
102
        };
103
    }
104
105
    /**
106
     * Calculates the character variety score component.
107
     *
108
     * @param string $password The password to evaluate
109
     * @return int Score from 0 to 30
110
     */
111
    private function calculateVarietyScore(string $password): int
112
    {
113
        return $this->getCharacterTypeScore($password) +
114
               $this->getMixedCaseScore($password) +
115
               $this->getMixedTypesScore($password);
116
    }
117
118
    /**
119
     * Gets the score for different character types.
120
     *
121
     * @param string $password The password to evaluate
122
     * @return int Score from 0 to 20
123
     */
124
    private function getCharacterTypeScore(string $password): int
125
    {
126
        $score = 0;
127
128
        if (preg_match('/[A-Z]/', $password)) {
129
            $score += 5;
130
        }
131
        if (preg_match('/[a-z]/', $password)) {
132
            $score += 5;
133
        }
134
        if (preg_match('/\d/', $password)) {
135
            $score += 5;
136
        }
137
        if (preg_match('/[^a-zA-Z\d]/', $password)) {
138
            $score += 5;
139
        }
140
141
        return $score;
142
    }
143
144
    /**
145
     * Gets the score for mixed case usage.
146
     *
147
     * @param string $password The password to evaluate
148
     * @return int Score from 0 to 5
149
     */
150
    private function getMixedCaseScore(string $password): int
151
    {
152
        return (preg_match('/[A-Z]/', $password) && preg_match('/[a-z]/', $password)) ? 5 : 0;
153
    }
154
155
    /**
156
     * Gets the score for mixed character types.
157
     *
158
     * @param string $password The password to evaluate
159
     * @return int Score from 0 to 5
160
     */
161
    private function getMixedTypesScore(string $password): int
162
    {
163
        $types = 0;
164
        $types += (int) preg_match('/[A-Z]/', $password);
165
        $types += (int) preg_match('/[a-z]/', $password);
166
        $types += (int) preg_match('/\d/', $password);
167
        $types += (int) preg_match('/[^a-zA-Z\d]/', $password);
168
169
        return ($types >= 3) ? 5 : 0;
170
    }
171
172
    /**
173
     * Calculates the complexity score component.
174
     *
175
     * @param string $password The password to evaluate
176
     * @return int Score from 0 to 20
177
     */
178
    private function calculateComplexityScore(string $password): int
179
    {
180
        return $this->getRepeatedCharsScore($password) +
181
               $this->getSequentialCharsScore($password) +
182
               $this->getCommonPatternScore($password) +
183
               $this->getDictionaryWordScore($password);
184
    }
185
186
    /**
187
     * Gets the score for repeated characters.
188
     *
189
     * @param string $password The password to evaluate
190
     * @return int Score from 0 to 5
191
     */
192
    private function getRepeatedCharsScore(string $password): int
193
    {
194
        return !preg_match('/(.)\1{2,}/', $password) ? 5 : 0;
195
    }
196
197
    /**
198
     * Gets the score for sequential characters.
199
     *
200
     * @param string $password The password to evaluate
201
     * @return int Score from 0 to 5
202
     */
203
    private function getSequentialCharsScore(string $password): int
204
    {
205
        return !$this->hasSequentialChars($password) ? 5 : 0;
206
    }
207
208
    /**
209
     * Gets the score for common patterns.
210
     *
211
     * @param string $password The password to evaluate
212
     * @return int Score from 0 to 5
213
     */
214
    private function getCommonPatternScore(string $password): int
215
    {
216
        return !$this->hasCommonPattern($password) ? 5 : 0;
217
    }
218
219
    /**
220
     * Gets the score for dictionary words.
221
     *
222
     * @param string $password The password to evaluate
223
     * @return int Score from 0 to 5
224
     */
225
    private function getDictionaryWordScore(string $password): int
226
    {
227
        return !$this->hasDictionaryWord($password) ? 5 : 0;
228
    }
229
230
    /**
231
     * Calculates the entropy-based score component.
232
     *
233
     * @param string $password The password to evaluate
234
     * @return int Score from 0 to 20
235
     */
236
    private function calculateEntropyScore(string $password): int
237
    {
238
        $entropy = $this->calculateEntropy($password);
239
        return (int) min(20, ($entropy / 100) * 20);
240
    }
241
242
    /**
243
     * Calculates the entropy of a password.
244
     *
245
     * @param string $password The password to evaluate
246
     * @return float The entropy in bits
247
     */
248
    private function calculateEntropy(string $password): float
249
    {
250
        $length = strlen($password);
251
        $charset = $this->calculateCharsetSize($password);
252
        return $length * log($charset, 2);
253
    }
254
255
    /**
256
     * Calculates the size of the character set used in the password.
257
     *
258
     * @param string $password The password to evaluate
259
     * @return int The size of the character set
260
     */
261
    private function calculateCharsetSize(string $password): int
262
    {
263
        $charset = 0;
264
265
        if (preg_match('/[a-z]/', $password)) {
266
            $charset += 26;
267
        }
268
        if (preg_match('/[A-Z]/', $password)) {
269
            $charset += 26;
270
        }
271
        if (preg_match('/\d/', $password)) {
272
            $charset += 10;
273
        }
274
        if (preg_match('/[^a-zA-Z\d]/', $password)) {
275
            $charset += 32;
276
        }
277
278
        return $charset;
279
    }
280
281
    /**
282
     * Checks if the password contains sequential characters.
283
     *
284
     * @param string $password The password to check
285
     * @return bool True if sequential characters are found
286
     */
287
    private function hasSequentialChars(string $password): bool
288
    {
289
        return $this->hasSequentialNumbers($password) ||
290
               $this->hasSequentialLetters($password) ||
291
               $this->hasKeyboardPattern($password);
292
    }
293
294
    /**
295
     * Checks if the password contains sequential numbers.
296
     *
297
     * @param string $password The password to check
298
     * @return bool True if sequential numbers are found
299
     */
300
    private function hasSequentialNumbers(string $password): bool
301
    {
302
        return (bool) preg_match('/012|123|234|345|456|567|678|789/', $password);
303
    }
304
305
    /**
306
     * Checks if the password contains sequential letters.
307
     *
308
     * @param string $password The password to check
309
     * @return bool True if sequential letters are found
310
     */
311
    private function hasSequentialLetters(string $password): bool
312
    {
313
        return (bool) preg_match('/abc|bcd|cde|def|efg|fgh|ghi|hij|ijk|jkl|klm|lmn|mno|nop|opq|pqr|qrs|rst|stu|tuv|uvw|vwx|wxy|xyz/i', $password);
314
    }
315
316
    /**
317
     * Checks if the password contains keyboard patterns.
318
     *
319
     * @param string $password The password to check
320
     * @return bool True if keyboard patterns are found
321
     */
322
    private function hasKeyboardPattern(string $password): bool
323
    {
324
        return (bool) preg_match('/qwer|wert|erty|rtyu|tyui|yuio|uiop|asdf|sdfg|dfgh|fghj|ghjk|hjkl|zxcv|xcvb|cvbn|vbnm/i', $password);
325
    }
326
327
    /**
328
     * Checks if the password contains common patterns.
329
     *
330
     * @param string $password The password to check
331
     * @return bool True if common patterns are found
332
     */
333
    private function hasCommonPattern(string $password): bool
334
    {
335
        $password = strtolower($password);
336
337
        foreach (self::COMMON_PATTERNS as $pattern) {
338
            if (str_contains($password, $pattern)) {
339
                return true;
340
            }
341
        }
342
343
        return false;
344
    }
345
346
    /**
347
     * Checks if the password contains dictionary words.
348
     *
349
     * @param string $password The password to check
350
     * @return bool True if dictionary words are found
351
     */
352
    private function hasDictionaryWord(string $password): bool
353
    {
354
        foreach (self::COMMON_WORDS as $word) {
355
            if (stripos($password, $word) !== false) {
356
                return true;
357
            }
358
        }
359
360
        return false;
361
    }
362
}
363