Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for reCaptcha v3 and Password Meter #1086

Merged
merged 2 commits into from
May 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Docs/Documentation/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
6 changes: 6 additions & 0 deletions config/users.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
99 changes: 87 additions & 12 deletions src/View/Helper/UserHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use Cake\Utility\Inflector;
use Cake\View\Helper;
use CakeDC\Users\Utility\UsersUrl;
use InvalidArgumentException;

/**
* User helper
Expand All @@ -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');
Expand Down Expand Up @@ -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 [];
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -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(
Expand All @@ -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',
Expand All @@ -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
*
Expand All @@ -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',
Expand All @@ -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 : '');
Expand Down Expand Up @@ -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 '';
Expand Down
8 changes: 6 additions & 2 deletions templates/Users/change_password.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,19 @@
<?= $this->Form->control('password', [
'type' => 'password',
'required' => true,
'id' => 'new-password',
'label' => __d('cake_d_c/users', 'New password')]);
?>
<?php if (\Cake\Core\Configure::read('Users.passwordMeter')) : ?>
<?= $this->User->addPasswordMeter() ?>
<?php endif; ?>
<?= $this->Form->control('password_confirm', [
'type' => 'password',
'required' => true,
'label' => __d('cake_d_c/users', 'Confirm password')]);
?>

</fieldset>
<?= $this->Form->button(__d('cake_d_c/users', 'Submit')); ?>
<?= $this->Form->button(__d('cake_d_c/users', 'Submit'), ['id' => 'btn-submit']); ?>
<?= $this->Form->end() ?>
</div>
</div>
2 changes: 1 addition & 1 deletion templates/Users/login.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,6 @@
?>
</fieldset>
<?= implode(' ', $this->User->socialLoginList()); ?>
<?= $this->Form->button(__d('cake_d_c/users', 'Login')); ?>
<?= $this->User->button(__d('cake_d_c/users', 'Login')); ?>
<?= $this->Form->end() ?>
</div>
2 changes: 1 addition & 1 deletion templates/Users/profile.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
<p><?= h($user->username) ?></p>
<h6 class="subheader"><?= __d('cake_d_c/users', 'Email') ?></h6>
<p><?= h($user->email) ?></p>
<?= $this->User->socialConnectLinkList($user->social_accounts) ?>
<?= $this->User->socialConnectLinkList($user->social_accounts ?? []) ?>
<?php
if (!empty($user->social_accounts)):
?>
Expand Down
7 changes: 5 additions & 2 deletions templates/Users/register.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@
<?php
echo $this->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',
Expand All @@ -35,6 +38,6 @@
}
?>
</fieldset>
<?= $this->Form->button(__d('cake_d_c/users', 'Submit')) ?>
<?= $this->User->button(__d('cake_d_c/users', 'Submit'), ['id' => 'btn-submit']) ?>
<?= $this->Form->end() ?>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ public function testRequestResetPasswordPostValidEmail()
$this->assertResponseContains('Please enter the new password');
$this->assertResponseContains('<input type="password" name="password" required="required"');
$this->assertResponseContains('<input type="password" name="password_confirm" required="required"');
$this->assertResponseContains('<button type="submit">Submit</button>');
$this->assertResponseContains('<button id="btn-submit" type="submit">Submit</button>');

$this->post('/users/change-password', [
'password' => '9080706050',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,13 @@ public function testRegister()
$this->assertResponseContains('<legend>Add User</legend>');
$this->assertResponseContains('<input type="text" name="username" required="required"');
$this->assertResponseContains('<input type="email" name="email" required="required"');
$this->assertResponseContains('<input type="password" name="password" required="required"');
$this->assertResponseContains('<input type="password" name="password" id="new-password" required="required"');
$this->assertResponseContains('<input type="password" name="password_confirm" required="required"');
$this->assertResponseContains('<input type="text" name="first_name" id="first-name" maxlength="50"');
$this->assertResponseContains('<input type="text" name="last_name" id="last-name" maxlength="50"');
$this->assertResponseContains('<input type="hidden" name="tos" value="0"');
$this->assertResponseContains('<label for="tos"><input type="checkbox" name="tos" value="1" required="required" id="tos" aria-required="true">Accept TOS conditions?</label>');
$this->assertResponseContains('<button type="submit">Submit</button>');
$this->assertResponseContains('<button id="btn-submit" type="submit">Submit</button>');
}

/**
Expand Down Expand Up @@ -78,13 +78,13 @@ public function testRegisterPostWithErrors()
$this->assertResponseContains('<legend>Add User</legend>');
$this->assertResponseContains('<input type="text" name="username" required="required"');
$this->assertResponseContains('<input type="email" name="email" required="required"');
$this->assertResponseContains('<input type="password" name="password" required="required"');
$this->assertResponseContains('<input type="password" name="password" id="new-password" required="required"');
$this->assertResponseContains('<input type="password" name="password_confirm" required="required"');
$this->assertResponseContains('<input type="text" name="first_name" id="first-name" value="" maxlength="50"');
$this->assertResponseContains('<input type="text" name="last_name" id="last-name" value="" maxlength="50"');
$this->assertResponseContains('<input type="hidden" name="tos" value="0"');
$this->assertResponseContains('<label for="tos"><input type="checkbox" name="tos" value="1" required="required" id="tos" aria-required="true">Accept TOS conditions?</label>');
$this->assertResponseContains('<button type="submit">Submit</button>');
$this->assertResponseContains('<button id="btn-submit" type="submit">Submit</button>');
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ public function testCrud()
$this->assertResponseContains('<form method="post" accept-charset="utf-8" action="/users/change-password/00000000-0000-0000-0000-000000000005">');
$this->assertResponseContains('<input type="password" name="password" required="required"');
$this->assertResponseContains('<input type="password" name="password_confirm" required="required"');
$this->assertResponseContains('<button type="submit">Submit</button>');
$this->assertResponseContains('<button id="btn-submit" type="submit">Submit</button>');

$this->enableSecurityToken();
$this->post('/users/change-password/00000000-0000-0000-0000-000000000005', [
Expand Down
54 changes: 54 additions & 0 deletions tests/TestCase/View/Helper/UserHelperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -237,6 +247,50 @@ public function testAddReCaptcha()
$this->assertEquals('<div class="g-recaptcha" data-sitekey="testKey" data-theme="light" data-size="normal" data-tabindex="3"></div>', $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
*
Expand Down
Loading
Loading