Skip to content
This repository was archived by the owner on Mar 13, 2025. It is now read-only.

Commit d78f748

Browse files
committed
feat: added PasswordStrength rule
1 parent 16eeb75 commit d78f748

File tree

5 files changed

+169
-0
lines changed

5 files changed

+169
-0
lines changed

src/ChainedValidatorInterface.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ public function notBlank(
8484
?string $message = null
8585
): ChainedValidatorInterface&Validator;
8686

87+
public function passwordStrength(
88+
string $minStrength = 'medium',
89+
?string $message = null
90+
): ChainedValidatorInterface&Validator;
91+
8792
public function range(
8893
mixed $min,
8994
mixed $max,
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<?php
2+
3+
namespace ProgrammatorDev\Validator\Exception;
4+
5+
class PasswordStrengthException extends ValidationException {}

src/Rule/PasswordStrength.php

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
namespace ProgrammatorDev\Validator\Rule;
4+
5+
use ProgrammatorDev\Validator\Exception\PasswordStrengthException;
6+
use ProgrammatorDev\Validator\Exception\UnexpectedOptionException;
7+
use ProgrammatorDev\Validator\Exception\UnexpectedTypeException;
8+
9+
class PasswordStrength extends AbstractRule implements RuleInterface
10+
{
11+
private const STRENGTH_VERY_WEAK = 'very-weak';
12+
public const STRENGTH_WEAK = 'weak';
13+
public const STRENGTH_MEDIUM = 'medium';
14+
public const STRENGTH_STRONG = 'strong';
15+
public const STRENGTH_VERY_STRONG = 'very-strong';
16+
17+
private const STRENGTH_OPTIONS = [
18+
self::STRENGTH_WEAK,
19+
self::STRENGTH_MEDIUM,
20+
self::STRENGTH_STRONG,
21+
self::STRENGTH_VERY_STRONG
22+
];
23+
24+
private const STRENGTH_SCORE = [
25+
self::STRENGTH_VERY_WEAK => 0,
26+
self::STRENGTH_WEAK => 1,
27+
self::STRENGTH_MEDIUM => 2,
28+
self::STRENGTH_STRONG => 3,
29+
self::STRENGTH_VERY_STRONG => 4
30+
];
31+
32+
private string $message = 'The password strength is not strong enough.';
33+
34+
public function __construct(
35+
private readonly string $minStrength = self::STRENGTH_MEDIUM,
36+
?string $message = null
37+
)
38+
{
39+
$this->message = $message ?? $this->message;
40+
}
41+
42+
public function assert(#[\SensitiveParameter] mixed $value, ?string $name = null): void
43+
{
44+
if (!\in_array($this->minStrength, self::STRENGTH_OPTIONS)) {
45+
throw new UnexpectedOptionException('minStrength', self::STRENGTH_OPTIONS, $this->minStrength);
46+
}
47+
48+
if (!\is_string($value)) {
49+
throw new UnexpectedTypeException('string', get_debug_type($value));
50+
}
51+
52+
$minScore = self::STRENGTH_SCORE[$this->minStrength];
53+
$score = self::STRENGTH_SCORE[$this->calcStrength($value)];
54+
55+
if ($minScore > $score) {
56+
throw new PasswordStrengthException(
57+
message: $this->message,
58+
parameters: [
59+
'name' => $name,
60+
'minStrength' => $this->minStrength
61+
]
62+
);
63+
}
64+
}
65+
66+
private function calcStrength(#[\SensitiveParameter] string $password): string
67+
{
68+
$length = \strlen($password);
69+
$chars = \count_chars($password, 1);
70+
71+
$control = $digit = $upper = $lower = $symbol = $other = 0;
72+
foreach ($chars as $char => $count) {
73+
match (true) {
74+
($char < 32 || $char === 127) => $control = 33,
75+
($char >= 48 && $char <= 57) => $digit = 10,
76+
($char >= 65 && $char <= 90) => $upper = 26,
77+
($char >= 97 && $char <= 122) => $lower = 26,
78+
($char >= 128) => $other = 128,
79+
default => $symbol = 33,
80+
};
81+
}
82+
83+
$pool = $control + $digit + $upper + $lower + $symbol + $other;
84+
$entropy = \log(\pow($pool, $length), 2);
85+
86+
return match (true) {
87+
$entropy >= 128 => self::STRENGTH_VERY_STRONG,
88+
$entropy >= 96 => self::STRENGTH_STRONG,
89+
$entropy >= 80 => self::STRENGTH_MEDIUM,
90+
$entropy >= 64 => self::STRENGTH_WEAK,
91+
default => self::STRENGTH_VERY_WEAK
92+
};
93+
}
94+
}

src/StaticValidatorInterface.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ public static function notBlank(
8383
?string $message = null
8484
): ChainedValidatorInterface&Validator;
8585

86+
public static function passwordStrength(
87+
string $minStrength = 'medium',
88+
?string $message = null
89+
): ChainedValidatorInterface&Validator;
90+
8691
public static function range(
8792
mixed $min,
8893
mixed $max,

tests/PasswordStrengthTest.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
namespace ProgrammatorDev\Validator\Test;
4+
5+
use ProgrammatorDev\Validator\Exception\PasswordStrengthException;
6+
use ProgrammatorDev\Validator\Rule\PasswordStrength;
7+
use ProgrammatorDev\Validator\Test\Util\TestRuleFailureConditionTrait;
8+
use ProgrammatorDev\Validator\Test\Util\TestRuleMessageOptionTrait;
9+
use ProgrammatorDev\Validator\Test\Util\TestRuleSuccessConditionTrait;
10+
use ProgrammatorDev\Validator\Test\Util\TestRuleUnexpectedValueTrait;
11+
12+
class PasswordStrengthTest extends AbstractTest
13+
{
14+
use TestRuleUnexpectedValueTrait;
15+
use TestRuleFailureConditionTrait;
16+
use TestRuleSuccessConditionTrait;
17+
use TestRuleMessageOptionTrait;
18+
19+
public static function provideRuleUnexpectedValueData(): \Generator
20+
{
21+
$unexpectedOptionMessage = '/Invalid (.*) "(.*)". Accepted values are: "(.*)"./';
22+
$unexpectedTypeMessage = '/Expected value of type "string", "(.*)" given./';
23+
24+
yield 'invalid min strength' => [new PasswordStrength(minStrength: 'invalid'), 'password', $unexpectedOptionMessage];
25+
yield 'invalid value type' => [new PasswordStrength(), 123, $unexpectedTypeMessage];
26+
}
27+
28+
public static function provideRuleFailureConditionData(): \Generator
29+
{
30+
$value = 'password';
31+
$exception = PasswordStrengthException::class;
32+
$message = '/The password strength is not strong enough./';
33+
34+
yield 'min strength weak' => [new PasswordStrength(minStrength: 'weak'), $value, $exception, $message];
35+
yield 'min strength medium' => [new PasswordStrength(minStrength: 'medium'), $value, $exception, $message];
36+
yield 'min strength strong' => [new PasswordStrength(minStrength: 'strong'), $value, $exception, $message];
37+
yield 'min strength very strong' => [new PasswordStrength(minStrength: 'very-strong'), $value, $exception, $message];
38+
}
39+
40+
public static function provideRuleSuccessConditionData(): \Generator
41+
{
42+
$value = 'tP}D+9_$?m&g<ZX[D-]}5`ou$}Y,G1';
43+
44+
yield 'min strength weak' => [new PasswordStrength(minStrength: 'weak'), $value];
45+
yield 'min strength medium' => [new PasswordStrength(minStrength: 'medium'), $value];
46+
yield 'min strength strong' => [new PasswordStrength(minStrength: 'strong'), $value];
47+
yield 'min strength very strong' => [new PasswordStrength(minStrength: 'very-strong'), $value];
48+
}
49+
50+
public static function provideRuleMessageOptionData(): \Generator
51+
{
52+
yield 'message' => [
53+
new PasswordStrength(
54+
message: 'The {{ name }} value entropy is not high enough.'
55+
),
56+
'password',
57+
'The test value entropy is not high enough.'
58+
];
59+
}
60+
}

0 commit comments

Comments
 (0)