From 5e2a71f8a454f360a9f8a212093705a285cf12fa Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Fri, 19 Apr 2024 11:44:03 +0200 Subject: [PATCH] Add password meter for registration and change password --- config/users.php | 4 + src/View/Helper/UserHelper.php | 26 ++- templates/Users/change_password.php | 8 +- templates/Users/profile.php | 2 +- templates/Users/register.php | 7 +- ...PasswordManagementTraitIntegrationTest.php | 2 +- .../RegisterTraitIntegrationTest.php | 8 +- .../SimpleCrudTraitIntegrationTest.php | 2 +- webroot/js/pswmeter.js | 150 ++++++++++++++++++ 9 files changed, 196 insertions(+), 13 deletions(-) create mode 100644 webroot/js/pswmeter.js diff --git a/config/users.php b/config/users.php index 41cb241f7..860508b0a 100644 --- a/config/users.php +++ b/config/users.php @@ -78,6 +78,10 @@ // use reCaptcha in login, valid values are false, true 'login' => false, ], + 'passwordMeter' => [ + 'enabled' => true, + 'requiredScore' => 3, + ], 'Tos' => [ // determines if the user should include tos accepted 'required' => true, diff --git a/src/View/Helper/UserHelper.php b/src/View/Helper/UserHelper.php index b4bbf496f..dd3d500e1 100644 --- a/src/View/Helper/UserHelper.php +++ b/src/View/Helper/UserHelper.php @@ -18,7 +18,6 @@ use Cake\Utility\Inflector; use Cake\View\Helper; use CakeDC\Users\Utility\UsersUrl; -use Exception; use InvalidArgumentException; /** @@ -152,6 +151,29 @@ public function addReCaptchaScript(): void ]); } + /** + * @return void + */ + public function addPasswordMeterStript(): void + { + $this->Html->script('CakeDC/Users.pswmeter', [ + 'block' => 'script', + ]); + } + + /** + * @return string + */ + public function addPasswordMeter(): string + { + $this->addPasswordMeterStript(); + $requiredScore = Configure::read('Users.passwordMeter.requiredScore', 3); + $script = $this->Html->scriptBlock("const requiredScore = $requiredScore", ['defer' => true]); + + return $this->Html->tag('div', '', ['id' => 'pswmeter']) . + $this->Html->tag('div', '', ['id' => 'pswmeter-message']) . $script; + } + /** * Add reCaptcha to the form * @@ -174,7 +196,7 @@ public function addReCaptcha(): mixed if (method_exists($this, $method)) { try { $this->Form->unlockField('g-recaptcha-response'); - } catch (Exception $e) { + } catch (\Exception $e) { } return $this->{$method}(); diff --git a/templates/Users/change_password.php b/templates/Users/change_password.php index 7efba8f10..c266c3aa3 100644 --- a/templates/Users/change_password.php +++ b/templates/Users/change_password.php @@ -13,8 +13,12 @@ Form->control('password', [ 'type' => 'password', 'required' => true, + 'id' => 'new-password', 'label' => __d('cake_d_c/users', 'New password')]); ?> + + User->addPasswordMeter() ?> + Form->control('password_confirm', [ 'type' => 'password', 'required' => true, @@ -22,6 +26,6 @@ ?> - Form->button(__d('cake_d_c/users', 'Submit')); ?> + Form->button(__d('cake_d_c/users', 'Submit'), ['id' => 'btn-submit']); ?> Form->end() ?> - \ No newline at end of file + diff --git a/templates/Users/profile.php b/templates/Users/profile.php index aa4759fb7..30aa97094 100644 --- a/templates/Users/profile.php +++ b/templates/Users/profile.php @@ -32,7 +32,7 @@

username) ?>

email) ?>

