Skip to content

Commit e409d6f

Browse files
Damian Mooymanrobbieaverill
authored andcommitted
[ss-2018-001] Restrict non-admins from being assigned to admin groups
1 parent e967ab0 commit e409d6f

File tree

2 files changed

+97
-42
lines changed

2 files changed

+97
-42
lines changed

src/Security/Member.php

Lines changed: 62 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
use SilverStripe\ORM\ManyManyList;
3434
use SilverStripe\ORM\Map;
3535
use SilverStripe\ORM\SS_List;
36+
use SilverStripe\ORM\UnsavedRelationList;
3637
use SilverStripe\ORM\ValidationException;
3738
use SilverStripe\ORM\ValidationResult;
3839

@@ -60,35 +61,34 @@
6061
*/
6162
class Member extends DataObject
6263
{
63-
6464
private static $db = array(
65-
'FirstName' => 'Varchar',
66-
'Surname' => 'Varchar',
67-
'Email' => 'Varchar(254)', // See RFC 5321, Section 4.5.3.1.3. (256 minus the < and > character)
68-
'TempIDHash' => 'Varchar(160)', // Temporary id used for cms re-authentication
69-
'TempIDExpired' => 'Datetime', // Expiry of temp login
70-
'Password' => 'Varchar(160)',
71-
'AutoLoginHash' => 'Varchar(160)', // Used to auto-login the user on password reset
72-
'AutoLoginExpired' => 'Datetime',
65+
'FirstName' => 'Varchar',
66+
'Surname' => 'Varchar',
67+
'Email' => 'Varchar(254)', // See RFC 5321, Section 4.5.3.1.3. (256 minus the < and > character)
68+
'TempIDHash' => 'Varchar(160)', // Temporary id used for cms re-authentication
69+
'TempIDExpired' => 'Datetime', // Expiry of temp login
70+
'Password' => 'Varchar(160)',
71+
'AutoLoginHash' => 'Varchar(160)', // Used to auto-login the user on password reset
72+
'AutoLoginExpired' => 'Datetime',
7373
// This is an arbitrary code pointing to a PasswordEncryptor instance,
7474
// not an actual encryption algorithm.
7575
// Warning: Never change this field after its the first password hashing without
7676
// providing a new cleartext password as well.
7777
'PasswordEncryption' => "Varchar(50)",
78-
'Salt' => 'Varchar(50)',
79-
'PasswordExpiry' => 'Date',
80-
'LockedOutUntil' => 'Datetime',
81-
'Locale' => 'Varchar(6)',
78+
'Salt' => 'Varchar(50)',
79+
'PasswordExpiry' => 'Date',
80+
'LockedOutUntil' => 'Datetime',
81+
'Locale' => 'Varchar(6)',
8282
// handled in registerFailedLogin(), only used if $lock_out_after_incorrect_logins is set
83-
'FailedLoginCount' => 'Int',
83+
'FailedLoginCount' => 'Int',
8484
);
8585

8686
private static $belongs_many_many = array(
8787
'Groups' => Group::class,
8888
);
8989

9090
private static $has_many = array(
91-
'LoggedPasswords' => MemberPassword::class,
91+
'LoggedPasswords' => MemberPassword::class,
9292
'RememberLoginHashes' => RememberLoginHash::class,
9393
);
9494

@@ -312,7 +312,7 @@ public function checkPassword($password)
312312
break;
313313
}
314314
}
315-
return $result;
315+
return $result;
316316
}
317317

318318
/**
@@ -521,10 +521,9 @@ public static function logged_in_session_exists()
521521
'This method is deprecated and now does not add value. Please use Security::getCurrentUser()'
522522
);
523523

524-
if ($member = Security::getCurrentUser()) {
525-
if ($member && $member->exists()) {
526-
return true;
527-
}
524+
$member = Security::getCurrentUser();
525+
if ($member && $member->exists()) {
526+
return true;
528527
}
529528

530529
return false;
@@ -655,7 +654,7 @@ public static function member_from_autologinhash($hash, $login = false)
655654
{
656655
/** @var Member $member */
657656
$member = static::get()->filter([
658-
'AutoLoginHash' => $hash,
657+
'AutoLoginHash' => $hash,
659658
'AutoLoginExpired:GreaterThan' => DBDatetime::now()->getValue(),
660659
])->first();
661660

@@ -799,6 +798,7 @@ public static function currentUser()
799798
* @param Member|null|int $member Member or member ID to log in as.
800799
* Set to null or 0 to act as a logged out user.
801800
* @param callable $callback
801+
* @return mixed Result of $callback
802802
*/
803803
public static function actAs($member, $callback)
804804
{
@@ -831,11 +831,11 @@ public static function currentUserID()
831831
'This method is deprecated. Please use Security::getCurrentUser() or an IdentityStore'
832832
);
833833

