Merge pull request #10666 from creative-commoners/pulls/5/security-extensions

NEW migrate functionality from security-extensions module
This commit is contained in:
Sabina Talipova 2023-02-07 13:50:56 +13:00 committed by GitHub
commit 5236b0a9df
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 408 additions and 3 deletions

View File

@ -43,6 +43,8 @@ SilverStripe\Core\Injector\Injector:
Authenticators: Authenticators:
cms: '%$SilverStripe\Security\MemberAuthenticator\CMSMemberAuthenticator' cms: '%$SilverStripe\Security\MemberAuthenticator\CMSMemberAuthenticator'
SilverStripe\Security\IdentityStore: '%$SilverStripe\Security\AuthenticationHandler' SilverStripe\Security\IdentityStore: '%$SilverStripe\Security\AuthenticationHandler'
SilverStripe\Security\SudoMode\SudoModeServiceInterface:
class: SilverStripe\Security\SudoMode\SudoModeService
SilverStripe\Security\PasswordExpirationMiddleware: SilverStripe\Security\PasswordExpirationMiddleware:
default_redirect: Security/changepassword default_redirect: Security/changepassword

View File

@ -228,6 +228,7 @@ da:
PLURALS: PLURALS:
one: 'En bruger' one: 'En bruger'
other: '{count} brugere' other: '{count} brugere'
RequiresPasswordChangeOnNextLogin: 'Kræver ændring af kodeord næste gang der logges ind'
SINGULARNAME: Bruger SINGULARNAME: Bruger
SUBJECTPASSWORDCHANGED: 'Dit kodeord er blevet ændret' SUBJECTPASSWORDCHANGED: 'Dit kodeord er blevet ændret'
SUBJECTPASSWORDRESET: 'Link til at nulstille dit kodeord' SUBJECTPASSWORDRESET: 'Link til at nulstille dit kodeord'

View File

@ -135,6 +135,7 @@ de_DE:
PLURALS: PLURALS:
one: 'Ein Mitglied' one: 'Ein Mitglied'
other: '{count} Mitglieder' other: '{count} Mitglieder'
RequiresPasswordChangeOnNextLogin: 'Beim nächsten Login muss das Passwort geändert werden.'
SINGULARNAME: Mitglied SINGULARNAME: Mitglied
SURNAME: Nachname SURNAME: Nachname
YOUROLDPASSWORD: 'Ihr altes Passwort' YOUROLDPASSWORD: 'Ihr altes Passwort'

View File

@ -276,6 +276,7 @@ en:
PLURALS: PLURALS:
one: 'A Member' one: 'A Member'
other: '{count} Members' other: '{count} Members'
RequiresPasswordChangeOnNextLogin: 'Requires password change on next log in'
SINGULARNAME: Member SINGULARNAME: Member
SUBJECTPASSWORDCHANGED: 'Your password has been changed' SUBJECTPASSWORDCHANGED: 'Your password has been changed'
SUBJECTPASSWORDRESET: 'Your password reset link' SUBJECTPASSWORDRESET: 'Your password reset link'

View File

@ -277,6 +277,7 @@ eo:
PLURALS: PLURALS:
one: 'Unu membro' one: 'Unu membro'
other: '{count} membroj' other: '{count} membroj'
RequiresPasswordChangeOnNextLogin: 'Bezonas ŝanĝi pasvorton je sekva elsaluto'
SINGULARNAME: Membro SINGULARNAME: Membro
SUBJECTPASSWORDCHANGED: 'Via pasvorto estas ŝanĝita' SUBJECTPASSWORDCHANGED: 'Via pasvorto estas ŝanĝita'
SUBJECTPASSWORDRESET: 'Via pasvorto reagordis ligilon' SUBJECTPASSWORDRESET: 'Via pasvorto reagordis ligilon'

View File

@ -275,6 +275,7 @@ nl:
PLURALS: PLURALS:
one: 'Een lid' one: 'Een lid'
other: '{count} leden' other: '{count} leden'
RequiresPasswordChangeOnNextLogin: 'Wachtwoord veranderen is verplicht bij volgende aanmelding'
SINGULARNAME: Lid SINGULARNAME: Lid
SUBJECTPASSWORDCHANGED: 'Je wachtwoord is aangepast' SUBJECTPASSWORDCHANGED: 'Je wachtwoord is aangepast'
SUBJECTPASSWORDRESET: 'Je wachtwoord opnieuw instellen' SUBJECTPASSWORDRESET: 'Je wachtwoord opnieuw instellen'

View File

