Completed
Push — authenticator-refactor ( 7dc887...371abb )
by Simon
06:49
created

Member::actAs()   A

Complexity

Conditions 2
Paths 4

Size

Total Lines 16
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 9
nc 4
nop 2
dl 0
loc 16
rs 9.4285
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\Security;
4
5
use IntlDateFormatter;
6
use SilverStripe\Admin\LeftAndMain;
7
use SilverStripe\CMS\Controllers\CMSMain;
8
use SilverStripe\Control\Cookie;
9
use SilverStripe\Control\Director;
10
use SilverStripe\Control\Email\Email;
11
use SilverStripe\Control\Email\Mailer;
12
use SilverStripe\Control\Session;
13
use SilverStripe\Core\Convert;
14
use SilverStripe\Core\Injector\Injector;
15
use SilverStripe\Dev\Debug;
16
use SilverStripe\Dev\Deprecation;
17
use SilverStripe\Dev\SapphireTest;
18
use SilverStripe\Dev\TestMailer;
19
use SilverStripe\Forms\ConfirmedPasswordField;
20
use SilverStripe\Forms\DropdownField;
21
use SilverStripe\Forms\FieldList;
22
use SilverStripe\Forms\HTMLEditor\HTMLEditorConfig;
23
use SilverStripe\Forms\ListboxField;
24
use SilverStripe\i18n\i18n;
25
use SilverStripe\MSSQL\MSSQLDatabase;
26
use SilverStripe\ORM\ArrayList;
27
use SilverStripe\ORM\DataObject;
28
use SilverStripe\ORM\DB;
29
use SilverStripe\ORM\FieldType\DBDatetime;
30
use SilverStripe\ORM\HasManyList;
31
use SilverStripe\ORM\ManyManyList;
32
use SilverStripe\ORM\SS_List;
33
use SilverStripe\ORM\Map;
34
use SilverStripe\ORM\ValidationException;
35
use SilverStripe\ORM\ValidationResult;
36
use SilverStripe\View\SSViewer;
37
use SilverStripe\View\TemplateGlobalProvider;
38
use DateTime;
39
40
/**
41
 * The member class which represents the users of the system
42
 *
43
 * @method HasManyList LoggedPasswords()
44
 * @method HasManyList RememberLoginHashes()
45
 * @property string $FirstName
46
 * @property string $Surname
47
 * @property string $Email
48
 * @property string $Password
49
 * @property string $TempIDHash
50
 * @property string $TempIDExpired
51
 * @property string $AutoLoginHash
52
 * @property string $AutoLoginExpired
53
 * @property string $PasswordEncryption
54
 * @property string $Salt
55
 * @property string $PasswordExpiry
56
 * @property string $LockedOutUntil
57
 * @property string $Locale
58
 * @property int $FailedLoginCount
59
 * @property string $DateFormat
60
 * @property string $TimeFormat
61
 */
