diff --git a/src/Security/Member.php b/src/Security/Member.php index 1e3262943..c408d7e67 100644 --- a/src/Security/Member.php +++ b/src/Security/Member.php @@ -13,6 +13,7 @@ use SilverStripe\Core\Config\Config; use SilverStripe\Core\Convert; use SilverStripe\Core\Injector\Injector; use SilverStripe\Dev\TestMailer; +use SilverStripe\Forms\CheckboxField; use SilverStripe\Forms\ConfirmedPasswordField; use SilverStripe\Forms\DropdownField; use SilverStripe\Forms\FieldList; @@ -398,7 +399,40 @@ class Member extends DataObject return null; } - public function isPasswordExpired() + /** + * Used to get the value for the reset password on next login checkbox + */ + public function getRequiresPasswordChangeOnNextLogin(): bool + { + return $this->isPasswordExpired(); + } + + /** + * Set password expiry to "now" to require a change of password next log in + * + * @param int|null $dataValue 1 is checked, 0/null is not checked {@see CheckboxField::dataValue} + */ + public function saveRequiresPasswordChangeOnNextLogin(?int $dataValue): static + { + if (!$this->canEdit()) { + return $this; + } + + $currentValue = $this->PasswordExpiry; + $currentDate = $this->dbObject('PasswordExpiry'); + + if ($dataValue && (!$currentValue || $currentDate->inFuture())) { + // Only alter future expiries - this way an admin could see how long ago a password expired still + $this->PasswordExpiry = DBDatetime::now()->Rfc2822(); + } elseif (!$dataValue && $this->isPasswordExpired()) { + // Only unset if the expiry date is in the past + $this->PasswordExpiry = null; + } + + return $this; + } + + public function isPasswordExpired(): bool { if (!$this->PasswordExpiry) { return false; @@ -1363,6 +1397,19 @@ class Member extends DataObject if ($permissionsTab) { $permissionsTab->addExtraClass('readonly'); } + + $currentUser = Security::getCurrentUser(); + // We can allow an admin to require a user to change their password. But: + // - Don't show a read only field if the user cannot edit this record + // - Don't show if a user views their own profile (just let them reset their own password) + if ($currentUser && ($currentUser->ID !== $this->ID) && $this->canEdit()) { + $requireNewPassword = CheckboxField::create( + 'RequiresPasswordChangeOnNextLogin', + _t(__CLASS__ . '.RequiresPasswordChangeOnNextLogin', 'Requires password change on next log in') + ); + $fields->insertAfter('Password', $requireNewPassword); + $fields->dataFieldByName('Password')->addExtraClass('form-field--no-divider mb-0 pb-0'); + } }); return parent::getCMSFields(); diff --git a/tests/php/Security/MemberTest.php b/tests/php/Security/MemberTest.php index 18aa85174..7c31075fe 100644 --- a/tests/php/Security/MemberTest.php +++ b/tests/php/Security/MemberTest.php @@ -7,6 +7,9 @@ use SilverStripe\Core\Config\Config; use SilverStripe\Core\Convert; use SilverStripe\Core\Injector\Injector; use SilverStripe\Dev\FunctionalTest; +use SilverStripe\Forms\CheckboxField; +use SilverStripe\Forms\FieldList; +use SilverStripe\Forms\Form; use SilverStripe\Forms\ListboxField; use SilverStripe\i18n\i18n; use SilverStripe\ORM\DataObject; @@ -402,6 +405,161 @@ class MemberTest extends FunctionalTest $this->assertFalse($member->isPasswordExpired()); } + public function testAdminCanRequirePasswordChangeOnNextLogIn() + { + /** @var Member&MemberExtension $targetMember */ + $targetMember = $this->objFromFixture(Member::class, 'someone'); + $this->logInWithPermission('ADMIN'); + $field = $targetMember->getCMSFields()->dataFieldByName('RequiresPasswordChangeOnNextLogin'); + $this->assertNotNull($field); + $this->assertInstanceOf(CheckboxField::class, $field, 'The field should be an instance of ' . CheckboxField::class); + } + + public function testUserCannotRequireTheirOwnPasswordChangeOnNextLogIn() + { + /** @var Member&MemberExtension $targetMember */ + $targetMember = $this->objFromFixture(Member::class, 'someone'); + $this->logInAs($targetMember); + $field = $targetMember->getCMSFields()->dataFieldByName('RequiresPasswordChangeOnNextLogin'); + $this->assertNull($field); + } + + public function testUserCannotRequireOthersToPasswordChangeOnNextLogIn() + { + /** @var Member&MemberExtension $targetMember */ + $targetMember = $this->objFromFixture(Member::class, 'anyone'); + $this->logInAs('someone'); + $field = $targetMember->getCMSFields()->dataFieldByName('RequiresPasswordChangeOnNextLogin'); + $this->assertNull($field); + } + + public function testCheckingRequiresPasswordChangeOnNextLoginWillSetPasswordExpiryToNow() + { + $mockDate = '2019-03-02 00:00:00'; + DBDateTime::set_mock_now($mockDate); + + /** @var Member&MemberExtension $targetMember */ + $targetMember = $this->objFromFixture(Member::class, 'someone'); + + $this->assertNull($targetMember->PasswordExpiry); + + $this->logInWithPermission('ADMIN'); + $fields = $targetMember->getCMSFields(); + $form = new Form(null, 'SomeForm', $fields, new FieldList()); + $field = $fields->dataFieldByName('RequiresPasswordChangeOnNextLogin'); + $field->setValue(1); + $form->saveInto($targetMember); + + $this->assertEquals($mockDate, $targetMember->PasswordExpiry); + } + + public function testCheckingPasswordChangeUpdatesFutureExpiriesToNow() + { + $mockDate = '2019-03-02 00:00:00'; + DBDateTime::set_mock_now($mockDate); + + /** @var Member&MemberExtension $targetMember */ + $targetMember = $this->objFromFixture(Member::class, 'willexpire'); + + $this->assertTrue($targetMember->dbObject('PasswordExpiry')->inFuture()); + + $this->logInWithPermission('ADMIN'); + $fields = $targetMember->getCMSFields(); + $form = new Form(null, 'SomeForm', $fields, new FieldList()); + $field = $fields->dataFieldByName('RequiresPasswordChangeOnNextLogin'); + $field->setValue(1); + $form->saveInto($targetMember); + + $this->assertEquals($mockDate, $targetMember->PasswordExpiry); + } + + public function testCheckingPasswordChangeDoesNotAlterPastDates() + { + $mockDate = '2019-03-02 00:00:00'; + DBDateTime::set_mock_now($mockDate); + + /** @var Member&MemberExtension $targetMember */ + $targetMember = $this->objFromFixture(Member::class, 'expired'); + $originalValue = $targetMember->PasswordExpiry; + + $this->assertTrue($targetMember->dbObject('PasswordExpiry')->inPast()); + + $this->logInWithPermission('ADMIN'); + $fields = $targetMember->getCMSFields(); + $form = new Form(null, 'SomeForm', $fields, new FieldList()); + $field = $fields->dataFieldByName('RequiresPasswordChangeOnNextLogin'); + $field->setValue(1); + $form->saveInto($targetMember); + + $this->assertEquals($originalValue, $targetMember->PasswordExpiry); + } + + public function testSavingUncheckedPasswordChangeNullsPastDates() + { + $mockDate = '2019-03-02 00:00:00'; + DBDateTime::set_mock_now($mockDate); + + /** @var Member&MemberExtension $targetMember */ + $targetMember = $this->objFromFixture(Member::class, 'expired'); + + $this->logInWithPermission('ADMIN'); + $fields = $targetMember->getCMSFields(); + $form = new Form(null, 'SomeForm', $fields, new FieldList()); + $field = $fields->dataFieldByName('RequiresPasswordChangeOnNextLogin'); + $field->setValue(0); + $form->saveInto($targetMember); + + $this->assertNull($targetMember->PasswordExpiry); + } + + public function testSavingUncheckedPasswordChangeDoesNotAlterFutureDates() + { + $mockDate = '2019-03-02 00:00:00'; + DBDateTime::set_mock_now($mockDate); + + /** @var Member&MemberExtension $targetMember */ + $targetMember = $this->objFromFixture(Member::class, 'willexpire'); + $originalValue = $targetMember->PasswordExpiry; + + $this->logInWithPermission('ADMIN'); + $fields = $targetMember->getCMSFields(); + $form = new Form(null, 'SomeForm', $fields, new FieldList()); + $field = $fields->dataFieldByName('RequiresPasswordChangeOnNextLogin'); + $field->setValue(0); + $form->saveInto($targetMember); + + $this->assertNotNull($targetMember->PasswordExpiry); + $this->assertEquals($originalValue, $targetMember->PasswordExpiry); + } + + public function testSavingChangePasswordOnNextLoginIsNotPossibleIfTheCurrentMemberCannotEditTheMemberBeingSaved() + { + /** @var Member&MemberExtension $targetMember */ + $targetMember = $this->objFromFixture(Member::class, 'expired'); + $originalValue = $targetMember->PasswordExpiry; + + $this->logInAs('someone'); + $fields = $targetMember->saveRequiresPasswordChangeOnNextLogin(0); + + $this->assertEquals($originalValue, $targetMember->PasswordExpiry); + } + + public function testGetRequiresPasswordChangeOnNextLogin() + { + $this->assertTrue( + $this->objFromFixture(Member::class, 'expired')->getRequiresPasswordChangeOnNextLogin(), + 'PasswordExpiry date in the past should require a change' + ); + $this->assertFalse( + $this->objFromFixture(Member::class, 'willexpire')->getRequiresPasswordChangeOnNextLogin(), + 'PasswordExpiry date in the past should NOT require a change' + ); + $this->assertFalse( + $this->objFromFixture(Member::class, 'someone')->getRequiresPasswordChangeOnNextLogin(), + 'PasswordExpiry is NULL should NOT require a change' + ); + } + public function testInGroups() { /** @var Member $staffmember */ @@ -869,7 +1027,7 @@ class MemberTest extends FunctionalTest public function testMap_in_groupsReturnsAll() { $members = Member::map_in_groups(); - $this->assertEquals(13, $members->count(), 'There are 12 members in the mock plus a fake admin'); + $this->assertEquals(17, $members->count(), 'There are 16 members in the mock plus a fake admin'); } /** diff --git a/tests/php/Security/MemberTest.yml b/tests/php/Security/MemberTest.yml index 0f9ed3c91..0711914e6 100644 --- a/tests/php/Security/MemberTest.yml +++ b/tests/php/Security/MemberTest.yml @@ -57,6 +57,20 @@ Surname: User Email: noexpiry@silverstripe.com Password: 1nitialPassword + someone: + FirstName: 'Someone' + Email: 'someone@example.com' + anyone: + FirstName: 'Anyone' + Email: 'anyone@example.com' + expired: + Firstname: 'Expired' + Email: 'expired@example.com' + PasswordExpiry: '2018-01-01' + willexpire: + Firstname: 'William' + Email: 'william@example.com' + PasswordExpiry: '3018-01-01' staffmember: Email: staffmember@test.com Groups: '=>SilverStripe\Security\Group.staffgroup'