@ -294,6 +294,7 @@ sl:
two: '{count} uporabnika' two: '{count} uporabnika'
few: '{count} uporabnikov' few: '{count} uporabnikov'
other: '{count} uporabnikov' other: '{count} uporabnikov'
RequiresPasswordChangeOnNextLogin: 'Ob naslednji prijavi zahtevaj spremembo gesla'
SINGULARNAME: Uporabnik SINGULARNAME: Uporabnik
SUBJECTPASSWORDCHANGED: 'Geslo je bilo spremenjeno' SUBJECTPASSWORDCHANGED: 'Geslo je bilo spremenjeno'
SUBJECTPASSWORDRESET: 'Povezava za resetiranje vašega gesla' SUBJECTPASSWORDRESET: 'Povezava za resetiranje vašega gesla'

View File

@ -224,6 +224,7 @@ sv:
PLURALS: PLURALS:
one: 'En medlem' one: 'En medlem'
other: '{count} medlemmar' other: '{count} medlemmar'
RequiresPasswordChangeOnNextLogin: 'Kräver ändring av lösenord vid nästa inloggning'
SINGULARNAME: Medlem SINGULARNAME: Medlem
SUBJECTPASSWORDCHANGED: 'Ditt lösenord har ändrats' SUBJECTPASSWORDCHANGED: 'Ditt lösenord har ändrats'
SUBJECTPASSWORDRESET: 'Din återställningslänk' SUBJECTPASSWORDRESET: 'Din återställningslänk'

View File