62
class Member extends DataObject
63
{
64
65
    private static $db = array(
0 ignored issues
show
Comprehensibility introduced by
Consider using a different property name as you override a private property of the parent class.
Loading history...
66
        'FirstName'          => 'Varchar',
67
        'Surname'            => 'Varchar',
68
        'Email'              => 'Varchar(254)', // See RFC 5321, Section 4.5.3.1.3. (256 minus the < and > character)
69
        'TempIDHash'         => 'Varchar(160)', // Temporary id used for cms re-authentication
70
        'TempIDExpired'      => 'Datetime', // Expiry of temp login
71
        'Password'           => 'Varchar(160)',
72
        'AutoLoginHash'      => 'Varchar(160)', // Used to auto-login the user on password reset
73
        'AutoLoginExpired'   => 'Datetime',
74
        // This is an arbitrary code pointing to a PasswordEncryptor instance,
75
        // not an actual encryption algorithm.
76
        // Warning: Never change this field after its the first password hashing without
77
        // providing a new cleartext password as well.
78
        'PasswordEncryption' => "Varchar(50)",
79
        'Salt'               => 'Varchar(50)',
80
        'PasswordExpiry'     => 'Date',
81
        'LockedOutUntil'     => 'Datetime',
82
        'Locale'             => 'Varchar(6)',
83
        // handled in registerFailedLogin(), only used if $lock_out_after_incorrect_logins is set
84
        'FailedLoginCount'   => 'Int',
85
    );
86
87
    private static $belongs_many_many = array(
0 ignored issues
show
Comprehensibility introduced by
Consider using a different property name as you override a private property of the parent class.
Loading history...
88
        'Groups' => Group::class,
89
    );
90
91
    private static $has_many = array(
0 ignored issues
show
Comprehensibility introduced by
Consider using a different property name as you override a private property of the parent class.
Loading history...
92
        'LoggedPasswords'     => MemberPassword::class,
93
        'RememberLoginHashes' => RememberLoginHash::class,
94
    );
95
96
    private static $table_name = "Member";
0 ignored issues
show
Comprehensibility introduced by
Consider using a different property name as you override a private property of the parent class.
Loading history...
97
98
    private static $default_sort = '"Surname", "FirstName"';
0 ignored issues
show
Comprehensibility introduced by
Consider using a different property name as you override a private property of the parent class.
Loading history...
99
100
    private static $indexes = array(
0 ignored issues
show
Comprehensibility introduced by
Consider using a different property name as you override a private property of the parent class.
Loading history...
101
        'Email' => true,
102
        //Removed due to duplicate null values causing MSSQL problems
103
        //'AutoLoginHash' => Array('type'=>'unique', 'value'=>'AutoLoginHash', 'ignoreNulls'=>true)
104
    );
105
106
    /**
107
     * @config
108
     * @var boolean
109
     */
110
    private static $notify_password_change = false;
111
112
    /**
113
     * All searchable database columns
114
     * in this object, currently queried
115
     * with a "column LIKE '%keywords%'
116
     * statement.
117
     *
118
     * @var array
119
     * @todo Generic implementation of $searchable_fields on DataObject,
120
     * with definition for different searching algorithms
121
     * (LIKE, FULLTEXT) and default FormFields to construct a searchform.
122
     */
123
    private static $searchable_fields = array(
0 ignored issues
show
Comprehensibility introduced by
Consider using a different property name as you override a private property of the parent class.
Loading history...
124
        'FirstName',
125
        'Surname',
126
        'Email',
127
    );
128
129
    /**
130
     * @config
131
     * @var array
132
     */
133
    private static $summary_fields = array(
0 ignored issues
show
Comprehensibility introduced by
Consider using a different property name as you override a private property of the parent class.
Loading history...
134
        'FirstName',
135
        'Surname',
136
        'Email',
137
    );
138
139
    /**
140
     * @config
141
     * @var array
142
     */
143
    private static $casting = array(
0 ignored issues
show
Comprehensibility introduced by
Consider using a different property name as you override a private property of the parent class.
Loading history...
144
        'Name' => 'Varchar',
145
    );
146
147
    /**
148
     * Internal-use only fields
149
     *
150
     * @config
151
     * @var array
152
     */
153
    private static $hidden_fields = array(
154
        'AutoLoginHash',
155
        'AutoLoginExpired',
156
        'PasswordEncryption',
157
        'PasswordExpiry',
158
        'LockedOutUntil',
159
        'TempIDHash',
160
        'TempIDExpired',
161
        'Salt',
162
    );
163
164
    /**
165
     * @config
166
     * @var array See {@link set_title_columns()}
167
     */
168
    private static $title_format = null;
169
170
    /**
171
     * The unique field used to identify this member.
172
     * By default, it's "Email", but another common
173
     * field could be Username.
174
     *
175
     * @config
176
     * @var string
177
     * @skipUpgrade
178
     */
179
    private static $unique_identifier_field = 'Email';
180
181
    /**
182
     * Object for validating user's password
183
     *
184
     * @config
185
     * @var PasswordValidator
186
     */
187
    private static $password_validator = null;
188
189
    /**
190
     * @config
191
     * The number of days that a password should be valid for.
192
     * By default, this is null, which means that passwords never expire
193
     */
194
    private static $password_expiry_days = null;
195
196
    /**
197
     * @config
198
     * @var Int Number of incorrect logins after which
199
     * the user is blocked from further attempts for the timespan
200
     * defined in {@link $lock_out_delay_mins}.
201
     */
202
    private static $lock_out_after_incorrect_logins = 10;
203
204
    /**
205
     * @config
206
     * @var integer Minutes of enforced lockout after incorrect password attempts.
207
     * Only applies if {@link $lock_out_after_incorrect_logins} greater than 0.
208
     */
209
    private static $lock_out_delay_mins = 15;
210
211
    /**
212
     * @config
213
     * @var String If this is set, then a session cookie with the given name will be set on log-in,
214
     * and cleared on logout.
215
     */
216
    private static $login_marker_cookie = null;
217
218
    /**
219
     * Indicates that when a {@link Member} logs in, Member:session_regenerate_id()
220
     * should be called as a security precaution.
221
     *
222
     * This doesn't always work, especially if you're trying to set session cookies
223
     * across an entire site using the domain parameter to session_set_cookie_params()
224
     *
225
     * @config
226
     * @var boolean
227
     */
228
    private static $session_regenerate_id = true;
229
230
231
    /**
232
     * Default lifetime of temporary ids.
233
     *
234
     * This is the period within which a user can be re-authenticated within the CMS by entering only their password
235
     * and without losing their workspace.
236
     *
237
     * Any session expiration outside of this time will require them to login from the frontend using their full
238
     * username and password.
239
     *
240
     * Defaults to 72 hours. Set to zero to disable expiration.
241
     *
242
     * @config
243
     * @var int Lifetime in seconds
244
     */
245
    private static $temp_id_lifetime = 259200;
246
247
    /**
248
     * Ensure the locale is set to something sensible by default.
249
     */
250
    public function populateDefaults()
251
    {
252
        parent::populateDefaults();
253
        $this->Locale = i18n::get_closest_translation(i18n::get_locale());
254
    }
255
256
    public function requireDefaultRecords()
257
    {
258
        parent::requireDefaultRecords();
259
        // Default groups should've been built by Group->requireDefaultRecords() already
260
        static::default_admin();
261
    }
262
263
    /**
264
     * Get the default admin record if it exists, or creates it otherwise if enabled
265
     *
266
     * @return Member
267
     */
268
    public static function default_admin()
269
    {
270
        // Check if set
271
        if (!Security::has_default_admin()) {
272
            return null;
273
        }
274
275
        // Find or create ADMIN group
276
        Group::singleton()->requireDefaultRecords();
277
        $adminGroup = Permission::get_groups_by_permission('ADMIN')->first();
278
279
        // Find member
280
        /** @skipUpgrade */
281
        $admin = Member::get()
282
            ->filter('Email', Security::default_admin_username())
283
            ->first();
284
        if (!$admin) {
285
            // 'Password' is not set to avoid creating
286
            // persistent logins in the database. See Security::setDefaultAdmin().
287
            // Set 'Email' to identify this as the default admin
288
            $admin = Member::create();
289
            $admin->FirstName = _t(__CLASS__ . '.DefaultAdminFirstname', 'Default Admin');
290
            $admin->Email = Security::default_admin_username();
291
            $admin->write();
292
        }
293
294
        // Ensure this user is in the admin group
295
        if (!$admin->inGroup($adminGroup)) {
296
            // Add member to group instead of adding group to member
297
            // This bypasses the privilege escallation code in Member_GroupSet
298
            $adminGroup
299
                ->DirectMembers()
300
                ->add($admin);
301
        }
302
303
        return $admin;
304
    }
305
306
    /**
307
     * Check if the passed password matches the stored one (if the member is not locked out).
308
     *
309
     * @param  string $password
310
     * @return ValidationResult
311
     */
312
    public function checkPassword($password)
313
    {
314
        $result = $this->canLogIn();
315
316
        // Short-circuit the result upon failure, no further checks needed.
317
        if (!$result->isValid()) {
318
            return $result;
319
        }
320
321
        // Allow default admin to login as self
322
        if ($this->isDefaultAdmin() && Security::check_default_admin($this->Email, $password)) {
323
            return $result;
324
        }
325
326
        // Check a password is set on this member
327
        if (empty($this->Password) && $this->exists()) {
328
            $result->addError(_t(__CLASS__ . '.NoPassword', 'There is no password on this member.'));
329
330
            return $result;
331
        }
332
333
        $e = PasswordEncryptor::create_for_algorithm($this->PasswordEncryption);
334
        if (!$e->check($this->Password, $password, $this->Salt, $this)) {
335
            $result->addError(_t(
336
                __CLASS__ . '.ERRORWRONGCRED',
337
                'The provided details don\'t seem to be correct. Please try again.'
338
            ));
339
        }
340
341
        return $result;
342
    }
343
344
    /**
345
     * Check if this user is the currently configured default admin
346
     *
347
     * @return bool
348
     */
349
    public function isDefaultAdmin()
350
    {
351
        return Security::has_default_admin()
352
            && $this->Email === Security::default_admin_username();
353
    }
354
355
    /**
356
     * Returns a valid {@link ValidationResult} if this member can currently log in, or an invalid
357
     * one with error messages to display if the member is locked out.
358
     *
359
     * You can hook into this with a "canLogIn" method on an attached extension.
360
     *
361
     * @return ValidationResult
362
     */
363
    public function canLogIn()
364
    {
365
        $result = ValidationResult::create();
366
367
        if ($this->isLockedOut()) {
368
            $result->addError(
369
                _t(
370
                    __CLASS__ . '.ERRORLOCKEDOUT2',
371
                    'Your account has been temporarily disabled because of too many failed attempts at ' .
372
                    'logging in. Please try again in {count} minutes.',
373
                    null,
374
                    array('count' => static::config()->get('lock_out_delay_mins'))
375
                )
376
            );
377
        }
378
379
        $this->extend('canLogIn', $result);
380
381
        return $result;
382
    }
383
384
    /**
385
     * Returns true if this user is locked out
386
     *
387
     * @return bool
388
     */
389
    public function isLockedOut()
390
    {
391
        if (!$this->LockedOutUntil) {
392
            return false;
393
        }
394
395
        return DBDatetime::now()->getTimestamp() < $this->dbObject('LockedOutUntil')->getTimestamp();
396
    }
397
398
    /**
399
     * Set a {@link PasswordValidator} object to use to validate member's passwords.
400
     *
401
     * @param PasswordValidator $pv
402
     */
403
    public static function set_password_validator($pv)
404
    {
405
        self::$password_validator = $pv;
406
    }
407
408
    /**
409
     * Returns the current {@link PasswordValidator}
410
     *
411
     * @return PasswordValidator
412
     */
413
    public static function password_validator()
414
    {
415
        return self::$password_validator;
416
    }
417
418
419
    public function isPasswordExpired()
420
    {
421
        if (!$this->PasswordExpiry) {
422
            return false;
423
        }
424
425
        return strtotime(date('Y-m-d')) >= strtotime($this->PasswordExpiry);
426
    }
427
428
    /**
429
     * @deprecated 5.0.0 Use Security::setCurrentUser() or IdentityStore::logIn()
430
     *
431
     */
432
    public function logIn()
433
    {
434
        Deprecation::notice(
435
            '5.0.0',
436
            'This method is deprecated and only logs in for the current request. Please use Security::setCurrentUser($user) or an IdentityStore'
437
        );
438
        Security::setCurrentUser($this);
439
    }
440
441
    /**
442
     * Called before a member is logged in via session/cookie/etc
443
     */
444
    public function beforeMemberLoggedIn()
445
    {
446
        // @todo Move to middleware on the AuthenticationRequestFilter IdentityStore
447
        $this->extend('beforeMemberLoggedIn');
448
    }
449
450
    /**
451
     * Called after a member is logged in via session/cookie/etc
452
     */
453
    public function afterMemberLoggedIn()
454
    {
455
        // Clear the incorrect log-in count
456
        $this->registerSuccessfulLogin();
457
458
        $this->LockedOutUntil = null;
459
460
        $this->regenerateTempID();
461
462
        $this->write();
463
464
        // Audit logging hook
465
        $this->extend('afterMemberLoggedIn');
466
    }
467
468
    /**
469
     * Trigger regeneration of TempID.
470
     *
471
     * This should be performed any time the user presents their normal identification (normally Email)
472
     * and is successfully authenticated.
473
     */
474
    public function regenerateTempID()
475
    {
476
        $generator = new RandomGenerator();
477
        $this->TempIDHash = $generator->randomToken('sha1');
478
        $this->TempIDExpired = self::config()->temp_id_lifetime
479
            ? date('Y-m-d H:i:s', strtotime(DBDatetime::now()->getValue()) + self::config()->temp_id_lifetime)
480
            : null;
481
        $this->write();
482
    }
483
484
    /**
485
     * Check if the member ID logged in session actually
486
     * has a database record of the same ID. If there is
487
     * no logged in user, FALSE is returned anyway.
488
     *
489
     * @deprecated Not needed anymore, as it returns Security::getCurrentUser();
490
     *
491
     * @return boolean TRUE record found FALSE no record found
492
     */
493
    public static function logged_in_session_exists()
494
    {
495
        Deprecation::notice(
496
            '5.0.0',
497
            'This method is deprecated and now does not add value. Please use Security::getCurrentUser()'
498
        );
499
500
        if ($member = Security::getCurrentUser()) {
501
            if ($member && $member->exists()) {
502
                return true;
503
            }
504
        }
505
506
        return false;
507
    }
508
509
    /**
510
     * @deprecated Use Security::setCurrentUser(null) or an IdentityStore
511
     * Logs this member out.
512
     */
513
    public function logOut()
514
    {
515
        Deprecation::notice(
516
            '5.0.0',
517
            'This method is deprecated and now does not persist. Please use Security::setCurrentUser(null) or an IdenityStore'
518
        );
519
520
        $this->extend('beforeMemberLoggedOut');
521
522
        Injector::inst()->get(IdentityStore::class)->logOut($this->getRequest());
523
        // Audit logging hook
524
        $this->extend('memberLoggedOut');
525
    }
526
527
    /**
528
     * Utility for generating secure password hashes for this member.
529
     *
530
     * @param string $string
531
     * @return string
532
     * @throws PasswordEncryptor_NotFoundException
533
     */
534
    public function encryptWithUserSettings($string)
535
    {
536
        if (!$string) {
537
            return null;
538
        }
539
540
        // If the algorithm or salt is not available, it means we are operating
541
        // on legacy account with unhashed password. Do not hash the string.
542
        if (!$this->PasswordEncryption) {
543
            return $string;
544
        }
545
546
        // We assume we have PasswordEncryption and Salt available here.
547
        $e = PasswordEncryptor::create_for_algorithm($this->PasswordEncryption);
548
549
        return $e->encrypt($string, $this->Salt);
550
    }
551
552
    /**
553
     * Generate an auto login token which can be used to reset the password,
554
     * at the same time hashing it and storing in the database.
555
     *
556
     * @param int $lifetime The lifetime of the auto login hash in days (by default 2 days)
557
     *
558
     * @returns string Token that should be passed to the client (but NOT persisted).
559
     *
560
     * @todo Make it possible to handle database errors such as a "duplicate key" error
561
     */
562
    public function generateAutologinTokenAndStoreHash($lifetime = 2)
563
    {
564
        do {
565
            $generator = new RandomGenerator();
566
            $token = $generator->randomToken();
567
            $hash = $this->encryptWithUserSettings($token);
568
        } while (DataObject::get_one(Member::class, array(
569
            '"Member"."AutoLoginHash"' => $hash
570
        )));
571
572
        $this->AutoLoginHash = $hash;
573
        $this->AutoLoginExpired = date('Y-m-d H:i:s', time() + (86400 * $lifetime));
574
575
        $this->write();
576
577
        return $token;
578
    }
579
580
    /**
581
     * Check the token against the member.
582
     *
583
     * @param string $autologinToken
584
     *
585
     * @returns bool Is token valid?
586
     */
587
    public function validateAutoLoginToken($autologinToken)
588
    {
589
        $hash = $this->encryptWithUserSettings($autologinToken);
590
        $member = self::member_from_autologinhash($hash, false);
591
592
        return (bool)$member;
593
    }
594
595
    /**
596
     * Return the member for the auto login hash
597
     *
598
     * @param string $hash The hash key
599
     * @param bool $login Should the member be logged in?
600
     *
601
     * @return Member the matching member, if valid
602
     * @return Member
603
     */
604
    public static function member_from_autologinhash($hash, $login = false)
605
    {
606
        /** @var Member $member */
607
        $member = Member::get()->filter([
608
            'AutoLoginHash'                => $hash,
609
            'AutoLoginExpired:GreaterThan' => DBDatetime::now()->getValue(),
610
        ])->first();
611
612
        if ($login && $member) {
613
            Injector::inst()->get(IdentityStore::class)->logIn($member);
614
        }
615
616
        return $member;
617
    }
618
619
    /**
620
     * Find a member record with the given TempIDHash value
621
     *
622
     * @param string $tempid
623
     * @return Member
624
     */
625
    public static function member_from_tempid($tempid)
626
    {
627
        $members = Member::get()
628
            ->filter('TempIDHash', $tempid);
629
630
        // Exclude expired
631
        if (static::config()->get('temp_id_lifetime')) {
632
            $members = $members->filter('TempIDExpired:GreaterThan', DBDatetime::now()->getValue());
633
        }
634
635
        return $members->first();
636
    }
637
638
    /**
639
     * Returns the fields for the member form - used in the registration/profile module.
640
     * It should return fields that are editable by the admin and the logged-in user.
641
     *
642
     * @return FieldList Returns a {@link FieldList} containing the fields for
643
     *                   the member form.
644
     */
645
    public function getMemberFormFields()
646
    {
647
        $fields = parent::getFrontEndFields();
0 ignored issues
show
Comprehensibility Bug introduced by
It seems like you call parent on a different method (getFrontEndFields() instead of getMemberFormFields()). Are you sure this is correct? If so, you might want to change this to $this->getFrontEndFields().

This check looks for a call to a parent method whose name is different than the method from which it is called.

Consider the following code:

class Daddy
{
    protected function getFirstName()
    {
        return "Eidur";
    }

    protected function getSurName()
    {
        return "Gudjohnsen";
    }
}

class Son
{
    public function getFirstName()
    {
        return parent::getSurname();
    }
}

The getFirstName() method in the Son calls the wrong method in the parent class.

Loading history...
648
649
        $fields->replaceField('Password', $this->getMemberPasswordField());
650
651
        $fields->replaceField('Locale', new DropdownField(
652
            'Locale',
653
            $this->fieldLabel('Locale'),
654
            i18n::getSources()->getKnownLocales()
655
        ));
656
657
        $fields->removeByName(static::config()->get('hidden_fields'));
658
        $fields->removeByName('FailedLoginCount');
659
660
661
        $this->extend('updateMemberFormFields', $fields);
662
663
        return $fields;
664
    }
665
666
    /**
667
     * Builds "Change / Create Password" field for this member
668
     *
669
     * @return ConfirmedPasswordField
670
     */
671
    public function getMemberPasswordField()
672
    {
673
        $editingPassword = $this->isInDB();
674
        $label = $editingPassword
675
            ? _t(__CLASS__ . '.EDIT_PASSWORD', 'New Password')
676
            : $this->fieldLabel('Password');
677
        /** @var ConfirmedPasswordField $password */
678
        $password = ConfirmedPasswordField::create(
679
            'Password',
680
            $label,
681
            null,
682
            null,
683
            $editingPassword
684
        );
685
686
        // If editing own password, require confirmation of existing
687
        if ($editingPassword && $this->ID == Security::getCurrentUser()->ID) {
688
            $password->setRequireExistingPassword(true);
689
        }
690
691
        $password->setCanBeEmpty(true);
692
        $this->extend('updateMemberPasswordField', $password);
693
694
        return $password;
695
    }
696
697
698
    /**
699
     * Returns the {@link RequiredFields} instance for the Member object. This
700
     * Validator is used when saving a {@link CMSProfileController} or added to
701
     * any form responsible for saving a users data.
702
     *
703
     * To customize the required fields, add a {@link DataExtension} to member
704
     * calling the `updateValidator()` method.
705
     *
706
     * @return Member_Validator
707
     */
708
    public function getValidator()
709
    {
710
        $validator = Member_Validator::create();
711
        $validator->setForMember($this);
712
        $this->extend('updateValidator', $validator);
713
714
        return $validator;
715
    }
716
717
718
    /**
719
     * Returns the current logged in user
720
     *
721
     * @deprecated 5.0.0 use Security::getCurrentUser()
722
     *
723
     * @return Member
724
     */
725
    public static function currentUser()
726
    {
727
        Deprecation::notice(
728
            '5.0.0',
729
            'This method is deprecated. Please use Security::getCurrentUser() or an IdentityStore'
730
        );
731
732
        return Security::getCurrentUser();
733
    }
734
735
    /**
736
     * Temporarily act as the specified user, limited to a $callback, but
737
     * without logging in as that user.
738
     *
739
     * E.g.
740
     * <code>
741
     * Member::logInAs(Security::findAnAdministrator(), function() {
742
     *     $record->write();
743
     * });
744
     * </code>
745
     *
746
     * @param Member|null|int $member Member or member ID to log in as.
747
     * Set to null or 0 to act as a logged out user.
748
     * @param $callback
749
     */
750
    public static function actAs($member, $callback)
751
    {
752
        $previousUser = Security::getCurrentUser();
753
754
        // Transform ID to member
755
        if (is_numeric($member)) {
756
            $member = DataObject::get_by_id(Member::class, $member);
757
        }
758
        Security::setCurrentUser($member);
759
760
        try {
761
            return $callback();
762
        } finally {
763
            Security::setCurrentUser($previousUser);
764
        }
765
    }
766
767
    /**
768
     * Get the ID of the current logged in user
769
     *
770
     * @deprecated 5.0.0 use Security::getCurrentUser()
771
     *
772
     * @return int Returns the ID of the current logged in user or 0.
773
     */
774
    public static function currentUserID()
775
    {
776
        Deprecation::notice(
777
            '5.0.0',
778
            'This method is deprecated. Please use Security::getCurrentUser() or an IdentityStore'
779
        );
780
781
        if ($member = Security::getCurrentUser()) {
782
            return $member->ID;
783
        } else {
784
            return 0;
785
        }
786
    }
787
788
    /*
789
	 * Generate a random password, with randomiser to kick in if there's no words file on the
790
	 * filesystem.
791
	 *
792
	 * @return string Returns a random password.
793
	 */
794
    public static function create_new_password()
795
    {
796
        $words = Security::config()->uninherited('word_list');
797
798
        if ($words && file_exists($words)) {
799
            $words = file($words);
800
801
            list($usec, $sec) = explode(' ', microtime());
802
            mt_srand($sec + ((float)$usec * 100000));
803
804
            $word = trim($words[random_int(0, count($words) - 1)]);
805
            $number = random_int(10, 999);
806
807
            return $word . $number;
808
        } else {
809
            $random = mt_rand();
810
            $string = md5($random);
811
            $output = substr($string, 0, 8);
812
813
            return $output;
814
        }
815
    }
816
817
    /**
818
     * Event handler called before writing to the database.
819
     */
820
    public function onBeforeWrite()
821
    {
822
        if ($this->SetPassword) {
0 ignored issues
show
Bug introduced by
The property SetPassword does not seem to exist. Did you mean Password?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
823
            $this->Password = $this->SetPassword;
0 ignored issues
show
Bug introduced by
The property SetPassword does not seem to exist. Did you mean Password?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
824
        }
825
826
        // If a member with the same "unique identifier" already exists with a different ID, don't allow merging.
827
        // Note: This does not a full replacement for safeguards in the controller layer (e.g. in a registration form),
828
        // but rather a last line of defense against data inconsistencies.
829
        $identifierField = Member::config()->unique_identifier_field;
0 ignored issues
show
Documentation introduced by
The property unique_identifier_field does not exist on object<SilverStripe\Core\Config\Config_ForClass>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
830
        if ($this->$identifierField) {
831
            // Note: Same logic as Member_Validator class
832
            $filter = [
833
                "\"Member\".\"$identifierField\"" => $this->$identifierField
834
            ];
835
            if ($this->ID) {
836
                $filter[] = array('"Member"."ID" <> ?' => $this->ID);
837
            }
838
            $existingRecord = DataObject::get_one(Member::class, $filter);
839
840
            if ($existingRecord) {
841
                throw new ValidationException(_t(
842
                    __CLASS__ . '.ValidationIdentifierFailed',
843
                    'Can\'t overwrite existing member #{id} with identical identifier ({name} = {value}))',
844
                    'Values in brackets show "fieldname = value", usually denoting an existing email address',
845
                    array(
846
                        'id'    => $existingRecord->ID,
847
                        'name'  => $identifierField,
848
                        'value' => $this->$identifierField
849
                    )
850
                ));
851
            }
852
        }
853
854
        // We don't send emails out on dev/tests sites to prevent accidentally spamming users.
855
        // However, if TestMailer is in use this isn't a risk.
856
        if ((Director::isLive() || Injector::inst()->get(Mailer::class) instanceof TestMailer)
857
            && $this->isChanged('Password')
858
            && $this->record['Password']
859
            && static::config()->get('notify_password_change')
860
        ) {
861
            Email::create()
862
                ->setHTMLTemplate('SilverStripe\\Control\\Email\\ChangePasswordEmail')
863
                ->setData($this)
864
                ->setTo($this->Email)
865
                ->setSubject(
866
                    _t(__CLASS__ . '.SUBJECTPASSWORDCHANGED', "Your password has been changed",
867
                    'Email subject')
868
                )
869
                ->send();
870
        }
871
872
        // The test on $this->ID is used for when records are initially created.
873
        // Note that this only works with cleartext passwords, as we can't rehash
874
        // existing passwords.
875
        if ((!$this->ID && $this->Password) || $this->isChanged('Password')) {
876
            //reset salt so that it gets regenerated - this will invalidate any persistant login cookies
877
            // or other information encrypted with this Member's settings (see self::encryptWithUserSettings)
878
            $this->Salt = '';
879
            // Password was changed: encrypt the password according the settings
880
            $encryption_details = Security::encrypt_password(
881
                $this->Password, // this is assumed to be cleartext
882
                $this->Salt,
883
                ($this->PasswordEncryption) ?
884
                    $this->PasswordEncryption : Security::config()->password_encryption_algorithm,
885
                $this
886
            );
887
888
            // Overwrite the Password property with the hashed value
889
            $this->Password = $encryption_details['password'];
890
            $this->Salt = $encryption_details['salt'];
891
            $this->PasswordEncryption = $encryption_details['algorithm'];
892
893
            // If we haven't manually set a password expiry
894
            if (!$this->isChanged('PasswordExpiry')) {
895
                // then set it for us
896
                if (self::config()->password_expiry_days) {
897
                    $this->PasswordExpiry = date('Y-m-d', time() + 86400 * self::config()->password_expiry_days);
898
                } else {
899
                    $this->PasswordExpiry = null;
900
                }
901
            }
902
        }
903
904
        // save locale
905
        if (!$this->Locale) {
906
            $this->Locale = i18n::get_locale();
907
        }
908
909
        parent::onBeforeWrite();
910
    }
911
912
    public function onAfterWrite()
913
    {
914
        parent::onAfterWrite();
915
916
        Permission::reset();
917
918
        if ($this->isChanged('Password')) {
919
            MemberPassword::log($this);
920
        }
921
    }
922
923
    public function onAfterDelete()
924
    {
925
        parent::onAfterDelete();
926
927
        //prevent orphaned records remaining in the DB
928
        $this->deletePasswordLogs();
929
    }
930
931
    /**
932
     * Delete the MemberPassword objects that are associated to this user
933
     *
934
     * @return $this
935
     */
936
    protected function deletePasswordLogs()
937
    {
938
        foreach ($this->LoggedPasswords() as $password) {
939
            $password->delete();
940
            $password->destroy();
941
        }
942
943
        return $this;
944
    }
945
946
    /**
947
     * Filter out admin groups to avoid privilege escalation,
948
     * If any admin groups are requested, deny the whole save operation.
949
     *
950
     * @param array $ids Database IDs of Group records
951
     * @return bool True if the change can be accepted
952
     */
953
    public function onChangeGroups($ids)
954
    {
955
        // unless the current user is an admin already OR the logged in user is an admin
956
        if (Permission::check('ADMIN') || Permission::checkMember($this, 'ADMIN')) {
957
            return true;
958
        }
959
960
        // If there are no admin groups in this set then it's ok
961
        $adminGroups = Permission::get_groups_by_permission('ADMIN');
962
        $adminGroupIDs = ($adminGroups) ? $adminGroups->column('ID') : array();
963
964
        return count(array_intersect($ids, $adminGroupIDs)) == 0;
965
    }
966
967
968
    /**
969
     * Check if the member is in one of the given groups.
970
     *
971
     * @param array|SS_List $groups Collection of {@link Group} DataObjects to check
972
     * @param boolean $strict Only determine direct group membership if set to true (Default: false)
973
     * @return bool Returns TRUE if the member is in one of the given groups, otherwise FALSE.
974
     */
975
    public function inGroups($groups, $strict = false)
976
    {
977
        if ($groups) {
978
            foreach ($groups as $group) {
979
                if ($this->inGroup($group, $strict)) {
980
                    return true;
981
                }
982
            }
983
        }
984
985
        return false;
986
    }
987
988
989
    /**
990
     * Check if the member is in the given group or any parent groups.
991
     *
992
     * @param int|Group|string $group Group instance, Group Code or ID
993
     * @param boolean $strict Only determine direct group membership if set to TRUE (Default: FALSE)
994
     * @return bool Returns TRUE if the member is in the given group, otherwise FALSE.
995
     */
996
    public function inGroup($group, $strict = false)
997
    {
998
        if (is_numeric($group)) {
999
            $groupCheckObj = DataObject::get_by_id(Group::class, $group);
1000
        } elseif (is_string($group)) {
1001
            $groupCheckObj = DataObject::get_one(Group::class, array(
1002
                '"Group"."Code"' => $group
1003
            ));
1004
        } elseif ($group instanceof Group) {
1005
            $groupCheckObj = $group;
1006
        } else {
1007
            user_error('Member::inGroup(): Wrong format for $group parameter', E_USER_ERROR);
1008
        }
1009
1010
        if (!$groupCheckObj) {
0 ignored issues
show
Bug introduced by
The variable $groupCheckObj does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
1011
            return false;
1012
        }
1013
1014
        $groupCandidateObjs = ($strict) ? $this->getManyManyComponents("Groups") : $this->Groups();
1015
        if ($groupCandidateObjs) {
1016
            foreach ($groupCandidateObjs as $groupCandidateObj) {
1017
                if ($groupCandidateObj->ID == $groupCheckObj->ID) {
1018
                    return true;
1019
                }
1020
            }
1021
        }
1022
1023
        return false;
1024
    }
1025
1026
    /**
1027
     * Adds the member to a group. This will create the group if the given
1028
     * group code does not return a valid group object.
1029
     *
1030
     * @param string $groupcode
1031
     * @param string $title Title of the group
1032
     */
1033
    public function addToGroupByCode($groupcode, $title = "")
1034
    {
1035
        $group = DataObject::get_one(Group::class, array(
1036
            '"Group"."Code"' => $groupcode
1037
        ));
1038
1039
        if ($group) {
1040
            $this->Groups()->add($group);
1041
        } else {
1042
            if (!$title) {
1043
                $title = $groupcode;
1044
            }
1045
1046
            $group = new Group();
1047
            $group->Code = $groupcode;
1048
            $group->Title = $title;
1049
            $group->write();
1050
1051
            $this->Groups()->add($group);
1052
        }
1053
    }
1054
1055
    /**
1056
     * Removes a member from a group.
1057
     *
1058
     * @param string $groupcode
1059
     */
1060
    public function removeFromGroupByCode($groupcode)
1061
    {
1062
        $group = Group::get()->filter(array('Code' => $groupcode))->first();
1063
1064
        if ($group) {
1065
            $this->Groups()->remove($group);
1066
        }
1067
    }
1068
1069
    /**
1070
     * @param array $columns Column names on the Member record to show in {@link getTitle()}.
1071
     * @param String $sep Separator
1072
     */
1073
    public static function set_title_columns($columns, $sep = ' ')
1074
    {
1075
        if (!is_array($columns)) {
1076
            $columns = array($columns);
1077
        }
1078
        self::config()->title_format = array('columns' => $columns, 'sep' => $sep);
0 ignored issues
show
Documentation introduced by
The property title_format does not exist on object<SilverStripe\Core\Config\Config_ForClass>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
1079
    }
1080
1081
    //------------------- HELPER METHODS -----------------------------------//
1082
1083
    /**
1084
     * Get the complete name of the member, by default in the format "<Surname>, <FirstName>".
1085
     * Falls back to showing either field on its own.
1086
     *
1087
     * You can overload this getter with {@link set_title_format()}
1088
     * and {@link set_title_sql()}.
1089
     *
1090
     * @return string Returns the first- and surname of the member. If the ID
1091
     *  of the member is equal 0, only the surname is returned.
1092
     */
1093
    public function getTitle()
1094
    {
1095
        $format = static::config()->get('title_format');
1096
        if ($format) {
1097
            $values = array();
1098
            foreach ($format['columns'] as $col) {
1099
                $values[] = $this->getField($col);
1100
            }
1101
1102
            return implode($format['sep'], $values);
1103
        }
1104
        if ($this->getField('ID') === 0) {
1105
            return $this->getField('Surname');
1106
        } else {
1107
            if ($this->getField('Surname') && $this->getField('FirstName')) {
1108
                return $this->getField('Surname') . ', ' . $this->getField('FirstName');
1109
            } elseif ($this->getField('Surname')) {
1110
                return $this->getField('Surname');
1111
            } elseif ($this->getField('FirstName')) {
1112
                return $this->getField('FirstName');
1113
            } else {
1114
                return null;
1115
            }
1116
        }
1117
    }
1118
1119
    /**
1120
     * Return a SQL CONCAT() fragment suitable for a SELECT statement.
1121
     * Useful for custom queries which assume a certain member title format.
1122
     *
1123
     * @return String SQL
1124
     */
1125
    public static function get_title_sql()
1126
    {
1127
        // This should be abstracted to SSDatabase concatOperator or similar.
1128
        $op = (DB::get_conn() instanceof MSSQLDatabase) ? " + " : " || ";
0 ignored issues
show
Bug introduced by
The class SilverStripe\MSSQL\MSSQLDatabase does not exist. Did you forget a USE statement, or did you not list all dependencies?

This error could be the result of:

1. Missing dependencies

PHP Analyzer uses your composer.json file (if available) to determine the dependencies of your project and to determine all the available classes and functions. It expects the composer.json to be in the root folder of your repository.

Are you sure this class is defined by one of your dependencies, or did you maybe not list a dependency in either the require or require-dev section?

2. Missing use statement

PHP does not complain about undefined classes in ìnstanceof checks. For example, the following PHP code will work perfectly fine:

if ($x instanceof DoesNotExist) {
    // Do something.
}

If you have not tested against this specific condition, such errors might go unnoticed.

Loading history...
1129
1130
        // Get title_format with fallback to default
1131
        $format = static::config()->get('title_format');
1132
        if (!$format) {
1133
            $format = [
1134
                'columns' => ['Surname', 'FirstName'],
1135
                'sep'     => ' ',
1136
            ];
1137
        }
1138
1139
        $columnsWithTablename = array();
1140
        foreach ($format['columns'] as $column) {
1141
            $columnsWithTablename[] = static::getSchema()->sqlColumnForField(__CLASS__, $column);
1142
        }
1143
1144
        $sepSQL = Convert::raw2sql($format['sep'], true);
1145
1146
        return "(" . join(" $op $sepSQL $op ", $columnsWithTablename) . ")";
1147
    }
1148
1149
1150
    /**
1151
     * Get the complete name of the member
1152
     *
1153
     * @return string Returns the first- and surname of the member.
1154
     */
1155
    public function getName()
1156
    {
1157
        return ($this->Surname) ? trim($this->FirstName . ' ' . $this->Surname) : $this->FirstName;
1158
    }
1159
1160
1161
    /**
1162
     * Set first- and surname
1163
     *
1164
     * This method assumes that the last part of the name is the surname, e.g.
1165
     * <i>A B C</i> will result in firstname <i>A B</i> and surname <i>C</i>
1166
     *
1167
     * @param string $name The name
1168
     */
1169
    public function setName($name)
1170
    {
1171
        $nameParts = explode(' ', $name);
1172
        $this->Surname = array_pop($nameParts);
1173
        $this->FirstName = join(' ', $nameParts);
1174
    }
1175
1176
1177
    /**
1178
     * Alias for {@link setName}
1179
     *
1180
     * @param string $name The name
1181
     * @see setName()
1182
     */
1183
    public function splitName($name)
1184
    {
1185
        return $this->setName($name);
1186
    }
1187
1188
    /**
1189
     * Return the date format based on the user's chosen locale,
1190
     * falling back to the default format defined by the {@link i18n.get_locale()} setting.
1191
     *
1192
     * @return string ISO date format
1193
     */
1194 View Code Duplication
    public function getDateFormat()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
1195
    {
1196
        $formatter = new IntlDateFormatter(
1197
            $this->getLocale(),
1198
            IntlDateFormatter::MEDIUM,
1199
            IntlDateFormatter::NONE
1200
        );
1201
        $format = $formatter->getPattern();
1202
1203
        $this->extend('updateDateFormat', $format);
1204
1205
        return $format;
1206
    }
1207
1208
    /**
1209
     * Get user locale
1210
     */
1211
    public function getLocale()
1212
    {
1213
        $locale = $this->getField('Locale');
1214
        if ($locale) {
1215
            return $locale;
1216
        }
1217
1218
        return i18n::get_locale();
1219
    }
1220
1221
    /**
1222
     * Return the time format based on the user's chosen locale,
1223
     * falling back to the default format defined by the {@link i18n.get_locale()} setting.
1224
     *
1225
     * @return string ISO date format
1226
     */
1227 View Code Duplication
    public function getTimeFormat()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
1228
    {
1229
        $formatter = new IntlDateFormatter(
1230
            $this->getLocale(),
1231
            IntlDateFormatter::NONE,
1232
            IntlDateFormatter::MEDIUM
1233
        );
1234
        $format = $formatter->getPattern();
1235
1236
        $this->extend('updateTimeFormat', $format);
1237
1238
        return $format;
1239
    }
1240
1241
    //---------------------------------------------------------------------//
1242
1243
1244
    /**
1245
     * Get a "many-to-many" map that holds for all members their group memberships,
1246
     * including any parent groups where membership is implied.
1247
     * Use {@link DirectGroups()} to only retrieve the group relations without inheritance.
1248
     *
1249
     * @todo Push all this logic into Member_GroupSet's getIterator()?
1250
     * @return Member_Groupset
1251
     */
1252
    public function Groups()
1253
    {
1254
        $groups = Member_GroupSet::create(Group::class, 'Group_Members', 'GroupID', 'MemberID');
1255
        $groups = $groups->forForeignID($this->ID);
1256
1257
        $this->extend('updateGroups', $groups);
1258
1259
        return $groups;
1260
    }
1261
1262
    /**
1263
     * @return ManyManyList
1264
     */
1265
    public function DirectGroups()
1266
    {
1267
        return $this->getManyManyComponents('Groups');
0 ignored issues
show
Bug Compatibility introduced by
The expression $this->getManyManyComponents('Groups'); of type SilverStripe\ORM\Relatio...ORM\UnsavedRelationList adds the type SilverStripe\ORM\UnsavedRelationList to the return on line 1267 which is incompatible with the return type documented by SilverStripe\Security\Member::DirectGroups of type SilverStripe\ORM\ManyManyList.
Loading history...
1268
    }
1269
1270
    /**
1271
     * Get a member SQLMap of members in specific groups
1272
     *
1273
     * If no $groups is passed, all members will be returned
1274
     *
1275
     * @param mixed $groups - takes a SS_List, an array or a single Group.ID
1276
     * @return Map Returns an Map that returns all Member data.
1277
     */
1278
    public static function map_in_groups($groups = null)
1279
    {
1280
        $groupIDList = array();
1281
1282
        if ($groups instanceof SS_List) {
1283
            foreach ($groups as $group) {
1284
                $groupIDList[] = $group->ID;
1285
            }
1286
        } elseif (is_array($groups)) {
1287
            $groupIDList = $groups;
1288
        } elseif ($groups) {
1289
            $groupIDList[] = $groups;
1290
        }
1291
1292
        // No groups, return all Members
1293
        if (!$groupIDList) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $groupIDList of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
1294
            return Member::get()->sort(array('Surname' => 'ASC', 'FirstName' => 'ASC'))->map();
1295
        }
1296
1297
        $membersList = new ArrayList();
1298
        // This is a bit ineffective, but follow the ORM style
1299
        foreach (Group::get()->byIDs($groupIDList) as $group) {
1300
            $membersList->merge($group->Members());
1301
        }
1302
1303
        $membersList->removeDuplicates('ID');
1304
1305
        return $membersList->map();
1306
    }