- User->socialConnectLinkList($user->social_accounts) ?> + User->socialConnectLinkList($user->social_accounts ?? []) ?> social_accounts)): ?> diff --git a/templates/Users/register.php b/templates/Users/register.php index 1216b4599..cad1cc40a 100644 --- a/templates/Users/register.php +++ b/templates/Users/register.php @@ -19,7 +19,10 @@ Form->control('username', ['label' => __d('cake_d_c/users', 'Username')]); echo $this->Form->control('email', ['label' => __d('cake_d_c/users', 'Email')]); - echo $this->Form->control('password', ['label' => __d('cake_d_c/users', 'Password')]); + echo $this->Form->control('password', ['label' => __d('cake_d_c/users', 'Password'), 'id' => 'new-password']); + if (Configure::read('Users.passwordMeter')) { + echo $this->User->addPasswordMeter(); + } echo $this->Form->control('password_confirm', [ 'required' => true, 'type' => 'password', @@ -35,6 +38,6 @@ } ?> - User->button(__d('cake_d_c/users', 'Submit')) ?> + User->button(__d('cake_d_c/users', 'Submit'), ['id' => 'btn-submit']) ?> Form->end() ?> diff --git a/tests/TestCase/Controller/Traits/Integration/PasswordManagementTraitIntegrationTest.php b/tests/TestCase/Controller/Traits/Integration/PasswordManagementTraitIntegrationTest.php index f7b082f13..6ddf61e93 100644 --- a/tests/TestCase/Controller/Traits/Integration/PasswordManagementTraitIntegrationTest.php +++ b/tests/TestCase/Controller/Traits/Integration/PasswordManagementTraitIntegrationTest.php @@ -80,7 +80,7 @@ public function testRequestResetPasswordPostValidEmail() $this->assertResponseContains('Please enter the new password'); $this->assertResponseContains('assertResponseContains('assertResponseContains(''); + $this->assertResponseContains(''); $this->post('/users/change-password', [ 'password' => '9080706050', diff --git a/tests/TestCase/Controller/Traits/Integration/RegisterTraitIntegrationTest.php b/tests/TestCase/Controller/Traits/Integration/RegisterTraitIntegrationTest.php index afa4a15bb..5e27ef5ea 100644 --- a/tests/TestCase/Controller/Traits/Integration/RegisterTraitIntegrationTest.php +++ b/tests/TestCase/Controller/Traits/Integration/RegisterTraitIntegrationTest.php @@ -43,13 +43,13 @@ public function testRegister() $this->assertResponseContains('Add User'); $this->assertResponseContains('assertResponseContains('assertResponseContains('assertResponseContains('assertResponseContains('assertResponseContains('assertResponseContains('assertResponseContains('assertResponseContains(''); - $this->assertResponseContains(''); + $this->assertResponseContains(''); } /** @@ -78,13 +78,13 @@ public function testRegisterPostWithErrors() $this->assertResponseContains('Add User'); $this->assertResponseContains('assertResponseContains('assertResponseContains('assertResponseContains('assertResponseContains('assertResponseContains('assertResponseContains('assertResponseContains('assertResponseContains(''); - $this->assertResponseContains(''); + $this->assertResponseContains(''); } /** diff --git a/tests/TestCase/Controller/Traits/Integration/SimpleCrudTraitIntegrationTest.php b/tests/TestCase/Controller/Traits/Integration/SimpleCrudTraitIntegrationTest.php index 6965f9c6a..9a35d74b3 100644 --- a/tests/TestCase/Controller/Traits/Integration/SimpleCrudTraitIntegrationTest.php +++ b/tests/TestCase/Controller/Traits/Integration/SimpleCrudTraitIntegrationTest.php @@ -83,7 +83,7 @@ public function testCrud() $this->assertResponseContains('
'); $this->assertResponseContains('assertResponseContains('assertResponseContains(''); + $this->assertResponseContains(''); $this->enableSecurityToken(); $this->post('/users/change-password/00000000-0000-0000-0000-000000000005', [ diff --git a/webroot/js/pswmeter.js b/webroot/js/pswmeter.js new file mode 100644 index 000000000..fa9a3c28b --- /dev/null +++ b/webroot/js/pswmeter.js @@ -0,0 +1,150 @@ +/** + * PSWMeter + * @author pascualmj + * @see https://github.com/pascualmj/pswmeter + */ + +/** + * + * @param opts + * @returns {{getScore: (function(): number), containerElement: HTMLElement}} + */ +function passwordStrengthMeter(opts) { + + // Add styles inside body + const customStyles = document.createElement('style') + document.body.prepend(customStyles) + customStyles.innerHTML = ` + ${opts.containerElement} { + height: ${opts.height || 4}px; + background-color: #eee; + position: relative; + overflow: hidden; + border-radius: ${opts.borderRadius ? opt.borderRadius.toString() : 2}px; + } + ${opts.containerElement} .password-strength-meter-score { + height: inherit; + width: 0%; + transition: .3s ease-in-out; + background: ${opts.colorScore1 || '#ff7700'}; + } + ${opts.containerElement} .password-strength-meter-score.psms-25 {width: 25%; background: ${opts.colorScore1 || '#ff7700'};} + ${opts.containerElement} .password-strength-meter-score.psms-50 {width: 50%; background: ${opts.colorScore2 || '#ffff00'};} + ${opts.containerElement} .password-strength-meter-score.psms-75 {width: 75%; background: ${opts.colorScore3 || '#aeff00'};} + ${opts.containerElement} .password-strength-meter-score.psms-100 {width: 100%; background: ${opts.colorScore4 || '#00ff00'};}` + + // Container Element + const containerElement = document.getElementById(opts.containerElement.slice(1)) + containerElement.classList.add('password-strength-meter') + + // Score Bar + let scoreBar = document.createElement('div') + scoreBar.classList.add('password-strength-meter-score') + + // Append score bar to container element + containerElement.appendChild(scoreBar) + + // Password input + const passwordInput = document.getElementById(opts.passwordInput.slice(1)) + let passwordInputValue = '' + passwordInput.addEventListener('keyup', function() { + passwordInputValue = this.value + checkPassword() + }) + + // Chosen Min Length + let pswMinLength = opts.pswMinLength || 8 + + // Score Message + let scoreMessage = opts.showMessage ? document.getElementById(opts.messageContainer.slice(1)) : null + let messagesList = opts.messagesList === undefined ? ['Empty password', 'Too simple', 'Simple', 'That\'s OK', 'Great password!'] : opts.messagesList + if (scoreMessage) { scoreMessage.textContent = messagesList[0] || 'Empty password'} + + // Check Password Function + function checkPassword() { + + let score = getScore() + updateScore(score) + + } + + // Get Score Function + function getScore() { + + let score = 0 + + let regexLower = new RegExp('(?=.*[a-z])') + let regexUpper = new RegExp('(?=.*[A-Z])') + let regexDigits = new RegExp('(?=.*[0-9])') + // For length score print user selection or default value + let regexLength = new RegExp('(?=.{' + pswMinLength + ',})') + + if (passwordInputValue.match(regexLower)) { ++score } + if (passwordInputValue.match(regexUpper)) { ++score } + if (passwordInputValue.match(regexDigits)) { ++score } + if (passwordInputValue.match(regexLength)) { ++score } + + if (score === 0 && passwordInputValue.length > 0) { ++score } + + return score + + } + + // Show Score Function + function updateScore(score) { + switch(score) { + case 1: + scoreBar.className = 'password-strength-meter-score psms-25' + if (scoreMessage) { scoreMessage.textContent = messagesList[1] || 'Too simple' } + containerElement.dispatchEvent(new Event('onScore1', { bubbles: true })) + break + case 2: + scoreBar.className = 'password-strength-meter-score psms-50' + if (scoreMessage) { scoreMessage.textContent = messagesList[2] || 'Simple' } + containerElement.dispatchEvent(new Event('onScore2', { bubbles: true })) + break + case 3: + scoreBar.className = 'password-strength-meter-score psms-75' + if (scoreMessage) { scoreMessage.textContent = messagesList[3] || 'That\'s OK' } + containerElement.dispatchEvent(new Event('onScore3', { bubbles: true })) + break + case 4: + scoreBar.className = 'password-strength-meter-score psms-100' + if (scoreMessage) { scoreMessage.textContent = messagesList[4] || 'Great password!' } + containerElement.dispatchEvent(new Event('onScore4', { bubbles: true })) + break + default: + scoreBar.className = 'password-strength-meter-score' + if (scoreMessage) { scoreMessage.textContent = messagesList[0] || 'No data' } + containerElement.dispatchEvent(new Event('onScore0', { bubbles: true })) + } + } + + // Return anonymous object with properties + return { + containerElement, + getScore + } + +} +window.addEventListener("load",init); +function init() { + // Run pswmeter with options + const myPassMeter = passwordStrengthMeter({ + containerElement: '#pswmeter', + passwordInput: '#new-password', + showMessage: true, + messageContainer: '#pswmeter-message' + }); + for (let i = 0; i < 4; i++) { + myPassMeter.containerElement.addEventListener('onScore' + i, function() { + document.getElementById("btn-submit").disabled = i < requiredScore; + }) + } + + document.getElementById("new-password").dispatchEvent(new Event("keyup")); + if (myPassMeter.getScore < requiredScore) { + document.getElementById("btn-submit").disabled = true; + } + +}