diff --git a/lang/en.yml b/lang/en.yml index fbd74a2c0a4..06db670fd98 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. 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.' diff --git a/src/Forms/ConfirmedPasswordField.php b/src/Forms/ConfirmedPasswordField.php index ec5fabe22fe..1c4c34739d3 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 SilverStripe\Security\Member; /** * Two masked input fields, checks for matching passwords. @@ -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. @@ -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; } @@ -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) @@ -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); } /** @@ -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(''); + } + } } diff --git a/src/Security/Member.php b/src/Security/Member.php index 2dffce485e2..a773b94ece7 100644 --- a/src/Security/Member.php +++ b/src/Security/Member.php @@ -2,6 +2,7 @@ namespace SilverStripe\Security; +use Exception; use IntlDateFormatter; use InvalidArgumentException; use SilverStripe\Admin\LeftAndMain; @@ -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; @@ -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; + } } diff --git a/tests/php/Forms/ConfirmedPasswordFieldTest.php b/tests/php/Forms/ConfirmedPasswordFieldTest.php index d6eeb1f4597..4cd39663c3c 100644 --- a/tests/php/Forms/ConfirmedPasswordFieldTest.php +++ b/tests/php/Forms/ConfirmedPasswordFieldTest.php @@ -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()); + } } 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]); + } }