1307
1308
1309
    /**
1310
     * Get a map of all members in the groups given that have CMS permissions
1311
     *
1312
     * If no groups are passed, all groups with CMS permissions will be used.
1313
     *
1314
     * @param array $groups Groups to consider or NULL to use all groups with
1315
     *                      CMS permissions.
1316
     * @return Map Returns a map of all members in the groups given that
1317
     *                have CMS permissions.
1318
     */
1319
    public static function mapInCMSGroups($groups = null)
1320
    {
1321
        // Check CMS module exists
1322
        if (!class_exists(LeftAndMain::class)) {
1323
            return ArrayList::create()->map();
1324
        }
1325
1326
        if (!$groups || $groups->Count() == 0) {
0 ignored issues
show
Bug introduced by
The method Count cannot be called on $groups (of type array).

Methods can only be called on objects. This check looks for methods being called on variables that have been inferred to never be objects.

Loading history...
1327
            $perms = array('ADMIN', 'CMS_ACCESS_AssetAdmin');
1328
1329
            if (class_exists(CMSMain::class)) {
1330
                $cmsPerms = CMSMain::singleton()->providePermissions();
1331
            } else {
1332
                $cmsPerms = LeftAndMain::singleton()->providePermissions();
1333
            }
1334
1335
            if (!empty($cmsPerms)) {
1336
                $perms = array_unique(array_merge($perms, array_keys($cmsPerms)));
1337
            }
1338
1339
            $permsClause = DB::placeholders($perms);
1340
            /** @skipUpgrade */
1341
            $groups = Group::get()
1342
                ->innerJoin("Permission", '"Permission"."GroupID" = "Group"."ID"')
1343
                ->where(array(
1344
                    "\"Permission\".\"Code\" IN ($permsClause)" => $perms
1345
                ));
1346
        }
1347
1348
        $groupIDList = array();
1349
1350
        if ($groups instanceof SS_List) {
1351
            foreach ($groups as $group) {
1352
                $groupIDList[] = $group->ID;
1353
            }
1354
        } elseif (is_array($groups)) {
1355
            $groupIDList = $groups;
1356
        }
1357
1358
        /** @skipUpgrade */
1359
        $members = Member::get()
1360
            ->innerJoin("Group_Members", '"Group_Members"."MemberID" = "Member"."ID"')
1361
            ->innerJoin("Group", '"Group"."ID" = "Group_Members"."GroupID"');
1362
        if ($groupIDList) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $groupIDList of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
1363
            $groupClause = DB::placeholders($groupIDList);
1364
            $members = $members->where(array(
1365
                "\"Group\".\"ID\" IN ($groupClause)" => $groupIDList
1366
            ));
1367
        }
