Skip to content

Commit

Permalink
Merge pull request #55 from programmatordev/YAPV-40-create-passwordst…
Browse files Browse the repository at this point in the history
…rength-rule

Create PasswordStrength rule
  • Loading branch information
andrepimpao authored Mar 25, 2024
2 parents 0498dc3 + 990e81e commit ff5ef71
Show file tree
Hide file tree
Showing 11 changed files with 243 additions and 13 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,10 @@ Simple usage looks like:
use ProgrammatorDev\Validator\Rule;
use ProgrammatorDev\Validator\Validator;

// do this...
$validator = Validator::notBlank()->greaterThanOrEqual(18);
// do this:
$validator = Validator::type('int')->greaterThanOrEqual(18);

// ...and validate with these:
// and validate with these:
$validator->validate(16); // returns bool: false
$validator->assert(16, 'age'); // throws exception: The age value should be greater than or equal to 18, 16 given.
```
Expand Down
6 changes: 3 additions & 3 deletions docs/01-get-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ Simple usage looks like:
use ProgrammatorDev\Validator\Rule;
use ProgrammatorDev\Validator\Validator;

// do this...
$validator = Validator::notBlank()->greaterThanOrEqual(18);
// do this:
$validator = Validator::type('int')->greaterThanOrEqual(18);

// ...and validate with these:
// and validate with these:
$validator->validate(16); // returns bool: false
$validator->assert(16, 'age'); // throws exception: The age value should be greater than or equal to 18, 16 given.
```
8 changes: 4 additions & 4 deletions docs/02-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public function getWeather(float $latitude, float $longitude, string $unitSystem
{
Validator::range(-90, 90)->assert($latitude, 'latitude');
Validator::range(-180, 180)->assert($longitude, 'longitude');
Validator::notBlank()->choice(['metric', 'imperial'])->assert($unitSystem, 'unit system');
Validator::choice(['metric', 'imperial'])->assert($unitSystem, 'unit system');

// ...
}
Expand Down Expand Up @@ -51,7 +51,7 @@ function getWeather(float $latitude, float $longitude, string $unitSystem): floa
{
Validator::range(-90, 90)->assert($latitude, 'latitude');
Validator::range(-180, 180)->assert($longitude, 'longitude');
Validator::notBlank()->choice(['metric', 'imperial'])->assert($unitSystem, 'unit system');
Validator::choice(['metric', 'imperial'])->assert($unitSystem, 'unit system');

// ...
}
Expand Down Expand Up @@ -98,7 +98,7 @@ use ProgrammatorDev\Validator\Validator;
try {
Validator::range(-90, 90)->assert($latitude, 'latitude');
Validator::range(-180, 180)->assert($longitude, 'longitude');
Validator::notBlank()->choice(['metric', 'imperial'])->assert($unitSystem, 'unit system');
Validator::choice(['metric', 'imperial'])->assert($unitSystem, 'unit system');
}
catch (Exception\RangeException $exception) {
// do something when Range fails
Expand All @@ -120,7 +120,7 @@ use ProgrammatorDev\Validator\Validator;
try {
Validator::range(-90, 90)->assert($latitude, 'latitude');
Validator::range(-180, 180)->assert($longitude, 'longitude');
Validator::notBlank()->choice(['metric', 'imperial'])->assert($unitSystem, 'unit system');
Validator::choice(['metric', 'imperial'])->assert($unitSystem, 'unit system');
}
catch (ValidationException $exception) {
// do something when a rule fails
Expand Down
1 change: 1 addition & 0 deletions docs/03-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

- [Email](03-rules_email.md)
- [Length](03-rules_length.md)
- [PasswordStrength](03-rules_password-strength.md)
- [URL](03-rules_url.md)

## Comparison Rules
Expand Down
6 changes: 3 additions & 3 deletions docs/03-rules_length.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,10 @@ Allows to define a `callable` that will be applied to the value before checking
For example, use `trim`, or pass your own function, to not measure whitespaces at the end of a string:

```php
// existing php callables
Validator::length(max: 3)->validate('abc '); // false

Validator::length(max: 3, normalizer: 'trim')->validate('abc '); // true
// function
Validator::length(max: 3, normalizer: fn($value) => trim($value))->validate('abc '); // false
Validator::length(max: 3, normalizer: fn($value) => trim($value))->validate('abc '); // true
```

### `minMessage`
Expand Down
60 changes: 60 additions & 0 deletions docs/03-rules_password-strength.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# PasswordStrength

Validates that the given password has reached the minimum strength required by the constraint.
The strength is calculated by measuring the entropy of the password (in bits) based on its length and the number of unique characters.

```php
PasswordStrength(
string $minStrength = 'medium',
?string $minMessage = null
);
```

## Basic Usage

```php
Validator::passwordStrength()->validate('password'); // false
Validator::passwordStrength()->validate('i8Kq*MBob~2W"=p'); // true
Validator::passwordStrength(minStrength: 'very-strong')->validate('i8Kq*MBob~2W"=p'); // false
```

> [!NOTE]
> An `UnexpectedValueException` will be thrown when a `minStrength` option is invalid.
> [!NOTE]
> An `UnexpectedValueException` will be thrown when the input value is not a `string`.
## Options

### `minStrength`

type: `string` default: `medium`

Sets the minimum strength of the password in entropy bits.
The entropy is calculated using the formula [here](https://www.pleacher.com/mp/mlessons/algebra/entropy.html).

Available options are:

- `weak` entropy between `64` and `79` bits.
- `medium` entropy between `80` and `95` bits.
- `strong` entropy between `96` and `127` bits.
- `very-strong` entropy greater than `128` bits.

All measurements less than `64` bits will fail.

### `message`

type: `?string` default: `The password strength is not strong enough.`

Message that will be shown when the password is not strong enough.

The following parameters are available:

| Parameter | Description |
|---------------------|---------------------------|
| `{{ name }}` | Name of the invalid value |
| `{{ minStrength }}` | Selected minimum strength |

## Changelog

- `0.8.0` Created
5 changes: 5 additions & 0 deletions src/ChainedValidatorInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ public function notBlank(
?string $message = null
): ChainedValidatorInterface&Validator;

public function passwordStrength(
string $minStrength = 'medium',
?string $message = null
): ChainedValidatorInterface&Validator;

public function range(
mixed $min,
mixed $max,
Expand Down
5 changes: 5 additions & 0 deletions src/Exception/PasswordStrengthException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php

namespace ProgrammatorDev\Validator\Exception;

class PasswordStrengthException extends ValidationException {}
94 changes: 94 additions & 0 deletions src/Rule/PasswordStrength.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

namespace ProgrammatorDev\Validator\Rule;

use ProgrammatorDev\Validator\Exception\PasswordStrengthException;
use ProgrammatorDev\Validator\Exception\UnexpectedOptionException;
use ProgrammatorDev\Validator\Exception\UnexpectedTypeException;

class PasswordStrength extends AbstractRule implements RuleInterface
{
private const STRENGTH_VERY_WEAK = 'very-weak';
public const STRENGTH_WEAK = 'weak';
public const STRENGTH_MEDIUM = 'medium';
public const STRENGTH_STRONG = 'strong';
public const STRENGTH_VERY_STRONG = 'very-strong';

private const STRENGTH_OPTIONS = [
self::STRENGTH_WEAK,
self::STRENGTH_MEDIUM,
self::STRENGTH_STRONG,
self::STRENGTH_VERY_STRONG
];

private const STRENGTH_SCORE = [
self::STRENGTH_VERY_WEAK => 0,
self::STRENGTH_WEAK => 1,
self::STRENGTH_MEDIUM => 2,
self::STRENGTH_STRONG => 3,
self::STRENGTH_VERY_STRONG => 4
];

private string $message = 'The password strength is not strong enough.';

public function __construct(
private readonly string $minStrength = self::STRENGTH_MEDIUM,
?string $message = null
)
{
$this->message = $message ?? $this->message;
}

public function assert(#[\SensitiveParameter] mixed $value, ?string $name = null): void
{
if (!\in_array($this->minStrength, self::STRENGTH_OPTIONS)) {
throw new UnexpectedOptionException('minStrength', self::STRENGTH_OPTIONS, $this->minStrength);
}

if (!\is_string($value)) {
throw new UnexpectedTypeException('string', get_debug_type($value));
}

$minScore = self::STRENGTH_SCORE[$this->minStrength];
$score = self::STRENGTH_SCORE[$this->calcStrength($value)];

if ($minScore > $score) {
throw new PasswordStrengthException(
message: $this->message,
parameters: [
'name' => $name,
'minStrength' => $this->minStrength
]
);
}
}

private function calcStrength(#[\SensitiveParameter] string $password): string
{
$length = \strlen($password);
$chars = \count_chars($password, 1);

$control = $digit = $upper = $lower = $symbol = $other = 0;
foreach ($chars as $char => $count) {
match (true) {
($char < 32 || $char === 127) => $control = 33,
($char >= 48 && $char <= 57) => $digit = 10,
($char >= 65 && $char <= 90) => $upper = 26,
($char >= 97 && $char <= 122) => $lower = 26,
($char >= 128) => $other = 128,
default => $symbol = 33,
};
}

$pool = $control + $digit + $upper + $lower + $symbol + $other;
$entropy = \log(\pow($pool, $length), 2);

return match (true) {
$entropy >= 128 => self::STRENGTH_VERY_STRONG,
$entropy >= 96 => self::STRENGTH_STRONG,
$entropy >= 80 => self::STRENGTH_MEDIUM,
$entropy >= 64 => self::STRENGTH_WEAK,
default => self::STRENGTH_VERY_WEAK
};
}
}
5 changes: 5 additions & 0 deletions src/StaticValidatorInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ public static function notBlank(
?string $message = null
): ChainedValidatorInterface&Validator;

public static function passwordStrength(
string $minStrength = 'medium',
?string $message = null
): ChainedValidatorInterface&Validator;

public static function range(
mixed $min,
mixed $max,
Expand Down
60 changes: 60 additions & 0 deletions tests/PasswordStrengthTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

namespace ProgrammatorDev\Validator\Test;

use ProgrammatorDev\Validator\Exception\PasswordStrengthException;
use ProgrammatorDev\Validator\Rule\PasswordStrength;
use ProgrammatorDev\Validator\Test\Util\TestRuleFailureConditionTrait;
use ProgrammatorDev\Validator\Test\Util\TestRuleMessageOptionTrait;
use ProgrammatorDev\Validator\Test\Util\TestRuleSuccessConditionTrait;
use ProgrammatorDev\Validator\Test\Util\TestRuleUnexpectedValueTrait;

class PasswordStrengthTest extends AbstractTest
{
use TestRuleUnexpectedValueTrait;
use TestRuleFailureConditionTrait;
use TestRuleSuccessConditionTrait;
use TestRuleMessageOptionTrait;

public static function provideRuleUnexpectedValueData(): \Generator
{
$unexpectedOptionMessage = '/Invalid (.*) "(.*)". Accepted values are: "(.*)"./';
$unexpectedTypeMessage = '/Expected value of type "string", "(.*)" given./';

yield 'invalid min strength' => [new PasswordStrength(minStrength: 'invalid'), 'password', $unexpectedOptionMessage];
yield 'invalid value type' => [new PasswordStrength(), 123, $unexpectedTypeMessage];
}

public static function provideRuleFailureConditionData(): \Generator
{
$value = 'password';
$exception = PasswordStrengthException::class;
$message = '/The password strength is not strong enough./';

yield 'min strength weak' => [new PasswordStrength(minStrength: 'weak'), $value, $exception, $message];
yield 'min strength medium' => [new PasswordStrength(minStrength: 'medium'), $value, $exception, $message];
yield 'min strength strong' => [new PasswordStrength(minStrength: 'strong'), $value, $exception, $message];
yield 'min strength very strong' => [new PasswordStrength(minStrength: 'very-strong'), $value, $exception, $message];
}

public static function provideRuleSuccessConditionData(): \Generator
{
$value = 'tP}D+9_$?m&g<ZX[D-]}5`ou$}Y,G1';

yield 'min strength weak' => [new PasswordStrength(minStrength: 'weak'), $value];
yield 'min strength medium' => [new PasswordStrength(minStrength: 'medium'), $value];
yield 'min strength strong' => [new PasswordStrength(minStrength: 'strong'), $value];
yield 'min strength very strong' => [new PasswordStrength(minStrength: 'very-strong'), $value];
}

public static function provideRuleMessageOptionData(): \Generator
{
yield 'message' => [
new PasswordStrength(
message: 'The {{ name }} value entropy is not high enough.'
),
'password',
'The test value entropy is not high enough.'
];
}
}

0 comments on commit ff5ef71

Please sign in to comment.