Skip to content

Commit

Permalink
NEW Generate a random password if a blank password is entered
Browse files Browse the repository at this point in the history
  • Loading branch information
emteknetnz committed Oct 16, 2023
1 parent 87958e7 commit a6f581d
Show file tree
Hide file tree
Showing 5 changed files with 186 additions and 6 deletions.
1 change: 1 addition & 0 deletions lang/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ en:
SilverStripe\Forms\ConfirmedPasswordField:
ATLEAST: 'Passwords must be at least {min} characters long.'
BETWEEN: 'Passwords must be {min} to {max} characters long.'
RANDOM_IF_EMPTY: 'If this is left blank then a random password will be automatically generated. The user will need to click the forgotten password link to generate a reset password email so they can set their own password.'
CURRENT_PASSWORD_ERROR: 'The current password you have entered is not correct.'
CURRENT_PASSWORD_MISSING: 'You must enter your current password.'
LOGGED_IN_ERROR: 'You must be logged in to change your password.'
Expand Down
76 changes: 71 additions & 5 deletions src/Forms/ConfirmedPasswordField.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

namespace SilverStripe\Forms;

use Exception;
use LogicException;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DataObjectInterface;
use SilverStripe\Security\Authenticator;
use SilverStripe\Security\Security;
use SilverStripe\View\HTML;
use SilverStripe\Security\Member;

/**
* Two masked input fields, checks for matching passwords.
Expand Down Expand Up @@ -43,12 +46,26 @@ class ConfirmedPasswordField extends FormField
public $requireStrongPassword = false;

/**
* Allow empty fields in serverside validation
* Allow empty fields in when entering the password for the first time
* If this is set to true then a random password may be generated if the field is empty
* depending on the value of $self::generateRandomPasswordOnEmtpy
*
* @var boolean
*/
public $canBeEmpty = false;

private bool $generateRandomPasswordOnEmpty = false;

/**
* Callback to generate a random password
*
* Note: cannot strongly type a callable property so need to use docblock typing instead
* https://wiki.php.net/rfc/typed_properties_v2
*
* @var callable|null
*/
private $randomPasswordCallback = null;

/**
* If set to TRUE, the "password" and "confirm password" form fields will
* be hidden via CSS and JavaScript by default, and triggered by a link.
Expand Down Expand Up @@ -255,7 +272,36 @@ public function getChildren()
public function setCanBeEmpty($value)
{
$this->canBeEmpty = (bool)$value;
$this->updateRightTitle();
return $this;
}

public function getGenerateRandomPasswordOnEmpty(): bool
{
return $this->generateRandomPasswordOnEmpty;
}

public function setGenerateRandomPasswordOnEmpty(bool $generateRandomPasswordOnEmpty): static
{
$this->generateRandomPasswordOnEmpty = $generateRandomPasswordOnEmpty;
$this->updateRightTitle();
return $this;
}

/**
* @return callable|null
*/
public function getRandomPasswordCallback()
{
return $this->randomPasswordCallback;
}

/**
* @param callable|null $randomPasswordCallback
*/
public function setRandomPasswordCallback($randomPasswordCallback): static
{
$this->randomPasswordCallback = $randomPasswordCallback;
return $this;
}

Expand Down Expand Up @@ -552,8 +598,6 @@ public function validate($validator)
}

/**
* Only save if field was shown on the client, and is not empty.
*
* @param DataObjectInterface $record
*/
public function saveInto(DataObjectInterface $record)
Expand All @@ -562,9 +606,18 @@ public function saveInto(DataObjectInterface $record)
return;
}

if (!($this->canBeEmpty && !$this->value)) {
parent::saveInto($record);
// Create a random password for Member if password is blank
if (!$this->value
&& $this->canBeEmpty
&& $this->generateRandomPasswordOnEmpty
) {
if (!is_callable($this->randomPasswordCallback)) {
throw new LogicException('randomPasswordCallback must be callable');
}
$this->value = call_user_func($this->randomPasswordCallback, ['maxLength' => $this->maxLength]);
}

parent::saveInto($record);
}