@ -13,6 +13,7 @@ use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Convert; use SilverStripe\Core\Convert;
use SilverStripe\Core\Injector\Injector; use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\TestMailer; use SilverStripe\Dev\TestMailer;
use SilverStripe\Forms\CheckboxField;
use SilverStripe\Forms\ConfirmedPasswordField; use SilverStripe\Forms\ConfirmedPasswordField;
use SilverStripe\Forms\DropdownField; use SilverStripe\Forms\DropdownField;
use SilverStripe\Forms\FieldList; use SilverStripe\Forms\FieldList;
@ -398,7 +399,40 @@ class Member extends DataObject
return null; 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) { if (!$this->PasswordExpiry) {
return false; return false;
@ -1363,6 +1397,19 @@ class Member extends DataObject
if ($permissionsTab) { if ($permissionsTab) {
$permissionsTab->addExtraClass('readonly'); $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(); return parent::getCMSFields();

View File

@ -6,8 +6,11 @@ use SilverStripe\Control\Controller;
use SilverStripe\Control\Cookie; use SilverStripe\Control\Cookie;
use SilverStripe\Control\Director; use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest; use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\Session;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Security\AuthenticationHandler; use SilverStripe\Security\AuthenticationHandler;
use SilverStripe\Security\Member; use SilverStripe\Security\Member;
use SilverStripe\Security\SudoMode\SudoModeServiceInterface;
/** /**
* Authenticate a member passed on a session cookie * Authenticate a member passed on a session cookie
@ -72,13 +75,20 @@ class SessionAuthenticationHandler implements AuthenticationHandler
{ {
static::regenerateSessionId(); static::regenerateSessionId();
$request = $request ?: Controller::curr()->getRequest(); $request = $request ?: Controller::curr()->getRequest();
$request->getSession()->set($this->getSessionVariable(), $member->ID); $session = $request->getSession();
$session->set($this->getSessionVariable(), $member->ID);
// This lets apache rules detect whether the user has logged in // This lets apache rules detect whether the user has logged in
// @todo make this a setting on the authentication handler // @todo make this a setting on the authentication handler
if (Member::config()->get('login_marker_cookie')) { if (Member::config()->get('login_marker_cookie')) {
Cookie::set(Member::config()->get('login_marker_cookie'), 1, 0); Cookie::set(Member::config()->get('login_marker_cookie'), 1, 0);
} }
// Activate sudo mode on login so the user doesn't have to reauthenticate for sudo
// actions until the sudo mode timeout expires
/** @var SudoModeServiceInterface $service */
$service = Injector::inst()->get(SudoModeServiceInterface::class);
$service->activate($session);
} }
/** /**

View File

@ -0,0 +1,52 @@
<?php
namespace SilverStripe\Security\SudoMode;
use SilverStripe\Control\Session;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\ORM\FieldType\DBDatetime;
class SudoModeService implements SudoModeServiceInterface
{
use Configurable;
/**
* The lifetime that sudo mode authorization lasts for, in minutes.
*
* Note that if the PHP session times out before this lifetime is reached, it will automatically be reset.
* @see \SilverStripe\Control\Session::$timeout
*/
private static int $lifetime_minutes = 45;
/**
* The session key that is used to store the timestamp for when sudo mode was last activated
*
* @var string
*/
private const SUDO_MODE_SESSION_KEY = 'sudo-mode-last-activated';
public function check(Session $session): bool
{
$lastActivated = $session->get(self::SUDO_MODE_SESSION_KEY);
// Not activated at all
if (!$lastActivated) {
return false;
}
// Activated within the last "lifetime" window
$nowTimestamp = DBDatetime::now()->getTimestamp();
return $lastActivated > ($nowTimestamp - $this->getLifetime() * 60);
}
public function activate(Session $session): bool
{
$session->set(self::SUDO_MODE_SESSION_KEY, DBDatetime::now()->getTimestamp());
return true;
}
public function getLifetime(): int
{
return (int) static::config()->get('lifetime_minutes');
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace SilverStripe\Security\SudoMode;
use SilverStripe\Control\Session;
/**
* A service class responsible for activating and checking the current status of elevated permission levels
* via "sudo mode". This is done by checking a timestamp value in the provided session.
*/
interface SudoModeServiceInterface
{
/**
* Checks the current session to see if sudo mode was activated within the last section of lifetime allocation.
*
* @return true if sudo mode is currently active
*/
public function check(Session $session): bool;
/**
* Register activated sudo mode permission in the provided session, which lasts for the configured lifetime.
*
* @return true on success
*/
public function activate(Session $session): bool;
/**
* How long the sudo mode activation lasts for in minutes.
*/
public function getLifetime(): int;
}

View File

@ -9,6 +9,9 @@ use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Convert; use SilverStripe\Core\Convert;
use SilverStripe\Core\Injector\Injector; use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\FunctionalTest; use SilverStripe\Dev\FunctionalTest;
use SilverStripe\Forms\CheckboxField;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\Form;
use SilverStripe\Forms\ListboxField; use SilverStripe\Forms\ListboxField;
use SilverStripe\i18n\i18n; use SilverStripe\i18n\i18n;
use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DataObject;
@ -405,6 +408,161 @@ class MemberTest extends FunctionalTest
$this->assertFalse($member->isPasswordExpired()); $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() public function testInGroups()
{ {
/** @var Member $staffmember */ /** @var Member $staffmember */
@ -872,7 +1030,7 @@ class MemberTest extends FunctionalTest
public function testMap_in_groupsReturnsAll() public function testMap_in_groupsReturnsAll()
{ {
$members = Member::map_in_groups(); $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');
} }
/** /**

View File

@ -57,6 +57,20 @@
Surname: User Surname: User
Email: noexpiry@silverstripe.com Email: noexpiry@silverstripe.com
Password: 1nitialPassword 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: staffmember:
FirstName: Staff FirstName: Staff
Surname: User Surname: User

View File

@ -0,0 +1,84 @@
<?php
namespace SilverStripe\Security\SudoMode\Tests;
use SilverStripe\Control\Controller;
use SilverStripe\Control\Session;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\ORM\FieldType\DBDatetime;
use SilverStripe\Security\SudoMode\SudoModeService;
use SilverStripe\Security\SudoMode\SudoModeServiceInterface;
class SudoModeServiceTest extends SapphireTest
{
/**
* @var Session
*/
private $session;
/**
* @var SudoModeService
*/
private $service;
protected function setUp(): void
{
parent::setUp();
$this->session = new Session([]);
$this->service = new SudoModeService();
DBDatetime::set_mock_now('2019-03-01 12:00:00');
SudoModeService::config()->set('lifetime_minutes', 180);
}
public function testCheckWithoutActivation()
{
$this->session->clearAll();
$this->assertFalse($this->service->check($this->session));
}
public function testCheckWithLastActivationOutsideLifetimeWindow()
{
// 240 minutes ago
$lastActivated = DBDatetime::now()->getTimestamp() - 240 * 60;
$this->session->set('sudo-mode-last-activated', $lastActivated);
$this->assertFalse($this->service->check($this->session));
}
public function testCheckWithLastActivationInsideLifetimeWindow()
{
// 25 minutes ago
$lastActivated = DBDatetime::now()->getTimestamp() - 25 * 60;
$this->session->set('sudo-mode-last-activated', $lastActivated);
$this->assertTrue($this->service->check($this->session));
}
public function testActivateAndCheckImmediately()
{
$this->service->activate($this->session);
$this->assertTrue($this->service->check($this->session));
}
public function testSudoModeActivatesOnLogin()
{
// Sometimes being logged in carries over from other tests
$this->logOut();
/** @var SudoModeServiceInterface $service */
$service = Injector::inst()->get(SudoModeServiceInterface::class);
$session = Controller::curr()->getRequest()->getSession();
// Sudo mode should not be enabled automagically when nobody is logged in
$this->assertFalse($service->check($session));
// Ensure sudo mode is activated on login
$this->logInWithPermission();
$this->assertTrue($service->check($session));
// Ensure sudo mode is not active after logging out
$this->logOut();
$this->assertFalse($service->check($session));
}
}