1368
1369
        return $members->sort('"Member"."Surname", "Member"."FirstName"')->map();
1370
    }
1371
1372
1373
    /**
1374
     * Get the groups in which the member is NOT in
1375
     *
1376
     * When passed an array of groups, and a component set of groups, this
1377
     * function will return the array of groups the member is NOT in.
1378
     *
1379
     * @param array $groupList An array of group code names.
1380
     * @param array $memberGroups A component set of groups (if set to NULL,
1381
     *                            $this->groups() will be used)
1382
     * @return array Groups in which the member is NOT in.
1383
     */
1384
    public function memberNotInGroups($groupList, $memberGroups = null)
1385
    {
1386
        if (!$memberGroups) {
1387
            $memberGroups = $this->Groups();
1388
        }
1389
1390
        foreach ($memberGroups as $group) {
1391
            if (in_array($group->Code, $groupList)) {
1392
                $index = array_search($group->Code, $groupList);
1393
                unset($groupList[$index]);
1394
            }
1395
        }
1396
1397
        return $groupList;
1398
    }
1399
1400
1401
    /**
1402
     * Return a {@link FieldList} of fields that would appropriate for editing
1403
     * this member.
1404
     *
1405
     * @return FieldList Return a FieldList of fields that would appropriate for
1406
     *                   editing this member.
1407
     */
