From 32006fb8f0358228e2a29448407da48d5930317a Mon Sep 17 00:00:00 2001 From: Steve Boyd Date: Thu, 12 Oct 2023 17:21:15 +1300 Subject: [PATCH] ENH Generate a random password if a blank password is entered --- lang/en.yml | 1 + src/Forms/ConfirmedPasswordField.php | 47 ++++++++++++++++--- src/Security/Member.php | 2 +- .../php/Forms/ConfirmedPasswordFieldTest.php | 21 +++++++++ 4 files changed, 63 insertions(+), 8 deletions(-) diff --git a/lang/en.yml b/lang/en.yml index fbd74a2c0a4..0af411be806 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.' + CANBEEMTPYRIGHT: 'If a password is not set then a hidden random password will be automatically generated. The user will need to click the "I''ve lost my password" link to receive a password reset email.' 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..128e40fe5dc 100644 --- a/src/Forms/ConfirmedPasswordField.php +++ b/src/Forms/ConfirmedPasswordField.php @@ -43,7 +43,8 @@ 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 will be created before saving * * @var boolean */ @@ -119,6 +120,8 @@ class ConfirmedPasswordField extends FormField */ protected $hiddenField = null; + private string $strongPasswordRegex = '/^(([a-zA-Z]+\d+)|(\d+[a-zA-Z]+))[a-zA-Z0-9]*$/'; + /** * @param string $name * @param string $title @@ -255,7 +258,15 @@ public function getChildren() public function setCanBeEmpty($value) { $this->canBeEmpty = (bool)$value; - + $text = _t( + __CLASS__ . '.CANBEEMTPYRIGHT', + 'If a password is not set then a hidden random password will be automatically generated. The user will need to click the "I\'ve lost my password" link to receive a password reset email.' + ); + if ($this->canBeEmpty) { + $this->passwordField->setRightTitle($text); + } elseif (!$this->canBeEmpty && $this->passwordField->RightTitle() === $text) { + $this->passwordField->setRightTitle(''); + } return $this; } @@ -488,7 +499,7 @@ public function validate($validator) } if ($this->getRequireStrongPassword()) { - if (!preg_match('/^(([a-zA-Z]+\d+)|(\d+[a-zA-Z]+))[a-zA-Z0-9]*$/', $value ?? '')) { + if (!preg_match($this->strongPasswordRegex, $value ?? '')) { $validator->validationError( $name, _t( @@ -552,8 +563,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 +571,33 @@ public function saveInto(DataObjectInterface $record) return; } - if (!($this->canBeEmpty && !$this->value)) { - parent::saveInto($record); + // Create a random password if password is blank + if ($this->canBeEmpty && !$this->value) { + $this->value = $this->createStrongRandomPassword(); } + + parent::saveInto($record); + } + + private function createStrongRandomPassword(): string + { + $value = ''; + do { + // random_int() in OK to use for random password generation + $randomInt = random_int(0, PHP_INT_MAX); + // add in a-f as extra possible characters + $hashed = substr(md5($randomInt), 0, $this->getMaxLength() ?: 20); + // uppercase half of the characters + $hashed = implode('', [ + strtoupper(substr($hashed, 0, floor(strlen($hashed) / 2))), + substr($hashed, ceil(strlen($hashed) / 2)), + ]); + // shuffle the characters + $arr = str_split($hashed); + shuffle($arr); + $value = implode('', $arr); + } while (!preg_match($this->strongPasswordRegex, $value)); + return $value; } /** diff --git a/src/Security/Member.php b/src/Security/Member.php index 2dffce485e2..9f6d838042f 100644 --- a/src/Security/Member.php +++ b/src/Security/Member.php @@ -670,7 +670,7 @@ public function getMemberPasswordField() $password->setRequireExistingPassword(true); } - $password->setCanBeEmpty(false); + $password->setCanBeEmpty(true); $this->extend('updateMemberPasswordField', $password); return $password; diff --git a/tests/php/Forms/ConfirmedPasswordFieldTest.php b/tests/php/Forms/ConfirmedPasswordFieldTest.php index d6eeb1f4597..8a9ea988ce5 100644 --- a/tests/php/Forms/ConfirmedPasswordFieldTest.php +++ b/tests/php/Forms/ConfirmedPasswordFieldTest.php @@ -381,4 +381,25 @@ public function testSetRequireExistingPasswordOnlyRunsOnce() $field->setRequireExistingPassword(false); $this->assertCount(2, $field->getChildren(), 'Current password field should not be removed'); } + + public function testSetCanBeEmptySaveInto() + { + $field = new ConfirmedPasswordField('Test', 'Change it'); + $field->setCanBeEmpty(true); + $this->assertEmpty($field->Value()); + $member = new Member(); + $field->saveInto($member); + $this->assertNotEmpty($field->Value()); + $expected = $field->getMaxLength() ?: 20; + $this->assertSame($expected, strlen($field->Value())); + } + + public function testSetCanBeEmptyRightTitle() + { + $field = new ConfirmedPasswordField('Test', 'Change it'); + $passwordField = $field->getPasswordField(); + $this->assertEmpty($passwordField->RightTitle()); + $field->setCanBeEmpty(true); + $this->assertNotEmpty($passwordField->RightTitle()); + } }