834-
if ($member = Security::getCurrentUser()) {
834+
$member = Security::getCurrentUser();
835+
if ($member) {
835836
return $member->ID;
836-
} else {
837-
return 0;
838837
}
838+
return 0;
839839
}
840840

841841
/**
@@ -892,8 +892,8 @@ public function onBeforeWrite()
892892
'Can\'t overwrite existing member #{id} with identical identifier ({name} = {value}))',
893893
'Values in brackets show "fieldname = value", usually denoting an existing email address',
894894
array(
895-
'id' => $existingRecord->ID,
896-
'name' => $identifierField,
895+
'id' => $existingRecord->ID,
896+
'name' => $identifierField,
897897
'value' => $this->$identifierField
898898
)
899899
));
@@ -912,7 +912,11 @@ public function onBeforeWrite()
912912
->setHTMLTemplate('SilverStripe\\Control\\Email\\ChangePasswordEmail')
913913
->setData($this)
914914
->setTo($this->Email)
915-
->setSubject(_t(__CLASS__ . '.SUBJECTPASSWORDCHANGED', "Your password has been changed", 'Email subject'))
915+
->setSubject(_t(
916+
__CLASS__ . '.SUBJECTPASSWORDCHANGED',
917+
"Your password has been changed",
918+
'Email subject'
919+
))
916920
->send();
917921
}
918922

@@ -973,17 +977,26 @@ protected function deletePasswordLogs()
973977
* @return bool True if the change can be accepted
974978
*/
975979
public function onChangeGroups($ids)
980+
{
981+
// Ensure none of these match disallowed list
982+
$disallowedGroupIDs = $this->disallowedGroups();
983+
return count(array_intersect($ids, $disallowedGroupIDs)) == 0;
984+
}
985+
986+
/**
987+
* List of group IDs this user is disallowed from
988+
*
989+
* @return int[] List of group IDs
990+
*/
991+
protected function disallowedGroups()
976992
{
977993
// unless the current user is an admin already OR the logged in user is an admin
978994
if (Permission::check('ADMIN') || Permission::checkMember($this, 'ADMIN')) {
979-
return true;
995+
return [];
980996
}
981997

982-
// If there are no admin groups in this set then it's ok
983-
$adminGroups = Permission::get_groups_by_permission('ADMIN');
984-
$adminGroupIDs = ($adminGroups) ? $adminGroups->column('ID') : array();
985-
986-
return count(array_intersect($ids, $adminGroupIDs)) == 0;
998+
// Non-admins may not belong to admin groups
999+
return Permission::get_groups_by_permission('ADMIN')->column('ID');
9871000
}
9881001

9891002

@@ -1159,7 +1172,7 @@ public static function get_title_sql()
11591172
if (!$format) {
11601173
$format = [
11611174
'columns' => ['Surname', 'FirstName'],
1162-
'sep' => ' ',
1175+
'sep' => ' ',
11631176
];
11641177
}
11651178

@@ -1287,7 +1300,7 @@ public function Groups()
12871300
}
12881301