1408
    public function getCMSFields()
1409
    {
1410
        $this->beforeUpdateCMSFields(function (FieldList $fields) {
1411
            /** @var FieldList $mainFields */
1412
            $mainFields = $fields->fieldByName("Root")->fieldByName("Main")->getChildren();
1413
1414
            // Build change password field
1415
            $mainFields->replaceField('Password', $this->getMemberPasswordField());
1416
1417
            $mainFields->replaceField('Locale', new DropdownField(
1418
                "Locale",
1419
                _t(__CLASS__ . '.INTERFACELANG', "Interface Language", 'Language of the CMS'),
1420
                i18n::getSources()->getKnownLocales()
1421
            ));
1422
            $mainFields->removeByName(static::config()->get('hidden_fields'));
1423
1424
            if (!static::config()->get('lock_out_after_incorrect_logins')) {
1425
                $mainFields->removeByName('FailedLoginCount');
1426
            }
1427
1428
            // Groups relation will get us into logical conflicts because
1429
            // Members are displayed within  group edit form in SecurityAdmin
1430
            $fields->removeByName('Groups');
1431
1432
            // Members shouldn't be able to directly view/edit logged passwords
1433
            $fields->removeByName('LoggedPasswords');
1434
1435
            $fields->removeByName('RememberLoginHashes');
1436
1437
            if (Permission::check('EDIT_PERMISSIONS')) {
1438
                $groupsMap = array();
1439
                foreach (Group::get() as $group) {
1440
                    // Listboxfield values are escaped, use ASCII char instead of &raquo;
1441
                    $groupsMap[$group->ID] = $group->getBreadcrumbs(' > ');
1442
                }
1443
                asort($groupsMap);
1444
                $fields->addFieldToTab(
1445
                    'Root.Main',
1446
                    ListboxField::create('DirectGroups', Group::singleton()->i18n_plural_name())
1447
                        ->setSource($groupsMap)
1448
                        ->setAttribute(
1449
                            'data-placeholder',
1450
                            _t(__CLASS__ . '.ADDGROUP', 'Add group', 'Placeholder text for a dropdown')
1451
                        )
1452
                );
1453
1454
1455
                // Add permission field (readonly to avoid complicated group assignment logic).
1456
                // This should only be available for existing records, as new records start
1457
                // with no permissions until they have a group assignment anyway.
1458
                if ($this->ID) {
1459
                    $permissionsField = new PermissionCheckboxSetField_Readonly(
1460
                        'Permissions',
1461
                        false,
0 ignored issues
show
Documentation introduced by
false is of type boolean, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
1462
                        Permission::class,
1463
                        'GroupID',
1464
                        // we don't want parent relationships, they're automatically resolved in the field
1465
                        $this->getManyManyComponents('Groups')
1466
                    );
1467
                    $fields->findOrMakeTab('Root.Permissions', Permission::singleton()->i18n_plural_name());
1468
                    $fields->addFieldToTab('Root.Permissions', $permissionsField);
1469
                }
1470
            }
1471
1472
            $permissionsTab = $fields->fieldByName("Root")->fieldByName('Permissions');
1473
            if ($permissionsTab) {
1474
                $permissionsTab->addExtraClass('readonly');
1475
            }
1476
        });
1477
1478
        return parent::getCMSFields();
1479
    }