/**
Expand Down Expand Up @@ -694,4 +747,17 @@ public function getRequireStrongPassword()
{
return $this->requireStrongPassword;
}

private function updateRightTitle(): void
{
$text = _t(
__CLASS__ . '.RANDOM_IF_EMPTY',
'If this is left blank then a random password will be automatically generated. The user will need to click the forgotten password link to generate a reset password email so they can set their own password.'
);
if ($this->canBeEmpty && $this->generateRandomPasswordOnEmpty) {
$this->passwordField->setRightTitle($text);
} elseif ($this->passwordField->RightTitle() === $text) {
$this->passwordField->setRightTitle('');
}
}
}
52 changes: 51 additions & 1 deletion src/Security/Member.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace SilverStripe\Security;

use Exception;
use IntlDateFormatter;
use InvalidArgumentException;
use SilverStripe\Admin\LeftAndMain;
Expand Down Expand Up @@ -670,7 +671,14 @@ public function getMemberPasswordField()
$password->setRequireExistingPassword(true);
}

$password->setCanBeEmpty(false);
if (!$editingPassword) {
$password->setCanBeEmpty(true);
$password->setGenerateRandomPasswordOnEmpty(true);
$password->setRandomPasswordCallback([__CLASS__, 'generateRandomPassword']);
// explicitly set "require strong password" to false because its regex in ConfirmedPasswordField
// is too restrictive for generateRandomPassword() which will add in non-alphanumeric characters
$password->setRequireStrongPassword(false);
}
$this->extend('updateMemberPasswordField', $password);

return $password;
Expand Down Expand Up @@ -1702,4 +1710,46 @@ public function getHtmlEditorConfigForCMS()
// If can't find a suitable editor, just default to cms
return $currentName ? $currentName : 'cms';
}

/**
* This needs to be public because it's referenced in getMemberPasswordField() and called in ConfirmedPasswordField
*/
public static function generateRandomPassword(array $args = []): string
{
$password = '';
$validator = self::password_validator();
$minLength = $validator ? $validator->getMinLength() : 20;
$maxLength = $args['maxLength'] ?? 0;
if ($maxLength === 0) {
$maxLength = max($minLength, 20);
} else {
if ($maxLength < $minLength) {
throw new InvalidArgumentException('maxLength argument is less than PasswordValidator minLength');
}
}
$dummyMember = new Member();
// These are similar character sets that used in the default PasswordValidator
$lc = 'abcdefghijklmnopqrstuvwyxz';
$uc = strtoupper($lc);
$num = '0123456789';
$pun = '!@#$%^&*()_+-=[]{};:,./<>?';
$chars = $lc . $uc . $num . $pun;
$c = 0;
do {
$valid = true;
$password = '';
for ($i = 0; $i < $maxLength; $i++) {
$randomInt = random_int(0, strlen($chars) - 1);
$password .= $chars[$randomInt];
}
if ($validator && !$validator->validate($password, $dummyMember)) {
$valid = false;
}
if (++$c === 100) {
// protection against infinite while loop
throw new Exception('Unable to generate a random password');
}
} while (!$valid);
return $password;
}
}
42 changes: 42 additions & 0 deletions tests/php/Forms/ConfirmedPasswordFieldTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -381,4 +381,46 @@ public function testSetRequireExistingPasswordOnlyRunsOnce()
$field->setRequireExistingPassword(false);
$this->assertCount(2, $field->getChildren(), 'Current password field should not be removed');
}

/**
* @dataProvider provideSetCanBeEmptySaveInto
*/
public function testSetCanBeEmptySaveInto(bool $generateRandomPasswordOnEmpty, ?string $expected)
{
$field = new ConfirmedPasswordField('Test', 'Change it');
$field->setCanBeEmpty(true);
$field->setGenerateRandomPasswordOnEmpty($generateRandomPasswordOnEmpty);
$field->setRandomPasswordCallback(function () {
return 'R4ndom-P4ssw0rd$LOREM^ipsum#12345';
});
$this->assertEmpty($field->Value());
$member = new Member();
$field->saveInto($member);
$this->assertSame($expected, $field->Value());
}

public function provideSetCanBeEmptySaveInto(): array
{
return [
[
'generateRandomPasswordOnEmpty' => true,
'expected' => 'R4ndom-P4ssw0rd$LOREM^ipsum#12345',
],
[
'generateRandomPasswordOnEmpty' => false,
'expected' => null,
],
];
}

public function testSetCanBeEmptyRightTitle()
{
$field = new ConfirmedPasswordField('Test', 'Change it');
$passwordField = $field->getPasswordField();
$this->assertEmpty($passwordField->RightTitle());
$field->setCanBeEmpty(true);
$this->assertEmpty($passwordField->RightTitle());
$field->setGenerateRandomPasswordOnEmpty(true);
$this->assertNotEmpty($passwordField->RightTitle());
}
}
21 changes: 21 additions & 0 deletions tests/php/Security/MemberTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1896,4 +1896,25 @@ public function provideMapInCMSGroups()
],
];
}

public function testGenerateRandomPassword()
{
$validator = new PasswordValidator();
Member::set_password_validator($validator);
// Password length of 20 even if validator minLength is less than 20
$validator->setMinLength(10);
$password = Member::generateRandomPassword();
$this->assertSame(20, strlen($password));
// Password length of 25 if passing maxLength argument, and validator minlength is less than maxLength argument
$password = Member::generateRandomPassword(['maxLength' => 25]);
$this->assertSame(25, strlen($password));
// Password length is validator minLength if validator minLength is greater than 20 and no maxLength argument
$validator->setMinLength(30);
$password = Member::generateRandomPassword();
$this->assertSame(30, strlen($password));
// Exception throw if maxLength argument is less than validator minLength
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('maxLength argument is less than PasswordValidator minLength');
$password = Member::generateRandomPassword(['maxLength' => 25]);
}
}

0 comments on commit a6f581d

Please sign in to comment.