Rework dictionary validator, refactoring (#20)

* Update meta

* Rewrite to rule-based validation

* New tests and testing framework

* Minor restructure and cleanup

* Update README.md

* Copy-tweaks. Drop PHP 7.3

* Fix message redout in older Laravel-versions
This commit is contained in:
Marcus Olsson 2022-09-08 14:17:02 +02:00 committed by GitHub
parent 1ea671c7fe
commit 3d6e0448e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 343 additions and 268 deletions

2
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,2 @@
github: olssonm
custom: https://marcusolsson.me/kontakta

View File

@ -21,8 +21,6 @@ jobs:
illuminate: ^8.0
- php: 7.4
illuminate: ^7.0
- php: 7.3
illuminate: ^7.0
name: PHP ${{ matrix.php }} - Illuminate ${{ matrix.illuminate }}

View File

@ -1,6 +1,6 @@
# The MIT License (MIT)
Copyright (c) 2020 Marcus Olsson <contact@marcusolsson.me>
Copyright (c) 2022 Marcus Olsson <contact@marcusolsson.me>
> Permission is hereby granted, free of charge, to any person obtaining a copy
> of this software and associated documentation files (the "Software"), to deal

132
README.md
View File

@ -9,7 +9,7 @@
A simple implementation of zxcvbn for Laravel. This package allows you to access "zxcvbn-related" data on a passphrase in the application and also to use zxcvbn as a standard validator.
Uses [Zxcvbn-PHP](https://github.com/bjeavons/zxcvbn-php) by [@bjeavons](https://github.com/bjeavons) and [@mkopinsky](https://github.com/mkopinsky), which in turn is inspired by [zxcvbn](https://github.com/dropbox/zxcvbn) by [@dropbox](https://github.com/dropbox).
Uses [Zxcvbn-PHP](https://github.com/bjeavons/zxcvbn-php) by [@bjeavons](https://github.com/bjeavons), which in turn is inspired by [zxcvbn](https://github.com/dropbox/zxcvbn) by [@dropbox](https://github.com/dropbox).
## Install
@ -33,9 +33,7 @@ If you've added `Olssonm\Zxcvbn` as an alias, your can access Zxcvbn easily from
### "In app"
```php
<?php
``` php
use Zxcvbn;
class MyClass extends MyOtherClass
@ -47,14 +45,29 @@ class MyClass extends MyOtherClass
// array:9 [
// "password" => "password"
// "guesses" => 3
// "guesses" => 3.0
// "guesses_log10" => 0.47712125471966
// "sequence" => array:1 []
// "crack_times_seconds" => array:4 []
// "crack_times_display" => array:4 []
// "sequence" => [],
// "crack_times_seconds" => array:4 [
// "online_throttling_100_per_hour" => 108.0
// "online_no_throttling_10_per_second" => 0.3
// "offline_slow_hashing_1e4_per_second" => 0.0003
// "offline_fast_hashing_1e10_per_second" => 3.0E-10
// ]
// "crack_times_display" => array:4 [
// "online_throttling_100_per_hour" => "2 minutes"
// "online_no_throttling_10_per_second" => "less than a second"
// "offline_slow_hashing_1e4_per_second" => "less than a second"
// "offline_fast_hashing_1e10_per_second" => "less than a second"
// ]
// "score" => 0
// "feedback" => array:2 []
// "calc_time" => 0.042769908905029
// "feedback" => array:2 [
// "warning" => "This is a top-10 common password"
// "suggestions" => array:1 [
// 0 => "Add another word or two. Uncommon words are better."
// ]
// ]
// "calc_time" => 0.020488977432251
// ]
}
}
@ -64,71 +77,68 @@ Play around with different passwords and phrases, the results may surprise you.
### As a validator
The package gives you two different validation rules that you may use; `zxcvbn_min` and `zxcvbn_dictionary`.
The package makes two types of validations available for your application. `zxcvbn` and `zxcvbn_dictionary`.
#### zxcvbn_min
### zxcvbn
`zxcvbn_min` allows you to set up a rule for minimum score that the value beeing tested should adhere to.
With this rule you set the lowest score that the phrase need to score wuth Zxcvbn to pass.
**Syntax**
input' => 'zxcvbn_min:min_value'
**Example**
```php
<?php
$data = ['password' => 'password'];
$validator = Validator::make($data, [
'password' => 'zxcvbn_min:3|required',
], [
'password.zxcvbn_min' => 'Your password is not strong enough!'
]);
``` php
'input' => 'zxcvbn:min_value'
```
In this example the password should at least have a "score" of three (3) to pass the validation. Of course, you should probably use the zxcvbn-library on the front-end too to allow the user to know this before posting the form...
**Examples**
#### zxcvbn_dictionary
``` php
$request->validate([
'password' => 'required|zxcvbn:3'
]);
```
This is a bit more interesting. `zxcvbn_dictionary` allows you to input both the users username and/or email, and their password. The validator checks that the password doesn't exist in the username, or that they are too similar.
You may also initialize the rule as an object:
``` php
use Olssonm\Zxcvbn\Rules\Zxcvbn;
function rules()
{
return [
'password' => ['required', new Zxcvbn($minScore = 3)]
];
}
```
In this example the password should at least have a "score" of three (3) to pass the validation. Of course, you should probably use the [zxcvbn-library](https://github.com/dropbox/zxcvbn) on the front-end too to allow the user to know this before posting the form.
### zxcvbn_dictionary
This is a bit more interesting. `zxcvbn_dictionary` allows you to input both the users username and/or email together with their password (you need suply one piece of user input). The validator checks that the password doesn't exist in the username, or that they are too similar.
**Syntax**
'input' => 'xcvbn_dictionary:username,email'
``` php
'input' => 'zxcvbn_dictionary:input1,input2'
```
**Example**
**Examples**
```php
<?php
/**
* Example 1, pass
*/
$password = '31??2sa//"dhjd2askjd19sad19!!&!#"';
$data = [
'username' => 'user',
'email' => 'trash@thedumpster.com'
``` php
$request->validate([
'password' => sprintf('required|zxcvbn_dictionary:%s,%s', $request->username, $request->email)
]);
```
``` php
use Olssonm\Zxcvbn\Rules\ZxcvbnDictionary;
function rules()
{
return [
'password' => ['required', new ZxcvbnDictionary($this->username)]
];
$validator = Validator::make($password, [
'password' => sprintf('required|zxcvbn_dictionary:%s,%s', $data['username'], $data['email'])
]);
dd($validator->passes());
// true
/**
* Example 2, fail
*/
$password = 'mycomplicatedphrase';
$data = [
'username' => 'mycomplicatedphrase',
'email' => 'mycomplicatedphrase@thedumpster.com'
];
$validator = Validator::make($password, [
'password' => sprintf('required|zxcvbn_dictionary:%s,%s', $data['username'], $data['email'])
]);
dd($validator->passes());
// false
}
```
## Testing
@ -147,7 +157,7 @@ $ phpunit
The MIT License (MIT). Please see the [License File](LICENSE.md) for more information.
© 2020 [Marcus Olsson](https://marcusolsson.me).
© 2022 [Marcus Olsson](https://marcusolsson.me).
[ico-version]: https://img.shields.io/packagist/v/olssonm/l5-zxcvbn.svg?style=flat-square

View File

@ -20,13 +20,15 @@
}
],
"require": {
"php": "^7.3|^8.0",
"php": "^7.4|^8.0",
"illuminate/support": "^7.0|^8.0|^9.0",
"bjeavons/zxcvbn-php": "^1.2"
},
"require-dev": {
"phpunit/phpunit": "^8.0|^9.0",
"orchestra/testbench": ">=4.0"
"orchestra/testbench": ">=4.0",
"pestphp/pest": "^1.22",
"squizlabs/php_codesniffer": "^3.7"
},
"autoload": {
"psr-4": {
@ -39,11 +41,14 @@
}
},
"scripts": {
"test": "phpunit"
"test": "./vendor/bin/pest",
"coverage": "XDEBUG_MODE=coverage; ./vendor/bin/pest --coverage",
"phpsniff": "vendor/bin/phpcs --standard=\"PSR12\" ./src",
"phpfix": "vendor/bin/phpcbf --standard=\"PSR12\" ./src"
},
"extra": {
"branch-alias": {
"dev-master": "4.x-dev"
"dev-master": "5.x-dev"
},
"laravel": {
"providers": [
@ -52,5 +57,10 @@
}
},
"minimum-stability": "dev",
"prefer-stable": true
"prefer-stable": true,
"config": {
"allow-plugins": {
"pestphp/pest-plugin": true
}
}
}

50
src/Rules/Zxcvbn.php Normal file
View File

@ -0,0 +1,50 @@
<?php
namespace Olssonm\Zxcvbn\Rules;
use Illuminate\Contracts\Validation\Rule;
use ZxcvbnPhp\Zxcvbn as ZxcvbnPhp;
class Zxcvbn implements Rule
{
private $target;
/**
* Create a new rule instance.
*
* @return void
*/
public function __construct($target = 5)
{
$this->target = $target;
}
public static function handle(): string
{
return 'zxcvbn';
}
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param mixed $value
*
* @return bool
*/
public function passes($attribute, $value)
{
$zxcvbn = (new ZxcvbnPhp())->passwordStrength($value);
return ($zxcvbn['score'] >= $this->target);
}
/**
* Get the validation error message.
*
* @return string
*/
public function message()
{
return 'The :attribute is not strong enough.';
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace Olssonm\Zxcvbn\Rules;
use Illuminate\Contracts\Validation\Rule;
use ZxcvbnPhp\Matchers\DictionaryMatch;
class ZxcvbnDictionary implements Rule
{
protected array $input;
/**
* Create a new rule instance.
*
* @return void
*/
public function __construct($input1 = null, $input2 = null)
{
$this->input = array_filter([$input1, $input2]);
}
public static function handle(): string
{
return 'zxcvbn_dictionary';
}
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param mixed $value
*
* @return bool
*/
public function passes($attribute, $value)
{
$matches = DictionaryMatch::match($value, $this->input);
$matches = array_values(array_filter($matches, function ($match) {
return $match->dictionaryName === 'user_inputs';
}));
return count($matches) === 0;
}
/**
* Get the validation error message.
*
* @return string
*/
public function message()
{
return 'The :attribute is too simililar to another field.';
}
}

View File

@ -2,71 +2,15 @@
namespace Olssonm\Zxcvbn;
use ZxcvbnPhp\Zxcvbn as ZxcvbnPhp;
use Illuminate\Contracts\Validation\Factory;
use Illuminate\Support\ServiceProvider;
use Olssonm\Zxcvbn\Rules\Zxcvbn;
use Olssonm\Zxcvbn\Rules\ZxcvbnDictionary;
use Validator;
use ZxcvbnPhp\Zxcvbn as ZxcvbnPhp;
class ZxcvbnServiceProvider extends ServiceProvider
{
/**
* Perform post-registration booting of services.
*
* @return void
*/
public function boot()
{
/**
* Extend the Laravel Validator with the "zxcvbn_min" rule
*/
Validator::extend('zxcvbn_min', function($attribute, $value, $parameters) {
$zxcvbn = new ZxcvbnPhp();
$zxcvbn = $zxcvbn->passwordStrength($value);
$target = 5;
if (isset($parameters[0])) {
$target = $parameters[0];
}
return ($zxcvbn['score'] >= $target);
}, 'Your :attribute is not secure enough.');
Validator::replacer('zxcvbn_min', function($message, $attribute) {
$message = str_replace(':attribute', $attribute, $message);
return $message;
});
/**
* Extend the Laravel Validator with the "zxcvbn_dictionary" rule
*/
Validator::extend('zxcvbn_dictionary', function($attribute, $value, $parameters) {
$email = null;
$username = null;
if (isset($parameters[0])) {
$email = $parameters[0];
$username = $parameters[1];
}
$zxcvbn = new ZxcvbnPhp();
$zxcvbn = $zxcvbn->passwordStrength($value, [$username, $email]);
if (isset($zxcvbn['sequence'][0])) {
$dictionary = $zxcvbn['sequence'][0];
if (isset($dictionary->dictionaryName)) {
return false;
}
}
return true;
}, 'Your :attribute is insecure. It either matches a commonly used password, or you have used a similar username/password combination.');
Validator::replacer('zxcvbn_dictionary', function($message, $attribute) {
$message = str_replace(':attribute', $attribute, $message);
return $message;
});
}
/**
* Register any package services.
*
@ -74,8 +18,18 @@ class ZxcvbnServiceProvider extends ServiceProvider
*/
public function register()
{
$this->app->bind('zxcvbn', function() {
$this->app->bind('zxcvbn', function () {
return new ZxcvbnPhp();
});
}
public function boot()
{
foreach ([Zxcvbn::class, ZxcvbnDictionary::class] as $rule) {
$this->app['validator']->extend($rule::handle(), function ($attribute, $value, $parameters) use ($rule) {
return (new $rule(...$parameters))->passes($attribute, $value);
},
(new $rule())->message());
}
}
}

5
tests/Pest.php Normal file
View File

@ -0,0 +1,5 @@
<?php
namespace Olssonm\Zxcvbn\Test;
uses(TestCase::class)->in(__DIR__);

21
tests/TestCase.php Normal file
View File

@ -0,0 +1,21 @@
<?php
namespace Olssonm\Zxcvbn\Test;
use Olssonm\Zxcvbn\ZxcvbnServiceProvider;
use Orchestra\Testbench\TestCase as OrchestraTestCase;
abstract class TestCase extends OrchestraTestCase
{
protected function setUp(): void
{
parent::setUp();
}
protected function getPackageProviders($app)
{
return [
ZxcvbnServiceProvider::class
];
}
}

View File

@ -1,160 +1,131 @@
<?php namespace Olssonm\Zxcvbn\Tests;
<?php
use Validator;
namespace Olssonm\Zxcvbn\Test;
use Zxcvbn;
use Illuminate\Support\Facades\Validator;
use Olssonm\Zxcvbn\Facades\Zxcvbn;
use Olssonm\Zxcvbn\Rules\Zxcvbn as ZxcvbnRule;
use Olssonm\Zxcvbn\Rules\ZxcvbnDictionary as ZxcvbnDictionaryRule;
use Olssonm\Zxcvbn\ZxcvbnServiceProvider;
use ZxcvbnPhp\Zxcvbn as ZxcvbnPhp;
class ZxcvbnTest extends \Orchestra\Testbench\TestCase {
it('loads package', function () {
$providers = $this->app->getLoadedProviders();
$this->assertTrue(array_key_exists(ZxcvbnServiceProvider::class, $providers));
});
public function setUp(): void {
parent::setUp();
}
it('loads facade', function () {
$facade = $this->app['zxcvbn'];
$this->assertTrue(is_a($facade, ZxcvbnPhp::class));
});
/**
* Load the package
* @return array the packages
*/
protected function getPackageProviders($app)
{
return [
'Olssonm\Zxcvbn\ZxcvbnServiceProvider'
];
}
it('can perform zxcvbn basics', function () {
$zxcvbn = Zxcvbn::passwordStrength('password');
/**
* Load the alias
* @return array the aliases
*/
protected function getPackageAliases($app)
{
return [
'Zxcvbn' => 'Olssonm\Zxcvbn\Facades\Zxcvbn'
];
}
$testVar1 = Zxcvbn::passwordStrength('test');
/**
* Just run som standard tests to see that Zxcvbn is up to snuff and working
* @test
*/
public function test_zxcvbn_basics()
{
$zxcvbn = Zxcvbn::passwordStrength('password');
// Check keys
$this->assertArrayHasKey('score', $testVar1);
$this->assertArrayHasKey('sequence', $testVar1);
$this->assertArrayHasKey('crack_times_seconds', $testVar1);
$this->assertArrayHasKey('crack_times_display', $testVar1);
$this->assertArrayHasKey('calc_time', $testVar1);
$this->assertArrayHasKey('guesses', $testVar1);
$testVar1 = Zxcvbn::passwordStrength('test');
// Check score-value
$this->assertEquals(0, $testVar1['score']);
// Check keys
$this->assertArrayHasKey('score', $testVar1);
$this->assertArrayHasKey('sequence', $testVar1);
$this->assertArrayHasKey('crack_times_seconds', $testVar1);
$this->assertArrayHasKey('crack_times_display', $testVar1);
$this->assertArrayHasKey('calc_time', $testVar1);
$this->assertArrayHasKey('guesses', $testVar1);
// Run some more tests
$testVar2 = Zxcvbn::passwordStrength('dadaurka');
$testVar3 = Zxcvbn::passwordStrength('staple horse battery');
$testVar4 = Zxcvbn::passwordStrength('7E6k9axB*gwGHa&aZTohmD9Wr&NVs[b4'); //<-- 32
// Check score-value
$this->assertEquals(0, $testVar1['score']);
// Check score-value
$this->assertEquals(2, $testVar2['score']);
$this->assertEquals(4, $testVar3['score']);
$this->assertEquals(4, $testVar4['score']);
});
// Run some more tests
$testVar2 = Zxcvbn::passwordStrength('dadaurka');
$testVar3 = Zxcvbn::passwordStrength('staple horse battery');
$testVar4 = Zxcvbn::passwordStrength('7E6k9axB*gwGHa&aZTohmD9Wr&NVs[b4'); //<-- 32
it('can validate min-rule', function () {
// Fails: returns message
$this->assertEquals('Just a test message', min_validation('test', 4, 'Just a test message'));
$this->assertEquals('Just another test message', min_validation('test', 4, 'Just another test message'));
$this->assertEquals('The password is not strong enough.', min_validation('staple horse battery', 5, null));
// Check score-value
$this->assertEquals(2, $testVar2['score']);
$this->assertEquals(4, $testVar3['score']);
$this->assertEquals(4, $testVar4['score']);
}
// Passes: returns true
$this->assertEquals(true, min_validation('test', 0));
$this->assertEquals(true, min_validation('staple horse battery', 3));
$this->assertEquals(true, min_validation('staple horse battery', 4));
});
/** @test */
public function test_password_strength()
{
// Standard tests
$this->assertEquals(true, $this->validate_without_message_min('test', 0));
$this->assertEquals(false, $this->validate_without_message_min('test', 4));
it('can validate dictionary-rule', function () {
// Fails: returns message
$this->assertEquals('The password is too simililar to another field.', dictionary_validation('dadaurka', 'test@test.com', 'dadaurka', null));
$this->assertEquals('The password is too simililar to another field.', dictionary_validation('dadaurka', 'dadaurka', null, null));
$this->assertEquals('Just a message', dictionary_validation('test', 'test@test.com', 'test', 'Just a message'));
$this->assertEquals(true, $this->validate_without_message_min('staple horse battery', 3));
$this->assertEquals(true, $this->validate_without_message_min('staple horse battery', 4));
$this->assertEquals(false, $this->validate_without_message_min('staple horse battery', 5));
}
// Passes: returns true
$this->assertEquals(true, dictionary_validation('d5=:r+AEl5?+', 'dadaurka@test.com', 'dadaurka', null));
$this->assertEquals(true, dictionary_validation('Mo]R^v@vYo]I', 'myemail@test.com', 'username', null));
$this->assertEquals(true, dictionary_validation('%!/%^Qz1q&KH', 'trash@thedumpster.com', 'username', null));
$this->assertEquals(true, dictionary_validation('O`l}/RqR9$.S','trash@thedumpster.com', null, null));
});
/** @test */
public function test_password_strength_with_message()
{
// Standard message
$this->assertEquals('Your password is not secure enough.', $this->validate_with_message_min('staple horse battery', 5, null));
$this->assertEquals('Just a message', $this->validate_with_message_min('test', 4, 'Just a message'));
}
it('can validate rules as objects', function() {
// Pass min-rule, fail dictionary-rule
$this->assertEquals('The password is too simililar to another field.', rule_validator(3, 'gagadododaka', 'gagadododaka@test.com', 'gagadododaka', null));
/** @test */
public function test_password_dictionary()
{
// Standard tests
$this->assertEquals(false, $this->validate_without_message_dictionary('password', 'test@test.com', 'test'));
$this->assertEquals(false, $this->validate_without_message_dictionary('test', 'test@test.com', 'test'));
$this->assertEquals(false, $this->validate_without_message_dictionary('721ahsa!', '721ahsa@test.com', '721ahsa'));
// Fail min-rule, pass dictionary-rule
$this->assertEquals('The password is not strong enough.', rule_validator(4, 'test', 'trash@thedumpster.com', 'username', null));
$this->assertEquals(true, $this->validate_without_message_dictionary('721ahsa!', 'dadaurka@test.com', 'dadaurka'));
$this->assertEquals(true, $this->validate_without_message_dictionary('asd912j!', 'myemail@test.com', 'username'));
$this->assertEquals(true, $this->validate_without_message_dictionary('asd912j!', 'trash@thedumpster.com', 'username'));
// Pass both rules
$this->assertEquals('The password is not strong enough.', rule_validator(7, 'O`l}/RqR9$.S', 'trash@thedumpster.com', null));
});
$this->assertEquals(true, $this->validate_without_message_dictionary('asd912j!', null, 'username'));
$this->assertEquals(true, $this->validate_without_message_dictionary('asd912j!', null, null));
}
/** @note validation helper */
function min_validation($password, $min, $message = null)
{
$data = ['password' => $password];
$validator = Validator::make($data, [
'password' => ['required', 'zxcvbn:' . $min],
], $message ? ['password.zxcvbn' => $message] : []);
/** @test */
public function test_password_dictionary_with_message()
{
// Standard message
$this->assertEquals('Your password is insecure. It either matches a commonly used password, or you have used a similar username/password combination.', $this->validate_with_message_dictionary('password', 'test@test.com', 'test', null));
$this->assertEquals('Just a message', $this->validate_with_message_dictionary('test', 'test@test.com', 'test', 'Just a message'));
}
/** @note validation helper */
private function validate_without_message_min($password, $min)
{
$data = ['password' => $password];
$validator = Validator::make($data, [
'password' => 'zxcvbn_min:' . $min . '|required',
]);
return $validator->passes();
}
/** @note validation helper */
private function validate_with_message_min($password, $min, $message)
{
$data = ['password' => $password];
$validator = Validator::make($data, [
'password' => 'zxcvbn_min:' . $min . '|required',
], [
'password.zxcvbn_min' => $message
]);
$errors = $validator->errors();
if (!$validator->passes()) {
$errors = $validator->errors('password');
return $errors->first('password');
}
}
/** @note validation helper */
private function validate_without_message_dictionary($password, $email, $username)
{
$data = ['password' => $password];
$validator = Validator::make($data, [
'password' => 'zxcvbn_dictionary:' . $username . ',' . $email . '|required',
]);
return $validator->passes();
}
/** @note validation helper */
private function validate_with_message_dictionary($password, $email, $username, $message)
{
$data = ['password' => $password];
$validator = Validator::make($data, [
'password' => 'zxcvbn_dictionary:' . $username . ',' . $email . '|required',
], [
'password.zxcvbn_dictionary' => $message
]);
$errors = $validator->errors();
return $errors->first('password');
}
return $validator->passes();
}
/** @note validation helper */
function dictionary_validation($password, $email, $username, $message = null)
{
$data = ['password' => $password];
$validator = Validator::make($data, [
'password' => 'zxcvbn_dictionary:' . $username . ',' . $email . '|required',
], $message ? ['password.zxcvbn_dictionary' => $message] : []);
if (!$validator->passes()) {
$errors = $validator->errors('password');
return $errors->first('password');
}
return $validator->passes();
}
/** @note object rule validator */
function rule_validator($min, $password, $email, $username, $message = null)
{
$data = ['password' => $password];
$validator = Validator::make($data, [
'password' => ['required', new ZxcvbnDictionaryRule($username, $email), new ZxcvbnRule($min)],
], $message ? ['password.zxcvbn' => $message] : []);
if (!$validator->passes()) {
$errors = $validator->errors('password');
return $errors->first('password');
}
return $validator->passes();
}