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 17, 2023
1 parent 87958e7 commit fbf27dd
Show file tree
Hide file tree
Showing 5 changed files with 187 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.'
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
69 changes: 64 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 Closure;

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

/**
* Allow empty fields in serverside validation
* Allow empty fields 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
*/
private ?Closure $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 +267,33 @@ 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(): ?Closure
{
return $this->randomPasswordCallback;
}

public function setRandomPasswordCallback(?Closure $randomPasswordCallback): static
{
$this->randomPasswordCallback = $randomPasswordCallback;
return $this;
}

Expand Down Expand Up @@ -552,8 +590,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 +598,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 +739,18 @@ 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.'
);
$rightTitle = $this->passwordField->RightTitle() ?? '';
$rightTitle = trim(str_replace($text, '', $rightTitle));
if ($this->canBeEmpty && $this->generateRandomPasswordOnEmpty) {
$rightTitle = $text . ' ' . $rightTitle;
}
$this->passwordField->setRightTitle($rightTitle ?: null);
}
}
59 changes: 58 additions & 1 deletion src/Security/Member.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
use SilverStripe\ORM\ValidationException;
use SilverStripe\ORM\ValidationResult;
use Symfony\Component\Mailer\MailerInterface;
use Closure;
use RuntimeException;

/**
* The member class which represents the users of the system
Expand Down Expand Up @@ -670,7 +672,14 @@ public function getMemberPasswordField()
$password->setRequireExistingPassword(true);
}

$password->setCanBeEmpty(false);
if (!$editingPassword) {
$password->setCanBeEmpty(true);
$password->setGenerateRandomPasswordOnEmpty(true);
$password->setRandomPasswordCallback(Closure::fromCallable([__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 +1711,52 @@ 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');
}
}
// Create a dummy member to test the password validator against
// Use a dummy member because this is intended to be used in a context where an Administrator
// is created a branch new member who doesn't yet have an ID
$dummyMember = Member::create();
// These are similar character sets that used in the default PasswordValidator
$charsets = [
'abcdefghijklmnopqrstuvwyxz',
'ABCDEFGHIJKLMNOPQRSTUVWYXZ',
'0123456789',
'!@#$%^&*()_+-=[]{};:,./<>?',
];
$n = 0;
do {
$valid = true;
$password = '';
for ($i = 0; $i < $maxLength; $i++) {
$charset = $charsets[$i % 4];
$randomInt = random_int(0, strlen($charset) - 1);
$password .= $charset[$randomInt];
}
$password = str_shuffle($password);
if ($validator && !$validator->validate($password, $dummyMember)) {
$valid = false;
}
if (++$n === 100) {
// protection against infinite while loop
throw new RuntimeException('Unable to generate a random password');
}
} while (!$valid);
return $password;
}
}
43 changes: 43 additions & 0 deletions tests/php/Forms/ConfirmedPasswordFieldTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use SilverStripe\Forms\RequiredFields;
use SilverStripe\Security\Member;
use SilverStripe\Security\PasswordValidator;
use Closure;

class ConfirmedPasswordFieldTest extends SapphireTest
{
Expand Down Expand Up @@ -381,4 +382,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(Closure::fromCallable(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 fbf27dd

Please sign in to comment.