1480
1481
    /**
1482
     * @param bool $includerelations Indicate if the labels returned include relation fields
1483
     * @return array
1484
     */
1485
    public function fieldLabels($includerelations = true)
1486
    {
1487
        $labels = parent::fieldLabels($includerelations);
1488
1489
        $labels['FirstName'] = _t(__CLASS__ . '.FIRSTNAME', 'First Name');
1490
        $labels['Surname'] = _t(__CLASS__ . '.SURNAME', 'Surname');
1491
        /** @skipUpgrade */
1492
        $labels['Email'] = _t(__CLASS__ . '.EMAIL', 'Email');
1493
        $labels['Password'] = _t(__CLASS__ . '.db_Password', 'Password');
1494
        $labels['PasswordExpiry'] = _t(__CLASS__ . '.db_PasswordExpiry', 'Password Expiry Date',
1495
            'Password expiry date');
1496
        $labels['LockedOutUntil'] = _t(__CLASS__ . '.db_LockedOutUntil', 'Locked out until', 'Security related date');
1497
        $labels['Locale'] = _t(__CLASS__ . '.db_Locale', 'Interface Locale');
1498
        if ($includerelations) {
1499
            $labels['Groups'] = _t(
1500
                __CLASS__ . '.belongs_many_many_Groups',
1501
                'Groups',
1502
                'Security Groups this member belongs to'
1503
            );
1504
        }
1505
1506
        return $labels;
1507
    }
