From 3d6e0448e45f7396b5dfda683dd61b03397f936c Mon Sep 17 00:00:00 2001 From: Marcus Olsson Date: Thu, 8 Sep 2022 14:17:02 +0200 Subject: [PATCH] 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 --- .github/FUNDING.yml | 2 + .github/workflows/test.yml | 2 - LICENSE.md | 2 +- README.md | 132 ++++++++++-------- composer.json | 20 ++- src/Rules/Zxcvbn.php | 50 +++++++ src/Rules/ZxcvbnDictionary.php | 54 +++++++ src/ZxcvbnServiceProvider.php | 76 ++-------- tests/Pest.php | 5 + tests/TestCase.php | 21 +++ tests/ZxcvbnTest.php | 247 +++++++++++++++------------------ 11 files changed, 343 insertions(+), 268 deletions(-) create mode 100644 .github/FUNDING.yml create mode 100644 src/Rules/Zxcvbn.php create mode 100644 src/Rules/ZxcvbnDictionary.php create mode 100644 tests/Pest.php create mode 100644 tests/TestCase.php diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..5dacfe4 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: olssonm +custom: https://marcusolsson.me/kontakta diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d2c0a92..50a4daa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 }} diff --git a/LICENSE.md b/LICENSE.md index 558812c..477b3eb 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ # The MIT License (MIT) -Copyright (c) 2020 Marcus Olsson +Copyright (c) 2022 Marcus Olsson > Permission is hereby granted, free of charge, to any person obtaining a copy > of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 46849c4..dc5b754 100644 --- a/README.md +++ b/README.md @@ -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 - "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 - '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 - '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 diff --git a/composer.json b/composer.json index 7a5e2ec..b72e9c7 100644 --- a/composer.json +++ b/composer.json @@ -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 + } + } } diff --git a/src/Rules/Zxcvbn.php b/src/Rules/Zxcvbn.php new file mode 100644 index 0000000..398179f --- /dev/null +++ b/src/Rules/Zxcvbn.php @@ -0,0 +1,50 @@ +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.'; + } +} diff --git a/src/Rules/ZxcvbnDictionary.php b/src/Rules/ZxcvbnDictionary.php new file mode 100644 index 0000000..9d39912 --- /dev/null +++ b/src/Rules/ZxcvbnDictionary.php @@ -0,0 +1,54 @@ +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.'; + } +} diff --git a/src/ZxcvbnServiceProvider.php b/src/ZxcvbnServiceProvider.php index 731e342..3782f68 100644 --- a/src/ZxcvbnServiceProvider.php +++ b/src/ZxcvbnServiceProvider.php @@ -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()); + } + } } diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..48924bc --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,5 @@ +in(__DIR__); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..2f5244e --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,21 @@ +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(); }