12891302
/**
1290-
* @return ManyManyList
1303+
* @return ManyManyList|UnsavedRelationList
12911304
*/
12921305
public function DirectGroups()
12931306
{
@@ -1468,8 +1481,14 @@ public function getCMSFields()
14681481
$fields->removeByName('RememberLoginHashes');
14691482

14701483
if (Permission::check('EDIT_PERMISSIONS')) {
1484+
// Filter allowed groups
1485+
$groups = Group::get();
1486+
$disallowedGroupIDs = $this->disallowedGroups();
1487+
if ($disallowedGroupIDs) {
1488+
$groups = $groups->exclude('ID', $disallowedGroupIDs);
1489+
}
14711490
$groupsMap = array();
1472-
foreach (Group::get() as $group) {
1491+
foreach ($groups as $group) {
14731492
// Listboxfield values are escaped, use ASCII char instead of &raquo;
14741493
$groupsMap[$group->ID] = $group->getBreadcrumbs(' > ');
14751494
}
@@ -1524,7 +1543,11 @@ public function fieldLabels($includerelations = true)
15241543
/** @skipUpgrade */
15251544
$labels['Email'] = _t(__CLASS__ . '.EMAIL', 'Email');
15261545
$labels['Password'] = _t(__CLASS__ . '.db_Password', 'Password');
1527-
$labels['PasswordExpiry'] = _t(__CLASS__ . '.db_PasswordExpiry', 'Password Expiry Date', 'Password expiry date');
1546+
$labels['PasswordExpiry'] = _t(
1547+
__CLASS__ . '.db_PasswordExpiry',
1548+
'Password Expiry Date',
1549+
'Password expiry date'
1550+
);
15281551
$labels['LockedOutUntil'] = _t(__CLASS__ . '.db_LockedOutUntil', 'Locked out until', 'Security related date');
15291552
$labels['Locale'] = _t(__CLASS__ . '.db_Locale', 'Interface Locale');
15301553
if ($includerelations) {
@@ -1673,8 +1696,8 @@ public function validate()
16731696
*
16741697
* This method will encrypt the password prior to writing.
16751698
*
1676-
* @param string $password Cleartext password
1677-
* @param bool $write Whether to write the member afterwards
1699+
* @param string $password Cleartext password
1700+
* @param bool $write Whether to write the member afterwards
16781701
* @return ValidationResult
16791702
*/
16801703
public function changePassword($password, $write = true)

tests/php/Security/MemberTest.php

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use SilverStripe\Core\Convert;
88
use SilverStripe\Core\Injector\Injector;
99
use SilverStripe\Dev\FunctionalTest;
10+
use SilverStripe\Forms\ListboxField;
1011
use SilverStripe\i18n\i18n;
1112
use SilverStripe\ORM\DataObject;
1213
use SilverStripe\ORM\DB;
@@ -20,7 +21,6 @@
2021
use SilverStripe\Security\MemberAuthenticator\MemberAuthenticator;
2122
use SilverStripe\Security\MemberAuthenticator\SessionAuthenticationHandler;
2223
use SilverStripe\Security\MemberPassword;
23-
use SilverStripe\Security\PasswordEncryptor_Blowfish;
2424
use SilverStripe\Security\Permission;
2525
use SilverStripe\Security\RememberLoginHash;
2626
use SilverStripe\Security\Security;
@@ -656,6 +656,8 @@ public function testOnChangeGroups()
656656
$staffMember = $this->objFromFixture(Member::class, 'staffmember');
657657
/** @var Member $adminMember */
658658
$adminMember = $this->objFromFixture(Member::class, 'admin');
659+
660+
// Construct admin and non-admin gruops
659661
$newAdminGroup = new Group(array('Title' => 'newadmin'));
660662
$newAdminGroup->write();
661663
Permission::grant($newAdminGroup->ID, 'ADMIN');
@@ -688,6 +690,37 @@ public function testOnChangeGroups()
688690
);
689691
}
690692

693+
/**
694+
* Ensure DirectGroups listbox disallows admin-promotion
695+
*/
696+
public function testAllowedGroupsListbox()
697+
{
698+
/** @var Group $adminGroup */
699+
$adminGroup = $this->objFromFixture(Group::class, 'admingroup');
700+
/** @var Member $staffMember */
701+
$staffMember = $this->objFromFixture(Member::class, 'staffmember');
702+
/** @var Member $adminMember */
703+
$adminMember = $this->objFromFixture(Member::class, 'admin');
704+
705+
// Ensure you can see the DirectGroups box
706+
$this->logInWithPermission('EDIT_PERMISSIONS');
707+
708+
// Non-admin member field contains non-admin groups
709+
/** @var ListboxField $staffListbox */
710+
$staffListbox = $staffMember->getCMSFields()->dataFieldByName('DirectGroups');
711+
$this->assertArrayNotHasKey($adminGroup->ID, $staffListbox->getSource());
712+
713+
// admin member field contains admin group
714+
/** @var ListboxField $adminListbox */
715+
$adminListbox = $adminMember->getCMSFields()->dataFieldByName('DirectGroups');
716+
$this->assertArrayHasKey($adminGroup->ID, $adminListbox->getSource());
717+
718+
// If logged in as admin, staff listbox has admin group
719+
$this->logInWithPermission('ADMIN');
720+
$staffListbox = $staffMember->getCMSFields()->dataFieldByName('DirectGroups');
721+
$this->assertArrayHasKey($adminGroup->ID, $staffListbox->getSource());
722+
}
723+
691724
/**
692725
* Test Member_GroupSet::add
693726
*/
@@ -866,8 +899,6 @@ public function testGenerateAutologinTokenAndStoreHash()
866899

867900
public function testValidateAutoLoginToken()
868901
{
869-
$enc = new PasswordEncryptor_Blowfish();
870-
871902
$m1 = new Member();
872903
$m1->PasswordEncryption = 'blowfish';
873904
$m1->Salt = $enc->salt('123');
@@ -1463,6 +1494,7 @@ public function testActAsUser()
14631494
public function testChangePasswordWithExtensionsThatModifyValidationResult()
14641495
{
14651496
// Default behaviour
1497+
/** @var Member $member */
14661498
$member = $this->objFromFixture(Member::class, 'admin');
14671499
$result = $member->changePassword('my-secret-new-password');
14681500
$this->assertInstanceOf(ValidationResult::class, $result);

0 commit comments

Comments
 (0)