diff --git a/lang/en.yml b/lang/en.yml index fbd74a2c0a4..1b19e705cc0 100644 --- a/lang/en.yml +++ b/lang/en.yml @@ -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.' diff --git a/src/Forms/ConfirmedPasswordField.php b/src/Forms/ConfirmedPasswordField.php index ec5fabe22fe..e446df0c1c9 100644 --- a/src/Forms/ConfirmedPasswordField.php +++ b/src/Forms/ConfirmedPasswordField.php @@ -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. @@ -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. @@ -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; } @@ -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) @@ -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); } /** @@ -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); + } } diff --git a/src/Security/Member.php b/src/Security/Member.php index 2dffce485e2..ff4d2877439 100644 --- a/src/Security/Member.php +++ b/src/Security/Member.php @@ -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 @@ -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; @@ -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; + } } diff --git a/tests/php/Forms/ConfirmedPasswordFieldTest.php b/tests/php/Forms/ConfirmedPasswordFieldTest.php index d6eeb1f4597..6b14f290d67 100644 --- a/tests/php/Forms/ConfirmedPasswordFieldTest.php +++ b/tests/php/Forms/ConfirmedPasswordFieldTest.php @@ -11,6 +11,7 @@ use SilverStripe\Forms\RequiredFields; use SilverStripe\Security\Member; use SilverStripe\Security\PasswordValidator; +use Closure; class ConfirmedPasswordFieldTest extends SapphireTest { @@ -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()); + } } diff --git a/tests/php/Security/MemberTest.php b/tests/php/Security/MemberTest.php index a600190c074..4b4fd4a78ac 100644 --- a/tests/php/Security/MemberTest.php +++ b/tests/php/Security/MemberTest.php @@ -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]); + } }