1508
1509
    /**
1510
     * Users can view their own record.
1511
     * Otherwise they'll need ADMIN or CMS_ACCESS_SecurityAdmin permissions.
1512
     * This is likely to be customized for social sites etc. with a looser permission model.
1513
     *
1514
     * @param Member $member
1515
     * @return bool
1516
     */
1517
    public function canView($member = null)
1518
    {
1519
        //get member
1520
        if (!$member) {
1521
            $member = Security::getCurrentUser();
1522
        }
1523
        //check for extensions, we do this first as they can overrule everything
1524
        $extended = $this->extendedCan(__FUNCTION__, $member);
1525
        if ($extended !== null) {
1526
            return $extended;
1527
        }
1528
1529
        //need to be logged in and/or most checks below rely on $member being a Member
1530
        if (!$member) {
1531
            return false;
1532
        }
1533
        // members can usually view their own record
1534
        if ($this->ID == $member->ID) {
1535
            return true;
1536
        }
1537
1538
        //standard check
1539
        return Permission::checkMember($member, 'CMS_ACCESS_SecurityAdmin');
1540
    }
1541
1542
    /**
1543
     * Users can edit their own record.
1544
     * Otherwise they'll need ADMIN or CMS_ACCESS_SecurityAdmin permissions
1545
     *
1546
     * @param Member $member
1547
     * @return bool
1548
     */
