Skip to content

Commit

Permalink
Merge pull request #10974 from creative-commoners/pulls/5/random-pass…
Browse files Browse the repository at this point in the history
…word

NEW Generate a random password if a blank password is entered
  • Loading branch information
GuySartorelli authored Oct 19, 2023
2 parents cbd358a + 159112c commit 64e2b5e
Show file tree
Hide file tree
Showing 5 changed files with 195 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
66 changes: 61 additions & 5 deletions src/Forms/ConfirmedPasswordField.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

namespace SilverStripe\Forms;

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 +45,20 @@ 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;

/**
* Callback used to generate a random password if $this->canBeEmpty is true and the field is left blank
* If this is set to null then a random password will not be generated
*/
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 +265,27 @@ public function getChildren()
public function setCanBeEmpty($value)
{
$this->canBeEmpty = (bool)$value;
$this->updateRightTitle();
return $this;
}

/**
* Gets the callback used to generate a random password
*/
public function getRandomPasswordCallback(): ?Closure
{
return $this->randomPasswordCallback;
}

/**
* Sets a callback used to generate a random password if canBeEmpty is set to true
* and the password field is left blank
* If this is set to null then a random password will not be generated
*/
public function setRandomPasswordCallback(?Closure $callback): static
{
$this->randomPasswordCallback = $callback;
$this->updateRightTitle();
return $this;
}

Expand Down Expand Up @@ -552,17 +582,26 @@ public function validate($validator)
}

/**
* Only save if field was shown on the client, and is not empty.
*
* @param DataObjectInterface $record
* Only save if field was shown on the client, and is not empty or random password generation is enabled
*/
public function saveInto(DataObjectInterface $record)
{
if (!$this->isSaveable()) {
return;
}

if (!($this->canBeEmpty && !$this->value)) {
// Create a random password if password is blank and the flag is set
if (!$this->value
&& $this->canBeEmpty
&& $this->randomPasswordCallback
) {
if (!is_callable($this->randomPasswordCallback)) {
throw new LogicException('randomPasswordCallback must be callable');
}
$this->value = call_user_func_array($this->randomPasswordCallback, [$this->maxLength ?: 0]);
}

if ($this->value || $this->canBeEmtpy) {
parent::saveInto($record);
}
}
Expand Down Expand Up @@ -694,4 +733,21 @@ public function getRequireStrongPassword()
{
return $this->requireStrongPassword;
}

/**
* Appends a warning to the right title, or removes that appended warning.
*/
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->randomPasswordCallback) {
$rightTitle = $text . ' ' . $rightTitle;
}
$this->passwordField->setRightTitle($rightTitle ?: null);
}
}
57 changes: 56 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,13 @@ public function getMemberPasswordField()
$password->setRequireExistingPassword(true);
}

$password->setCanBeEmpty(false);
if (!$editingPassword) {
$password->setCanBeEmpty(true);
$password->setRandomPasswordCallback(Closure::fromCallable([$this, '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,51 @@ public function getHtmlEditorConfigForCMS()
// If can't find a suitable editor, just default to cms
return $currentName ? $currentName : 'cms';
}

/**
* Generate a random password and validate it against the current password validator if one is set
*
* @param int $length The length of the password to generate, defaults to 0 which will use the
* greater of the validator's minimum length or 20
*/
public function generateRandomPassword(int $length = 0): string
{
$password = '';
$validator = self::password_validator();
if ($length && $validator && $length < $validator->getMinLength()) {
throw new InvalidArgumentException('length argument is less than password validator minLength');
}
$validatorMinLength = $validator ? $validator->getMinLength() : 0;
$len = $length ?: max($validatorMinLength, 20);
// The default PasswordValidator checks the password includes the following four character sets
$charsets = [
'abcdefghijklmnopqrstuvwyxz',
'ABCDEFGHIJKLMNOPQRSTUVWYXZ',
'0123456789',
'!@#$%^&*()_+-=[]{};:,./<>?',
];
$password = '';
for ($i = 0; $i < $len; $i++) {
$charset = $charsets[$i % 4];
$randomInt = random_int(0, strlen($charset) - 1);
$password .= $charset[$randomInt];
}
// randomise the order of the characters
$passwordArr = [];
$len = strlen($password);
foreach (str_split($password) as $char) {
$r = random_int(0, $len + 10000);
while (array_key_exists($r, $passwordArr)) {
$r++;
}
$passwordArr[$r] = $char;
}
ksort($passwordArr);
$password = implode('', $passwordArr);
$this->extend('updateRandomPassword', $password);
if ($validator && !$validator->validate($password, $this)) {
throw new RuntimeException('Unable to generate a random password');
}
return $password;
}
}
46 changes: 46 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,49 @@ 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);
if ($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->setRandomPasswordCallback(Closure::fromCallable(function () {
return 'R4ndom-P4ssw0rd$LOREM^ipsum#12345';
}));
$this->assertNotEmpty($passwordField->RightTitle());
}
}
31 changes: 31 additions & 0 deletions tests/php/Security/MemberTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1896,4 +1896,35 @@ public function provideMapInCMSGroups()
],
];
}

public function testGenerateRandomPassword()
{
$member = new Member();
// no password validator
Member::set_password_validator(null);
// password length is same as length argument
$password = $member->generateRandomPassword(5);
$this->assertSame(5, strlen($password));
// default to 20 if not length argument
$password = $member->generateRandomPassword();
$this->assertSame(20, strlen($password));
// password validator
$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 length argument, and validator minlength is less than length argument
$password = $member->generateRandomPassword(25);
$this->assertSame(25, strlen($password));
// Password length is validator minLength if validator minLength is greater than 20 and no length argument
$validator->setMinLength(30);
$password = $member->generateRandomPassword();
$this->assertSame(30, strlen($password));
// Exception throw if length argument is less than validator minLength
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('length argument is less than password validator minLength');
$password = $member->generateRandomPassword(15);
}
}

0 comments on commit 64e2b5e

Please sign in to comment.