Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NEW Generate a random password if a blank password is entered #10974

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved

/**
* 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.
*
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved
* @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
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved
{
$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);
}
}