1549 View Code Duplication
    public function canEdit($member = null)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
1550
    {
1551
        //get member
1552
        if (!$member) {
1553
            $member = Security::getCurrentUser();
1554
        }
1555
        //check for extensions, we do this first as they can overrule everything
1556
        $extended = $this->extendedCan(__FUNCTION__, $member);
1557
        if ($extended !== null) {
1558
            return $extended;
1559
        }
1560
1561
        //need to be logged in and/or most checks below rely on $member being a Member
1562
        if (!$member) {
1563
            return false;
1564
        }
1565
1566
        // HACK: we should not allow for an non-Admin to edit an Admin
1567
        if (!Permission::checkMember($member, 'ADMIN') && Permission::checkMember($this, 'ADMIN')) {
1568
            return false;
1569
        }
1570
        // members can usually edit their own record
1571
        if ($this->ID == $member->ID) {
1572
            return true;
1573
        }
1574
1575
        //standard check
1576
        return Permission::checkMember($member, 'CMS_ACCESS_SecurityAdmin');
1577
    }
1578
1579
    /**
1580
     * Users can edit their own record.
1581
     * Otherwise they'll need ADMIN or CMS_ACCESS_SecurityAdmin permissions
1582
     *
1583
     * @param Member $member
1584
     * @return bool
1585
     */
1586 View Code Duplication
    public function canDelete($member = null)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
1587
    {
1588
        if (!$member) {
1589
            $member = Security::getCurrentUser();
1590
        }
1591
        //check for extensions, we do this first as they can overrule everything
1592
        $extended = $this->extendedCan(__FUNCTION__, $member);
1593
        if ($extended !== null) {
1594
            return $extended;
1595
        }
1596
1597
        //need to be logged in and/or most checks below rely on $member being a Member
1598
        if (!$member) {
1599
            return false;
1600
        }
1601
        // Members are not allowed to remove themselves,
1602
        // since it would create inconsistencies in the admin UIs.
1603
        if ($this->ID && $member->ID == $this->ID) {
1604
            return false;
1605
        }
1606
1607
        // HACK: if you want to delete a member, you have to be a member yourself.
1608
        // this is a hack because what this should do is to stop a user
1609
        // deleting a member who has more privileges (e.g. a non-Admin deleting an Admin)
1610
        if (Permission::checkMember($this, 'ADMIN')) {
1611
            if (!Permission::checkMember($member, 'ADMIN')) {
1612
                return false;
1613
            }
1614
        }
1615
1616
        //standard check
1617
        return Permission::checkMember($member, 'CMS_ACCESS_SecurityAdmin');
1618
    }
1619
1620
    /**
1621
     * Validate this member object.
1622
     */
1623
    public function validate()
1624
    {
1625
        $valid = parent::validate();
1626
1627 View Code Duplication
        if (!$this->ID || $this->isChanged('Password')) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
1628
            if ($this->Password && self::$password_validator) {
1629
                $valid->combineAnd(self::$password_validator->validate($this->Password, $this));
1630
            }
1631
        }
1632
1633 View Code Duplication
        if ((!$this->ID && $this->SetPassword) || $this->isChanged('SetPassword')) {
0 ignored issues
show
Bug introduced by
The property SetPassword does not seem to exist. Did you mean Password?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
1634
            if ($this->SetPassword && self::$password_validator) {
0 ignored issues
show
Bug introduced by
The property SetPassword does not seem to exist. Did you mean Password?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
1635
                $valid->combineAnd(self::$password_validator->validate($this->SetPassword, $this));
0 ignored issues
show
Bug introduced by
The property SetPassword does not seem to exist. Did you mean Password?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
1636
            }
1637
        }
1638
1639
        return $valid;
1640
    }
1641
1642
    /**
1643
     * Change password. This will cause rehashing according to
1644
     * the `PasswordEncryption` property.
1645
     *
1646
     * @param string $password Cleartext password
1647
     * @return ValidationResult
1648
     */
1649
    public function changePassword($password)
1650
    {
1651
        $this->Password = $password;
1652
        $valid = $this->validate();
1653
1654
        if ($valid->isValid()) {
1655
            $this->AutoLoginHash = null;
1656
            $this->write();
1657
        }
1658
1659
        return $valid;
1660
    }
1661
1662
    /**
1663
     * Tell this member that someone made a failed attempt at logging in as them.
1664
     * This can be used to lock the user out temporarily if too many failed attempts are made.
1665
     */
1666
    public function registerFailedLogin()
1667
    {
1668
        if (self::config()->lock_out_after_incorrect_logins) {
1669
            // Keep a tally of the number of failed log-ins so that we can lock people out
1670
            $this->FailedLoginCount = $this->FailedLoginCount + 1;
1671
1672
            if ($this->FailedLoginCount >= self::config()->lock_out_after_incorrect_logins) {
1673
                $lockoutMins = self::config()->lock_out_delay_mins;
0 ignored issues
show
Documentation introduced by
The property lock_out_delay_mins does not exist on object<SilverStripe\Core\Config\Config_ForClass>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
1674
                $this->LockedOutUntil = date('Y-m-d H:i:s', DBDatetime::now()->getTimestamp() + $lockoutMins * 60);
1675
                $this->FailedLoginCount = 0;
1676
            }
1677
        }
1678
        $this->extend('registerFailedLogin');
1679
        $this->write();
1680
    }
1681
1682
    /**
1683
     * Tell this member that a successful login has been made
1684
     */
1685
    public function registerSuccessfulLogin()
1686
    {
1687
        if (self::config()->lock_out_after_incorrect_logins) {
1688
            // Forgive all past login failures
1689
            $this->FailedLoginCount = 0;
1690
            $this->write();
1691
        }
1692
    }
1693
1694
    /**
1695
     * Get the HtmlEditorConfig for this user to be used in the CMS.
1696
     * This is set by the group. If multiple configurations are set,
1697
     * the one with the highest priority wins.
1698
     *
1699
     * @return string
1700
     */
1701
    public function getHtmlEditorConfigForCMS()
1702
    {
1703
        $currentName = '';
1704
        $currentPriority = 0;
1705
1706
        foreach ($this->Groups() as $group) {
1707
            $configName = $group->HtmlEditorConfig;
1708
            if ($configName) {
1709
                $config = HTMLEditorConfig::get($group->HtmlEditorConfig);
1710
                if ($config && $config->getOption('priority') > $currentPriority) {
1711
                    $currentName = $configName;
1712
                    $currentPriority = $config->getOption('priority');
1713
                }
1714
            }
1715
        }
1716
1717
        // If can't find a suitable editor, just default to cms
1718
        return $currentName ? $currentName : 'cms';
1719
    }
1720
}
1721