diff --git a/Docs/Documentation/Configuration.md b/Docs/Documentation/Configuration.md index 01b960aa4..64ee6a8b6 100644 --- a/Docs/Documentation/Configuration.md +++ b/Docs/Documentation/Configuration.md @@ -54,6 +54,7 @@ and add this to your config/users.php file: ```php 'Users.reCaptcha.key' => 'YOUR RECAPTCHA KEY', 'Users.reCaptcha.secret' => 'YOUR RECAPTCHA SECRET', +'Users.reCaptcha.version' => '2', //defaults to version 2 (backward compatibility) but you can use version 3 which is recommended 'Users.reCaptcha.registration' => true, //enable on registration 'Users.reCaptcha.login' => true, //enable on login ``` diff --git a/config/users.php b/config/users.php index 3f347e18e..860508b0a 100644 --- a/config/users.php +++ b/config/users.php @@ -71,11 +71,17 @@ 'key' => null, // reCaptcha secret 'secret' => null, + // reCaptcha version. keep 2 for backward compatibility + 'version' => 2, // use reCaptcha in registration 'registration' => false, // 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 691ec0fce..dd3d500e1 100644 --- a/src/View/Helper/UserHelper.php +++ b/src/View/Helper/UserHelper.php @@ -18,6 +18,7 @@ use Cake\Utility\Inflector; use Cake\View\Helper; use CakeDC\Users\Utility\UsersUrl; +use InvalidArgumentException; /** * User helper @@ -42,7 +43,7 @@ class UserHelper extends Helper * @param array $options options * @return string */ - public function socialLogin($name, $options = []) + public function socialLogin(string $name, array $options = []): string { if (empty($options['label'])) { $options['label'] = __d('cake_d_c/users', 'Sign in with'); @@ -74,7 +75,7 @@ public function socialLogin($name, $options = []) * @param array $providerOptions Provider link options. * @return array Links to Social Login Urls */ - public function socialLoginList(array $providerOptions = []) + public function socialLoginList(array $providerOptions = []): array { if (!Configure::read('Users.Social.login')) { return []; @@ -105,7 +106,7 @@ public function socialLoginList(array $providerOptions = []) * @param array $options Array with option data. * @return string */ - public function logout($message = null, $options = []) + public function logout(?string $message = null, array $options = []): string { $url = UsersUrl::actionUrl('logout'); $title = empty($message) ? __d('cake_d_c/users', 'Logout') : $message; @@ -118,7 +119,7 @@ public function logout($message = null, $options = []) * * @return string|null */ - public function welcome() + public function welcome(): ?string { $identity = $this->getView()->getRequest()->getAttribute('identity'); if (!$identity) { @@ -143,19 +144,42 @@ public function welcome() * * @return void */ - public function addReCaptchaScript() + public function addReCaptchaScript(): void { $this->Html->script('https://www.google.com/recaptcha/api.js', [ 'block' => 'script', ]); } + /** + * @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 * * @return mixed */ - public function addReCaptcha() + public function addReCaptcha(): mixed { if (!Configure::read('Users.reCaptcha.key')) { return $this->Html->tag( @@ -167,10 +191,29 @@ public function addReCaptcha() ); } $this->addReCaptchaScript(); - try { - $this->Form->unlockField('g-recaptcha-response'); - } catch (\Exception $e) { + $version = Configure::read('Users.reCaptcha.version', 2); + $method = "addReCaptchaV$version"; + if (method_exists($this, $method)) { + try { + $this->Form->unlockField('g-recaptcha-response'); + } catch (\Exception $e) { + } + + return $this->{$method}(); } + throw new InvalidArgumentException( + __d('cake_d_c/users', 'reCaptcha version is wrong. Please configure Users.reCaptcha.version as 2 or 3') + ); + } + + /** + * Add required element for reCaptcha v2 + * + * @return string + */ + private function addReCaptchaV2(): string + { + deprecationWarning('14.2.0', 'reCaptcha version 3 will be used as default in version 15.0.0'); return $this->Html->tag('div', '', [ 'class' => 'g-recaptcha', @@ -181,6 +224,38 @@ public function addReCaptcha() ]); } + /** + * Add required script for reCaptcha v3 + */ + private function addReCaptchaV3(): void + { + $this->Html->script('CakeDC/Users.reCaptchaV3', [ + 'block' => 'script', + ]); + } + + /** + * Add required options for reCaptcha v3 + * + * @param string $title + * @param array $options + * @return string + */ + public function button(string $title, array $options = []): string + { + $key = Configure::read('Users.reCaptcha.key'); + if ($key && Configure::read('Users.reCaptcha.version', 2) === 3) { + $options = array_merge($options, [ + 'class' => 'g-recaptcha', + 'data-sitekey' => $key, + 'data-callback' => 'onSubmit', + 'data-action' => 'submit', + ]); + } + + return $this->Form->button($title, $options); + } + /** * Generate a link if the target url is authorized for the logged in user * @@ -190,7 +265,7 @@ public function addReCaptcha() * @param array $options Array with option data. * @return string */ - public function link($title, $url = null, array $options = []) + public function link(string $title, array|string|null $url = null, array $options = []): string { trigger_error( 'UserHelper::link() deprecated since 3.2.1. Use AuthLinkHelper::link() instead', @@ -208,7 +283,7 @@ public function link($title, $url = null, array $options = []) * @param bool $isConnected User is connected with this provider * @return string */ - public function socialConnectLink($name, $provider, $isConnected = false) + public function socialConnectLink(string $name, array $provider, bool $isConnected = false): string { $optionClass = $provider['options']['class'] ?? null; $linkClass = 'btn btn-social btn-' . strtolower($name) . ($optionClass ? ' ' . $optionClass : ''); @@ -236,7 +311,7 @@ public function socialConnectLink($name, $provider, $isConnected = false) * @param array $socialAccounts All social accounts connected by a user. * @return string */ - public function socialConnectLinkList($socialAccounts = []) + public function socialConnectLinkList(array $socialAccounts = []): string { if (!Configure::read('Users.Social.login')) { return ''; 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/login.php b/templates/Users/login.php index 99aaf4edd..2df2dab9d 100644 --- a/templates/Users/login.php +++ b/templates/Users/login.php @@ -45,6 +45,6 @@ ?> User->socialLoginList()); ?> - Form->button(__d('cake_d_c/users', 'Login')); ?> + User->button(__d('cake_d_c/users', 'Login')); ?> Form->end() ?> 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 e82673b67..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 @@ } ?> - Form->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/tests/TestCase/View/Helper/UserHelperTest.php b/tests/TestCase/View/Helper/UserHelperTest.php index a277ec50a..fb639330b 100644 --- a/tests/TestCase/View/Helper/UserHelperTest.php +++ b/tests/TestCase/View/Helper/UserHelperTest.php @@ -51,6 +51,16 @@ class UserHelperTest extends TestCase */ private $AuthLink; + /** + * @var (\Cake\View\View&\PHPUnit\Framework\MockObject\MockObject)|\PHPUnit\Framework\MockObject\MockObject + */ + private $View; + + /** + * @var ServerRequest + */ + private $request; + /** * setUp method * @@ -237,6 +247,50 @@ public function testAddReCaptcha() $this->assertEquals('
', $result); } + /** + * Test add ReCaptcha V3 + * + * @return void + */ + public function testAddReCaptchaV3() + { + $this->View->expects($this->exactly(2)) + ->method('append') + ->willReturnMap([ + ['https://www.google.com/recaptcha/api.js', null], + ['CakeDC/Users.reCaptchaV3', null], + ]); + Configure::write('Users.reCaptcha.key', 'testKey'); + Configure::write('Users.reCaptcha.version', 3); + Configure::write('Users.reCaptcha.theme', 'light'); + Configure::write('Users.reCaptcha.size', 'normal'); + Configure::write('Users.reCaptcha.tabindex', '3'); + $this->User->Form->create(); + $this->User->addReCaptcha(); + } + + public function testButton() + { + $title = 'test'; + $options = ['test' => 'test']; + $this->assertEquals($this->User->Form->button($title, $options), $this->User->button($title, $options)); + } + + public function testButtonReCaptchaV3() + { + Configure::write('Users.reCaptcha.key', 'testKey'); + Configure::write('Users.reCaptcha.version', 3); + $title = 'test'; + $options = ['test' => 'test']; + $reCaptchaOptions = [ + 'class' => 'g-recaptcha', + 'data-sitekey' => 'testKey', + 'data-callback' => 'onSubmit', + 'data-action' => 'submit', + ]; + $this->assertEquals($this->User->Form->button($title, array_merge($options, $reCaptchaOptions)), $this->User->button($title, $options)); + } + /** * Test add ReCaptcha field * 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; + } + +} diff --git a/webroot/js/reCaptchaV3.js b/webroot/js/reCaptchaV3.js new file mode 100644 index 000000000..42d4d65e7 --- /dev/null +++ b/webroot/js/reCaptchaV3.js @@ -0,0 +1,3 @@ +function onSubmit(token) { + document.forms[0].submit() +}