Completed
Push — authenticator-refactor ( 3617c4...16f104 )
by Sam
05:36
created

Member::getTitle()   C

Complexity

Conditions 8
Paths 7

Size

Total Lines 24
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 8
eloc 18
nc 7
nop 0
dl 0
loc 24
rs 5.7377
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\SapphireTest;
16
use SilverStripe\Dev\TestMailer;
17
use SilverStripe\Forms\ConfirmedPasswordField;
18
use SilverStripe\Forms\DropdownField;
19
use SilverStripe\Forms\FieldList;
20
use SilverStripe\Forms\HTMLEditor\HTMLEditorConfig;
21
use SilverStripe\Forms\ListboxField;
22
use SilverStripe\i18n\i18n;
23
use SilverStripe\MSSQL\MSSQLDatabase;
24
use SilverStripe\ORM\ArrayList;
25
use SilverStripe\ORM\DataObject;
26
use SilverStripe\ORM\DB;
27
use SilverStripe\ORM\FieldType\DBDatetime;
28
use SilverStripe\ORM\HasManyList;
29
use SilverStripe\ORM\ManyManyList;
30
use SilverStripe\ORM\SS_List;
31
use SilverStripe\ORM\Map;
32
use SilverStripe\ORM\ValidationException;
33
use SilverStripe\ORM\ValidationResult;
34
use SilverStripe\View\SSViewer;
35
use SilverStripe\View\TemplateGlobalProvider;
36
use DateTime;
37
38
/**
39
 * The member class which represents the users of the system
40
 *
41
 * @method HasManyList LoggedPasswords()
42
 * @method HasManyList RememberLoginHashes()
43
 * @property string $FirstName
44
 * @property string $Surname
45
 * @property string $Email
46
 * @property string $Password
47
 * @property string $TempIDHash
48
 * @property string $TempIDExpired
49
 * @property string $AutoLoginHash
50
 * @property string $AutoLoginExpired
51
 * @property string $PasswordEncryption
52
 * @property string $Salt
53
 * @property string $PasswordExpiry
54
 * @property string $LockedOutUntil
55
 * @property string $Locale
56
 * @property int $FailedLoginCount
57
 * @property string $DateFormat
58
 * @property string $TimeFormat
59
 */
60
class Member extends DataObject implements TemplateGlobalProvider
61
{
62
63
    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...
64
        'FirstName' => 'Varchar',
65
        'Surname' => 'Varchar',
66
        'Email' => 'Varchar(254)', // See RFC 5321, Section 4.5.3.1.3. (256 minus the < and > character)
67
        'TempIDHash' => 'Varchar(160)', // Temporary id used for cms re-authentication
68
        'TempIDExpired' => 'Datetime', // Expiry of temp login
69
        'Password' => 'Varchar(160)',
70
        'AutoLoginHash' => 'Varchar(160)', // Used to auto-login the user on password reset
71
        'AutoLoginExpired' => 'Datetime',
72
        // This is an arbitrary code pointing to a PasswordEncryptor instance,
73
        // not an actual encryption algorithm.
74
        // Warning: Never change this field after its the first password hashing without
75
        // providing a new cleartext password as well.
76
        'PasswordEncryption' => "Varchar(50)",
77
        'Salt' => 'Varchar(50)',
78
        'PasswordExpiry' => 'Date',
79
        'LockedOutUntil' => 'Datetime',
80
        'Locale' => 'Varchar(6)',
81
        // handled in registerFailedLogin(), only used if $lock_out_after_incorrect_logins is set
82
        'FailedLoginCount' => 'Int',
83
    );
84
85
    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...
86
        'Groups' => Group::class,
87
    );
88
89
    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...
90
        'LoggedPasswords' => MemberPassword::class,
91
        'RememberLoginHashes' => RememberLoginHash::class,
92
    );
93
94
    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...
95
96
    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...
97
98
    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...
99
        'Email' => true,
100
        //Removed due to duplicate null values causing MSSQL problems
101
        //'AutoLoginHash' => Array('type'=>'unique', 'value'=>'AutoLoginHash', 'ignoreNulls'=>true)
102
    );
103
104
    /**
105
     * @config
106
     * @var boolean
107
     */
108
    private static $notify_password_change = false;
109
110
    /**
111
     * All searchable database columns
112
     * in this object, currently queried
113
     * with a "column LIKE '%keywords%'
114
     * statement.
115
     *
116
     * @var array
117
     * @todo Generic implementation of $searchable_fields on DataObject,
118
     * with definition for different searching algorithms
119
     * (LIKE, FULLTEXT) and default FormFields to construct a searchform.
120
     */
121
    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...
122
        'FirstName',
123
        'Surname',
124
        'Email',
125
    );
126
127
    /**
128
     * @config
129
     * @var array
130
     */
131
    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...
132
        'FirstName',
133
        'Surname',
134
        'Email',
135
    );
136
137
    /**
138
     * @config
139
     * @var array
140
     */
141
    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...
142
        'Name' => 'Varchar',
143
    );
144
145
    /**
146
     * Internal-use only fields
147
     *
148
     * @config
149
     * @var array
150
     */
151
    private static $hidden_fields = array(
152
        'AutoLoginHash',
153
        'AutoLoginExpired',
154
        'PasswordEncryption',
155
        'PasswordExpiry',
156
        'LockedOutUntil',
157
        'TempIDHash',
158
        'TempIDExpired',
159
        'Salt',
160
    );
161
162
    /**
163
     * @config
164
     * @var array See {@link set_title_columns()}
165
     */
166
    private static $title_format = null;
167
168
    /**
169
     * The unique field used to identify this member.
170
     * By default, it's "Email", but another common
171
     * field could be Username.
172
     *
173
     * @config
174
     * @var string
175
     * @skipUpgrade
176
     */
177
    private static $unique_identifier_field = 'Email';
178
179
    /**
180
     * Object for validating user's password
181
     *
182
     * @config
183
     * @var PasswordValidator
184
     */
185
    private static $password_validator = null;
186
187
    /**
188
     * @config
189
     * The number of days that a password should be valid for.
190
     * By default, this is null, which means that passwords never expire
191
     */
192
    private static $password_expiry_days = null;
193
194
    /**
195
     * @config
196
     * @var Int Number of incorrect logins after which
197
     * the user is blocked from further attempts for the timespan
198
     * defined in {@link $lock_out_delay_mins}.
199
     */
200
    private static $lock_out_after_incorrect_logins = 10;
201
202
    /**
203
     * @config
204
     * @var integer Minutes of enforced lockout after incorrect password attempts.
205
     * Only applies if {@link $lock_out_after_incorrect_logins} greater than 0.
206
     */
207
    private static $lock_out_delay_mins = 15;
208
209
    /**
210
     * @config
211
     * @var String If this is set, then a session cookie with the given name will be set on log-in,
212
     * and cleared on logout.
213
     */
214
    private static $login_marker_cookie = null;
215
216
    /**
217
     * Indicates that when a {@link Member} logs in, Member:session_regenerate_id()
218
     * should be called as a security precaution.
219
     *
220
     * This doesn't always work, especially if you're trying to set session cookies
221
     * across an entire site using the domain parameter to session_set_cookie_params()
222
     *
223
     * @config
224
     * @var boolean
225
     */
226
    private static $session_regenerate_id = true;
227
228
229
    /**
230
     * Default lifetime of temporary ids.
231
     *
232
     * This is the period within which a user can be re-authenticated within the CMS by entering only their password
233
     * and without losing their workspace.
234
     *
235
     * Any session expiration outside of this time will require them to login from the frontend using their full
236
     * username and password.
237
     *
238
     * Defaults to 72 hours. Set to zero to disable expiration.
239
     *
240
     * @config
241
     * @var int Lifetime in seconds
242
     */
243
    private static $temp_id_lifetime = 259200;
244
245
    /**
246
     * Ensure the locale is set to something sensible by default.
247
     */
248
    public function populateDefaults()
249
    {
250
        parent::populateDefaults();
251
        $this->Locale = i18n::get_closest_translation(i18n::get_locale());
252
    }
253
254
    public function requireDefaultRecords()
255
    {
256
        parent::requireDefaultRecords();
257
        // Default groups should've been built by Group->requireDefaultRecords() already
258
        static::default_admin();
259
    }
260
261
    /**
262
     * Get the default admin record if it exists, or creates it otherwise if enabled
263
     *
264
     * @return Member
265
     */
266
    public static function default_admin()
267
    {
268
        // Check if set
269
        if (!Security::has_default_admin()) {
270
            return null;
271
        }
272
273
        // Find or create ADMIN group
274
        Group::singleton()->requireDefaultRecords();
275
        $adminGroup = Permission::get_groups_by_permission('ADMIN')->first();
276
277
        // Find member
278
        /** @skipUpgrade */
279
        $admin = Member::get()
280
            ->filter('Email', Security::default_admin_username())
281
            ->first();
282
        if (!$admin) {
283
            // 'Password' is not set to avoid creating
284
            // persistent logins in the database. See Security::setDefaultAdmin().
285
            // Set 'Email' to identify this as the default admin
286
            $admin = Member::create();
287
            $admin->FirstName = _t('SilverStripe\\Security\\Member.DefaultAdminFirstname', 'Default Admin');
288
            $admin->Email = Security::default_admin_username();
289
            $admin->write();
290
        }
291
292
        // Ensure this user is in the admin group
293
        if (!$admin->inGroup($adminGroup)) {
294
            // Add member to group instead of adding group to member
295
            // This bypasses the privilege escallation code in Member_GroupSet
296
            $adminGroup
297
                ->DirectMembers()
298
                ->add($admin);
299
        }
300
301
        return $admin;
302
    }
303
304
    /**
305
     * Check if the passed password matches the stored one (if the member is not locked out).
306
     *
307
     * @param  string $password
308
     * @return ValidationResult
309
     */
310
    public function checkPassword($password)
311
    {
312
        $result = $this->canLogIn();
313
314
        // Short-circuit the result upon failure, no further checks needed.
315
        if (!$result->isValid()) {
316
            return $result;
317
        }
318
319
        // Allow default admin to login as self
320
        if ($this->isDefaultAdmin() && Security::check_default_admin($this->Email, $password)) {
321
            return $result;
322
        }
323
324
        // Check a password is set on this member
325
        if (empty($this->Password) && $this->exists()) {
326
            $result->addError(_t('SilverStripe\\Security\\Member.NoPassword', 'There is no password on this member.'));
327
            return $result;
328
        }
329
330
        $e = PasswordEncryptor::create_for_algorithm($this->PasswordEncryption);
331
        if (!$e->check($this->Password, $password, $this->Salt, $this)) {
332
            $result->addError(_t(
333
                'SilverStripe\\Security\\Member.ERRORWRONGCRED',
334
                'The provided details don\'t seem to be correct. Please try again.'
335
            ));
336
        }
337
338
        return $result;
339
    }
340
341
    /**
342
     * Check if this user is the currently configured default admin
343
     *
344
     * @return bool
345
     */
346
    public function isDefaultAdmin()
347
    {
348
        return Security::has_default_admin()
349
            && $this->Email === Security::default_admin_username();
350
    }
351
352
    /**
353
     * Returns a valid {@link ValidationResult} if this member can currently log in, or an invalid
354
     * one with error messages to display if the member is locked out.
355
     *
356
     * You can hook into this with a "canLogIn" method on an attached extension.
357
     *
358
     * @return ValidationResult
359
     */
360
    public function canLogIn()
361
    {
362
        $result = ValidationResult::create();
363
364
        if ($this->isLockedOut()) {
365
            $result->addError(
366
                _t(
367
                    'SilverStripe\\Security\\Member.ERRORLOCKEDOUT2',
368
                    'Your account has been temporarily disabled because of too many failed attempts at ' .
369
                    'logging in. Please try again in {count} minutes.',
370
                    null,
371
                    array('count' => static::config()->get('lock_out_delay_mins'))
372
                )
373
            );
374
        }
375
376
        $this->extend('canLogIn', $result);
377
        return $result;
378
    }
379
380
    /**
381
     * Returns true if this user is locked out
382
     *
383
     * @return bool
384
     */
385
    protected function isLockedOut()
386
    {
387
        if (!$this->LockedOutUntil) {
388
            return false;
389
        }
390
        return DBDatetime::now()->getTimestamp() < $this->dbObject('LockedOutUntil')->getTimestamp();
391
    }
392
393
    /**
394
     * Set a {@link PasswordValidator} object to use to validate member's passwords.
395
     *
396
     * @param PasswordValidator $pv
397
     */
398
    public static function set_password_validator($pv)
399
    {
400
        self::$password_validator = $pv;
401
    }
402
403
    /**
404
     * Returns the current {@link PasswordValidator}
405
     *
406
     * @return PasswordValidator
407
     */
408
    public static function password_validator()
409
    {
410
        return self::$password_validator;
411
    }
412
413
414
    public function isPasswordExpired()
415
    {
416
        if (!$this->PasswordExpiry) {
417
            return false;
418
        }
419
        return strtotime(date('Y-m-d')) >= strtotime($this->PasswordExpiry);
420
    }
421
422
    /**
423
     * @deprecated Use Security::setCurrentUser() or IdentityStore::logIn()
424
     *
425
     * @param bool $remember If set to TRUE, the member will be logged in automatically the next time.
0 ignored issues
show
Bug introduced by
There is no parameter named $remember. Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

Loading history...
426
     */
427
    public function logIn()
428
    {
429
        user_error("This method is deprecated and now only logs in for the current request", E_USER_WARNING);
430
        Security::setCurrentUser($this);
431
    }
432
433
    /**
434
     * Called before a member is logged in via session/cookie/etc
435
     */
436
    public function beforeMemberLoggedIn()
437
    {
438
        // @todo Move to middleware on the AuthenticationRequestFilter IdentityStore
439
        $this->extend('beforeMemberLoggedIn');
440
    }
441
442
    /**
443
     * Called after a member is logged in via session/cookie/etc
444
     */
445
    public function afterMemberLoggedIn()
446
    {
447
        // Clear the incorrect log-in count
448
        $this->registerSuccessfulLogin();
449
450
        $this->LockedOutUntil = null;
451
452
        $this->regenerateTempID();
453
454
        $this->write();
455
456
        // Audit logging hook
457
        $this->extend('afterMemberLoggedIn');
458
    }
459
460
    /**
461
     * Trigger regeneration of TempID.
462
     *
463
     * This should be performed any time the user presents their normal identification (normally Email)
464
     * and is successfully authenticated.
465
     */
466
    public function regenerateTempID()
467
    {
468
        $generator = new RandomGenerator();
469
        $this->TempIDHash = $generator->randomToken('sha1');
470
        $this->TempIDExpired = self::config()->temp_id_lifetime
471
            ? date('Y-m-d H:i:s', strtotime(DBDatetime::now()->getValue()) + self::config()->temp_id_lifetime)
472
            : null;
473
        $this->write();
474
    }
475
476
    /**
477
     * Check if the member ID logged in session actually
478
     * has a database record of the same ID. If there is
479
     * no logged in user, FALSE is returned anyway.
480
     *
481
     * @return boolean TRUE record found FALSE no record found
482
     */
483
    public static function logged_in_session_exists()
484
    {
485
        if ($id = Member::currentUserID()) {
486
            if ($member = DataObject::get_by_id(Member::class, $id)) {
487
                if ($member->exists()) {
488
                    return true;
489
                }
490
            }
491
        }
492
493
        return false;
494
    }
495
496
    /**
497
     * Logs this member out.
498
     */
499
    public function logOut()
500
    {
501
        $this->extend('beforeMemberLoggedOut');
502
503
        Session::clear("loggedInAs");
504
        if (Member::config()->login_marker_cookie) {
505
            Cookie::set(Member::config()->login_marker_cookie, null, 0);
506
        }
507
508
        Session::destroy();
509
510
        $this->extend('memberLoggedOut');
511
512
        // Clears any potential previous hashes for this member
513
        RememberLoginHash::clear($this, Cookie::get('alc_device'));
514
515
        Cookie::set('alc_enc', null); // // Clear the Remember Me cookie
516
        Cookie::force_expiry('alc_enc');
517
        Cookie::set('alc_device', null);
518
        Cookie::force_expiry('alc_device');
519
520
        // Switch back to live in order to avoid infinite loops when
521
        // redirecting to the login screen (if this login screen is versioned)
522
        Session::clear('readingMode');
523
524
        $this->write();
525
526
        // Audit logging hook
527
        $this->extend('memberLoggedOut');
528
    }
529
530
    /**
531
     * Utility for generating secure password hashes for this member.
532
     *
533
     * @param string $string
534
     * @return string
535
     * @throws PasswordEncryptor_NotFoundException
536
     */
537
    public function encryptWithUserSettings($string)
538
    {
539
        if (!$string) {
540
            return null;
541
        }
542
543
        // If the algorithm or salt is not available, it means we are operating
544
        // on legacy account with unhashed password. Do not hash the string.
545
        if (!$this->PasswordEncryption) {
546
            return $string;
547
        }
548
549
        // We assume we have PasswordEncryption and Salt available here.
550
        $e = PasswordEncryptor::create_for_algorithm($this->PasswordEncryption);
551
        return $e->encrypt($string, $this->Salt);
552
    }
553
554
    /**
555
     * Generate an auto login token which can be used to reset the password,
556
     * at the same time hashing it and storing in the database.
557
     *
558
     * @param int $lifetime The lifetime of the auto login hash in days (by default 2 days)
559
     *
560
     * @returns string Token that should be passed to the client (but NOT persisted).
561
     *
562
     * @todo Make it possible to handle database errors such as a "duplicate key" error
563
     */
564
    public function generateAutologinTokenAndStoreHash($lifetime = 2)
565
    {
566
        do {
567
            $generator = new RandomGenerator();
568
            $token = $generator->randomToken();
569
            $hash = $this->encryptWithUserSettings($token);
570
        } while (DataObject::get_one(Member::class, array(
571
            '"Member"."AutoLoginHash"' => $hash
572
        )));
573
574
        $this->AutoLoginHash = $hash;
575
        $this->AutoLoginExpired = date('Y-m-d H:i:s', time() + (86400 * $lifetime));
576
577
        $this->write();
578
579
        return $token;
580
    }
581
582
    /**
583
     * Check the token against the member.
584
     *
585
     * @param string $autologinToken
586
     *
587
     * @returns bool Is token valid?
588
     */
589
    public function validateAutoLoginToken($autologinToken)
590
    {
591
        $hash = $this->encryptWithUserSettings($autologinToken);
592
        $member = self::member_from_autologinhash($hash, false);
593
        return (bool)$member;
594
    }
595
596
    /**
597
     * Return the member for the auto login hash
598
     *
599
     * @param string $hash The hash key
600
     * @param bool $login Should the member be logged in?
601
     *
602
     * @return Member the matching member, if valid
603
     * @return Member
604
     */
605
    public static function member_from_autologinhash($hash, $login = false)
606
    {
607
        /** @var Member $member */
608
        $member = Member::get()->filter([
609
            'AutoLoginHash' => $hash,
610
            'AutoLoginExpired:GreaterThan' => DBDatetime::now()->getValue(),
611
        ])->first();
612
613
        if ($login && $member) {
614
            $member->logIn();
0 ignored issues
show
Deprecated Code introduced by
The method SilverStripe\Security\Member::logIn() has been deprecated with message: Use Security::setCurrentUser() or IdentityStore::logIn()

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
615
        }
616
617
        return $member;
618
    }
619
620
    /**
621
     * Find a member record with the given TempIDHash value
622
     *
623
     * @param string $tempid
624
     * @return Member
625
     */
626
    public static function member_from_tempid($tempid)
627
    {
628
        $members = Member::get()
629
            ->filter('TempIDHash', $tempid);
630
631
        // Exclude expired
632
        if (static::config()->get('temp_id_lifetime')) {
633
            $members = $members->filter('TempIDExpired:GreaterThan', DBDatetime::now()->getValue());
634
        }
635
636
        return $members->first();
637
    }
638
639
    /**
640
     * Returns the fields for the member form - used in the registration/profile module.
641
     * It should return fields that are editable by the admin and the logged-in user.
642
     *
643
     * @return FieldList Returns a {@link FieldList} containing the fields for
644
     *                   the member form.
645
     */
646
    public function getMemberFormFields()
647
    {
648
        $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...
649
650
        $fields->replaceField('Password', $this->getMemberPasswordField());
651
652
        $fields->replaceField('Locale', new DropdownField(
653
            'Locale',
654
            $this->fieldLabel('Locale'),
655
            i18n::getSources()->getKnownLocales()
656
        ));
657
658
        $fields->removeByName(static::config()->get('hidden_fields'));
659
        $fields->removeByName('FailedLoginCount');
660
661
662
        $this->extend('updateMemberFormFields', $fields);
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('SilverStripe\\Security\\Member.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 == Member::currentUserID()) {
688
            $password->setRequireExistingPassword(true);
689
        }
690
691
        $password->setCanBeEmpty(true);
692
        $this->extend('updateMemberPasswordField', $password);
693
        return $password;
694
    }
695
696
697
    /**
698
     * Returns the {@link RequiredFields} instance for the Member object. This
699
     * Validator is used when saving a {@link CMSProfileController} or added to
700
     * any form responsible for saving a users data.
701
     *
702
     * To customize the required fields, add a {@link DataExtension} to member
703
     * calling the `updateValidator()` method.
704
     *
705
     * @return Member_Validator
706
     */
707
    public function getValidator()
708
    {
709
        $validator = Member_Validator::create();
710
        $validator->setForMember($this);
711
        $this->extend('updateValidator', $validator);
712
713
        return $validator;
714
    }
715
716
717
    /**
718
     * Returns the current logged in user
719
     *
720
     * @return Member
721
     */
722
    public static function currentUser()
723
    {
724
        return Security::getCurrentUser();
725
    }
726
727
    /**
728
     * Temporarily act as the specified user, limited to a $callback, but
729
     * without logging in as that user.
730
     *
731
     * E.g.
732
     * <code>
733
     * Member::logInAs(Security::findAnAdministrator(), function() {
734
     *     $record->write();
735
     * });
736
     * </code>
737
     *
738
     * @param Member|null|int $member Member or member ID to log in as.
739
     * Set to null or 0 to act as a logged out user.
740
     * @param $callback
741
     */
742
    public static function actAs($member, $callback)
743
    {
744
        $previousUser = Security::getCurrentUser();
745
746
        // Transform ID to member
747
        if (is_numeric($member)) {
748
            $member = DataObject::get_by_id(Member::class, $member);
749
        }
750
        Security::setCurrentUser($member);
751
752
        try {
753
            return $callback();
754
        } finally {
755
            Security::setCurrentUser($previousUser);
756
        }
757
    }
758
759
    /**
760
     * Get the ID of the current logged in user
761
     *
762
     * @return int Returns the ID of the current logged in user or 0.
763
     */
764
    public static function currentUserID()
765
    {
766
        if ($member = Security::getCurrentUser()) {
767
            return $member->ID;
768
        } else {
769
            return 0;
770
        }
771
    }
772
773
    /*
774
	 * Generate a random password, with randomiser to kick in if there's no words file on the
775
	 * filesystem.
776
	 *
777
	 * @return string Returns a random password.
778
	 */
779
    public static function create_new_password()
780
    {
781
        $words = Security::config()->uninherited('word_list');
782
783
        if ($words && file_exists($words)) {
784
            $words = file($words);
785
786
            list($usec, $sec) = explode(' ', microtime());
787
            srand($sec + ((float) $usec * 100000));
788
789
            $word = trim($words[rand(0, sizeof($words)-1)]);
790
            $number = rand(10, 999);
791
792
            return $word . $number;
793
        } else {
794
            $random = rand();
795
            $string = md5($random);
796
            $output = substr($string, 0, 8);
797
            return $output;
798
        }
799
    }
800
801
    /**
802
     * Event handler called before writing to the database.
803
     */
804
    public function onBeforeWrite()
805
    {
806
        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...
807
            $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...
808
        }
809
810
        // If a member with the same "unique identifier" already exists with a different ID, don't allow merging.
811
        // Note: This does not a full replacement for safeguards in the controller layer (e.g. in a registration form),
812
        // but rather a last line of defense against data inconsistencies.
813
        $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...
814
        if ($this->$identifierField) {
815
            // Note: Same logic as Member_Validator class
816
            $filter = [
817
                "\"Member\".\"$identifierField\"" => $this->$identifierField
818
            ];
819
            if ($this->ID) {
820
                $filter[] = array('"Member"."ID" <> ?' => $this->ID);
821
            }
822
            $existingRecord = DataObject::get_one(Member::class, $filter);
823
824
            if ($existingRecord) {
825
                throw new ValidationException(_t(
826
                    'SilverStripe\\Security\\Member.ValidationIdentifierFailed',
827
                    'Can\'t overwrite existing member #{id} with identical identifier ({name} = {value}))',
828
                    'Values in brackets show "fieldname = value", usually denoting an existing email address',
829
                    array(
830
                        'id' => $existingRecord->ID,
831
                        'name' => $identifierField,
832
                        'value' => $this->$identifierField
833
                    )
834
                ));
835
            }
836
        }
837
838
        // We don't send emails out on dev/tests sites to prevent accidentally spamming users.
839
        // However, if TestMailer is in use this isn't a risk.
840
        if ((Director::isLive() || Injector::inst()->get(Mailer::class) instanceof TestMailer)
841
            && $this->isChanged('Password')
842
            && $this->record['Password']
843
            && static::config()->get('notify_password_change')
844
        ) {
845
            Email::create()
846
                ->setHTMLTemplate('SilverStripe\\Control\\Email\\ChangePasswordEmail')
847
                ->setData($this)
848
                ->setTo($this->Email)
849
                ->setSubject(_t('SilverStripe\\Security\\Member.SUBJECTPASSWORDCHANGED', "Your password has been changed", 'Email subject'))
850
                ->send();
851
        }
852
853
        // The test on $this->ID is used for when records are initially created.
854
        // Note that this only works with cleartext passwords, as we can't rehash
855
        // existing passwords.
856
        if ((!$this->ID && $this->Password) || $this->isChanged('Password')) {
857
            //reset salt so that it gets regenerated - this will invalidate any persistant login cookies
858
            // or other information encrypted with this Member's settings (see self::encryptWithUserSettings)
859
            $this->Salt = '';
860
            // Password was changed: encrypt the password according the settings
861
            $encryption_details = Security::encrypt_password(
862
                $this->Password, // this is assumed to be cleartext
863
                $this->Salt,
864
                ($this->PasswordEncryption) ?
865
                    $this->PasswordEncryption : Security::config()->password_encryption_algorithm,
866
                $this
867
            );
868
869
            // Overwrite the Password property with the hashed value
870
            $this->Password = $encryption_details['password'];
871
            $this->Salt = $encryption_details['salt'];
872
            $this->PasswordEncryption = $encryption_details['algorithm'];
873
874
            // If we haven't manually set a password expiry
875
            if (!$this->isChanged('PasswordExpiry')) {
876
                // then set it for us
877
                if (self::config()->password_expiry_days) {
878
                    $this->PasswordExpiry = date('Y-m-d', time() + 86400 * self::config()->password_expiry_days);
879
                } else {
880
                    $this->PasswordExpiry = null;
881
                }
882
            }
883
        }
884
885
        // save locale
886
        if (!$this->Locale) {
887
            $this->Locale = i18n::get_locale();
888
        }
889
890
        parent::onBeforeWrite();
891
    }
892
893
    public function onAfterWrite()
894
    {
895
        parent::onAfterWrite();
896
897
        Permission::flush_permission_cache();
898
899
        if ($this->isChanged('Password')) {
900
            MemberPassword::log($this);
901
        }
902
    }
903
904
    public function onAfterDelete()
905
    {
906
        parent::onAfterDelete();
907
908
        //prevent orphaned records remaining in the DB
909
        $this->deletePasswordLogs();
910
    }
911
912
    /**
913
     * Delete the MemberPassword objects that are associated to this user
914
     *
915
     * @return $this
916
     */
917
    protected function deletePasswordLogs()
918
    {
919
        foreach ($this->LoggedPasswords() as $password) {
920
            $password->delete();
921
            $password->destroy();
922
        }
923
        return $this;
924
    }
925
926
    /**
927
     * Filter out admin groups to avoid privilege escalation,
928
     * If any admin groups are requested, deny the whole save operation.
929
     *
930
     * @param array $ids Database IDs of Group records
931
     * @return bool True if the change can be accepted
932
     */
933
    public function onChangeGroups($ids)
934
    {
935
        // unless the current user is an admin already OR the logged in user is an admin
936
        if (Permission::check('ADMIN') || Permission::checkMember($this, 'ADMIN')) {
937
            return true;
938
        }
939
940
        // If there are no admin groups in this set then it's ok
941
            $adminGroups = Permission::get_groups_by_permission('ADMIN');
942
            $adminGroupIDs = ($adminGroups) ? $adminGroups->column('ID') : array();
943
            return count(array_intersect($ids, $adminGroupIDs)) == 0;
944
    }
945
946
947
    /**
948
     * Check if the member is in one of the given groups.
949
     *
950
     * @param array|SS_List $groups Collection of {@link Group} DataObjects to check
951
     * @param boolean $strict Only determine direct group membership if set to true (Default: false)
952
     * @return bool Returns TRUE if the member is in one of the given groups, otherwise FALSE.
953
     */
954
    public function inGroups($groups, $strict = false)
955
    {
956
        if ($groups) {
957
            foreach ($groups as $group) {
958
                if ($this->inGroup($group, $strict)) {
959
                    return true;
960
                }
961
            }
962
        }
963
964
        return false;
965
    }
966
967
968
    /**
969
     * Check if the member is in the given group or any parent groups.
970
     *
971
     * @param int|Group|string $group Group instance, Group Code or ID
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 the given group, otherwise FALSE.
974
     */
975
    public function inGroup($group, $strict = false)
976
    {
977
        if (is_numeric($group)) {
978
            $groupCheckObj = DataObject::get_by_id(Group::class, $group);
979
        } elseif (is_string($group)) {
980
            $groupCheckObj = DataObject::get_one(Group::class, array(
981
                '"Group"."Code"' => $group
982
            ));
983
        } elseif ($group instanceof Group) {
984
            $groupCheckObj = $group;
985
        } else {
986
            user_error('Member::inGroup(): Wrong format for $group parameter', E_USER_ERROR);
987
        }
988
989
        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...
990
            return false;
991
        }
992
993
        $groupCandidateObjs = ($strict) ? $this->getManyManyComponents("Groups") : $this->Groups();
994
        if ($groupCandidateObjs) {
995
            foreach ($groupCandidateObjs as $groupCandidateObj) {
996
                if ($groupCandidateObj->ID == $groupCheckObj->ID) {
997
                    return true;
998
                }
999
            }
1000
        }
1001
1002
        return false;
1003
    }
1004
1005
    /**
1006
     * Adds the member to a group. This will create the group if the given
1007
     * group code does not return a valid group object.
1008
     *
1009
     * @param string $groupcode
1010
     * @param string $title Title of the group
1011
     */
1012
    public function addToGroupByCode($groupcode, $title = "")
1013
    {
1014
        $group = DataObject::get_one(Group::class, array(
1015
            '"Group"."Code"' => $groupcode
1016
        ));
1017
1018
        if ($group) {
1019
            $this->Groups()->add($group);
1020
        } else {
1021
            if (!$title) {
1022
                $title = $groupcode;
1023
            }
1024
1025
            $group = new Group();
1026
            $group->Code = $groupcode;
1027
            $group->Title = $title;
1028
            $group->write();
1029
1030
            $this->Groups()->add($group);
1031
        }
1032
    }
1033
1034
    /**
1035
     * Removes a member from a group.
1036
     *
1037
     * @param string $groupcode
1038
     */
1039
    public function removeFromGroupByCode($groupcode)
1040
    {
1041
        $group = Group::get()->filter(array('Code' => $groupcode))->first();
1042
1043
        if ($group) {
1044
            $this->Groups()->remove($group);
1045
        }
1046
    }
1047
1048
    /**
1049
     * @param array $columns Column names on the Member record to show in {@link getTitle()}.
1050
     * @param String $sep Separator
1051
     */
1052
    public static function set_title_columns($columns, $sep = ' ')
1053
    {
1054
        if (!is_array($columns)) {
1055
            $columns = array($columns);
1056
        }
1057
        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...
1058
    }
1059
1060
    //------------------- HELPER METHODS -----------------------------------//
1061
1062
    /**
1063
     * Get the complete name of the member, by default in the format "<Surname>, <FirstName>".
1064
     * Falls back to showing either field on its own.
1065
     *
1066
     * You can overload this getter with {@link set_title_format()}
1067
     * and {@link set_title_sql()}.
1068
     *
1069
     * @return string Returns the first- and surname of the member. If the ID
1070
     *  of the member is equal 0, only the surname is returned.
1071
     */
1072
    public function getTitle()
1073
    {
1074
        $format = static::config()->get('title_format');
1075
        if ($format) {
1076
            $values = array();
1077
            foreach ($format['columns'] as $col) {
1078
                $values[] = $this->getField($col);
1079
            }
1080
            return join($format['sep'], $values);
1081
        }
1082
        if ($this->getField('ID') === 0) {
1083
            return $this->getField('Surname');
1084
        } else {
1085
            if ($this->getField('Surname') && $this->getField('FirstName')) {
1086
                return $this->getField('Surname') . ', ' . $this->getField('FirstName');
1087
            } elseif ($this->getField('Surname')) {
1088
                return $this->getField('Surname');
1089
            } elseif ($this->getField('FirstName')) {
1090
                return $this->getField('FirstName');
1091
            } else {
1092
                return null;
1093
            }
1094
        }
1095
    }
1096
1097
    /**
1098
     * Return a SQL CONCAT() fragment suitable for a SELECT statement.
1099
     * Useful for custom queries which assume a certain member title format.
1100
     *
1101
     * @return String SQL
1102
     */
1103
    public static function get_title_sql()
1104
    {
1105
        // This should be abstracted to SSDatabase concatOperator or similar.
1106
        $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...
1107
1108
        // Get title_format with fallback to default
1109
        $format = static::config()->get('title_format');
1110
        if (!$format) {
1111
            $format = [
1112
                'columns' => ['Surname', 'FirstName'],
1113
                'sep' => ' ',
1114
            ];
1115
        }
1116
1117
            $columnsWithTablename = array();
1118
        foreach ($format['columns'] as $column) {
1119
            $columnsWithTablename[] = static::getSchema()->sqlColumnForField(__CLASS__, $column);
1120
        }
1121
1122
        $sepSQL = Convert::raw2sql($format['sep'], true);
1123
        return "(".join(" $op $sepSQL $op ", $columnsWithTablename).")";
1124
    }
1125
1126
1127
    /**
1128
     * Get the complete name of the member
1129
     *
1130
     * @return string Returns the first- and surname of the member.
1131
     */
1132
    public function getName()
1133
    {
1134
        return ($this->Surname) ? trim($this->FirstName . ' ' . $this->Surname) : $this->FirstName;
1135
    }
1136
1137
1138
    /**
1139
     * Set first- and surname
1140
     *
1141
     * This method assumes that the last part of the name is the surname, e.g.
1142
     * <i>A B C</i> will result in firstname <i>A B</i> and surname <i>C</i>
1143
     *
1144
     * @param string $name The name
1145
     */
1146
    public function setName($name)
1147
    {
1148
        $nameParts = explode(' ', $name);
1149
        $this->Surname = array_pop($nameParts);
1150
        $this->FirstName = join(' ', $nameParts);
1151
    }
1152
1153
1154
    /**
1155
     * Alias for {@link setName}
1156
     *
1157
     * @param string $name The name
1158
     * @see setName()
1159
     */
1160
    public function splitName($name)
1161
    {
1162
        return $this->setName($name);
1163
    }
1164
1165
    /**
1166
     * Return the date format based on the user's chosen locale,
1167
     * falling back to the default format defined by the {@link i18n.get_locale()} setting.
1168
     *
1169
     * @return string ISO date format
1170
     */
1171 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...
1172
    {
1173
        $formatter = new IntlDateFormatter(
1174
            $this->getLocale(),
1175
            IntlDateFormatter::MEDIUM,
1176
            IntlDateFormatter::NONE
1177
        );
1178
        $format = $formatter->getPattern();
1179
1180
        $this->extend('updateDateFormat', $format);
1181
1182
        return $format;
1183
    }
1184
1185
    /**
1186
     * Get user locale
1187
     */
1188
    public function getLocale()
1189
    {
1190
        $locale = $this->getField('Locale');
1191
        if ($locale) {
1192
            return $locale;
1193
        }
1194
        return i18n::get_locale();
1195
    }
1196
1197
    /**
1198
     * Return the time format based on the user's chosen locale,
1199
     * falling back to the default format defined by the {@link i18n.get_locale()} setting.
1200
     *
1201
     * @return string ISO date format
1202
     */
1203 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...
1204
    {
1205
        $formatter = new IntlDateFormatter(
1206
            $this->getLocale(),
1207
            IntlDateFormatter::NONE,
1208
            IntlDateFormatter::MEDIUM
1209
        );
1210
        $format = $formatter->getPattern();
1211
1212
        $this->extend('updateTimeFormat', $format);
1213
1214
        return $format;
1215
    }
1216
1217
    //---------------------------------------------------------------------//
1218
1219
1220
    /**
1221
     * Get a "many-to-many" map that holds for all members their group memberships,
1222
     * including any parent groups where membership is implied.
1223
     * Use {@link DirectGroups()} to only retrieve the group relations without inheritance.
1224
     *
1225
     * @todo Push all this logic into Member_GroupSet's getIterator()?
1226
     * @return Member_Groupset
1227
     */
1228
    public function Groups()
1229
    {
1230
        $groups = Member_GroupSet::create(Group::class, 'Group_Members', 'GroupID', 'MemberID');
1231
        $groups = $groups->forForeignID($this->ID);
1232
1233
        $this->extend('updateGroups', $groups);
1234
1235
        return $groups;
1236
    }
1237
1238
    /**
1239
     * @return ManyManyList
1240
     */
1241
    public function DirectGroups()
1242
    {
1243
        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 1243 which is incompatible with the return type documented by SilverStripe\Security\Member::DirectGroups of type SilverStripe\ORM\ManyManyList.
Loading history...
1244
    }
1245
1246
    /**
1247
     * Get a member SQLMap of members in specific groups
1248
     *
1249
     * If no $groups is passed, all members will be returned
1250
     *
1251
     * @param mixed $groups - takes a SS_List, an array or a single Group.ID
1252
     * @return Map Returns an Map that returns all Member data.
1253
     */
1254
    public static function map_in_groups($groups = null)
1255
    {
1256
        $groupIDList = array();
1257
1258
        if ($groups instanceof SS_List) {
1259
            foreach ($groups as $group) {
1260
                $groupIDList[] = $group->ID;
1261
            }
1262
        } elseif (is_array($groups)) {
1263
            $groupIDList = $groups;
1264
        } elseif ($groups) {
1265
            $groupIDList[] = $groups;
1266
        }
1267
1268
        // No groups, return all Members
1269
        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...
1270
            return Member::get()->sort(array('Surname'=>'ASC', 'FirstName'=>'ASC'))->map();
1271
        }
1272
1273
        $membersList = new ArrayList();
1274
        // This is a bit ineffective, but follow the ORM style
1275
        foreach (Group::get()->byIDs($groupIDList) as $group) {
1276
            $membersList->merge($group->Members());
1277
        }
1278
1279
        $membersList->removeDuplicates('ID');
1280
        return $membersList->map();
1281
    }
1282
1283
1284
    /**
1285
     * Get a map of all members in the groups given that have CMS permissions
1286
     *
1287
     * If no groups are passed, all groups with CMS permissions will be used.
1288
     *
1289
     * @param array $groups Groups to consider or NULL to use all groups with
1290
     *                      CMS permissions.
1291
     * @return Map Returns a map of all members in the groups given that
1292
     *                have CMS permissions.
1293
     */
1294
    public static function mapInCMSGroups($groups = null)
1295
    {
1296
        // Check CMS module exists
1297
        if (!class_exists(LeftAndMain::class)) {
1298
            return ArrayList::create()->map();
1299
        }
1300
1301
        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...
1302
            $perms = array('ADMIN', 'CMS_ACCESS_AssetAdmin');
1303
1304
            if (class_exists(CMSMain::class)) {
1305
                $cmsPerms = CMSMain::singleton()->providePermissions();
1306
            } else {
1307
                $cmsPerms = LeftAndMain::singleton()->providePermissions();
1308
            }
1309
1310
            if (!empty($cmsPerms)) {
1311
                $perms = array_unique(array_merge($perms, array_keys($cmsPerms)));
1312
            }
1313
1314
            $permsClause = DB::placeholders($perms);
1315
            /** @skipUpgrade */
1316
            $groups = Group::get()
1317
                ->innerJoin("Permission", '"Permission"."GroupID" = "Group"."ID"')
1318
                ->where(array(
1319
                    "\"Permission\".\"Code\" IN ($permsClause)" => $perms
1320
                ));
1321
        }
1322
1323
        $groupIDList = array();
1324
1325
        if ($groups instanceof SS_List) {
1326
            foreach ($groups as $group) {
1327
                $groupIDList[] = $group->ID;
1328
            }
1329
        } elseif (is_array($groups)) {
1330
            $groupIDList = $groups;
1331
        }
1332
1333
        /** @skipUpgrade */
1334
        $members = Member::get()
1335
            ->innerJoin("Group_Members", '"Group_Members"."MemberID" = "Member"."ID"')
1336
            ->innerJoin("Group", '"Group"."ID" = "Group_Members"."GroupID"');
1337
        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...
1338
            $groupClause = DB::placeholders($groupIDList);
1339
            $members = $members->where(array(
1340
                "\"Group\".\"ID\" IN ($groupClause)" => $groupIDList
1341
            ));
1342
        }
1343
1344
        return $members->sort('"Member"."Surname", "Member"."FirstName"')->map();
1345
    }
1346
1347
1348
    /**
1349
     * Get the groups in which the member is NOT in
1350
     *
1351
     * When passed an array of groups, and a component set of groups, this
1352
     * function will return the array of groups the member is NOT in.
1353
     *
1354
     * @param array $groupList An array of group code names.
1355
     * @param array $memberGroups A component set of groups (if set to NULL,
1356
     *                            $this->groups() will be used)
1357
     * @return array Groups in which the member is NOT in.
1358
     */
1359
    public function memberNotInGroups($groupList, $memberGroups = null)
1360
    {
1361
        if (!$memberGroups) {
1362
            $memberGroups = $this->Groups();
1363
        }
1364
1365
        foreach ($memberGroups as $group) {
1366
            if (in_array($group->Code, $groupList)) {
1367
                $index = array_search($group->Code, $groupList);
1368
                unset($groupList[$index]);
1369
            }
1370
        }
1371
1372
        return $groupList;
1373
    }
1374
1375
1376
    /**
1377
     * Return a {@link FieldList} of fields that would appropriate for editing
1378
     * this member.
1379
     *
1380
     * @return FieldList Return a FieldList of fields that would appropriate for
1381
     *                   editing this member.
1382
     */
1383
    public function getCMSFields()
1384
    {
1385
        $this->beforeUpdateCMSFields(function (FieldList $fields) {
1386
            /** @var FieldList $mainFields */
1387
            $mainFields = $fields->fieldByName("Root")->fieldByName("Main")->getChildren();
1388
1389
            // Build change password field
1390
            $mainFields->replaceField('Password', $this->getMemberPasswordField());
1391
1392
            $mainFields->replaceField('Locale', new DropdownField(
1393
                "Locale",
1394
                _t('SilverStripe\\Security\\Member.INTERFACELANG', "Interface Language", 'Language of the CMS'),
1395
                i18n::getSources()->getKnownLocales()
1396
            ));
1397
            $mainFields->removeByName(static::config()->get('hidden_fields'));
1398
1399
            if (! static::config()->get('lock_out_after_incorrect_logins')) {
1400
                $mainFields->removeByName('FailedLoginCount');
1401
            }
1402
1403
            // Groups relation will get us into logical conflicts because
1404
            // Members are displayed within  group edit form in SecurityAdmin
1405
            $fields->removeByName('Groups');
1406
1407
            // Members shouldn't be able to directly view/edit logged passwords
1408
            $fields->removeByName('LoggedPasswords');
1409
1410
            $fields->removeByName('RememberLoginHashes');
1411
1412
            if (Permission::check('EDIT_PERMISSIONS')) {
1413
                $groupsMap = array();
1414
                foreach (Group::get() as $group) {
1415
                    // Listboxfield values are escaped, use ASCII char instead of &raquo;
1416
                    $groupsMap[$group->ID] = $group->getBreadcrumbs(' > ');
1417
                }
1418
                asort($groupsMap);
1419
                $fields->addFieldToTab(
1420
                    'Root.Main',
1421
                    ListboxField::create('DirectGroups', Group::singleton()->i18n_plural_name())
1422
                        ->setSource($groupsMap)
1423
                        ->setAttribute(
1424
                            'data-placeholder',
1425
                            _t('SilverStripe\\Security\\Member.ADDGROUP', 'Add group', 'Placeholder text for a dropdown')
1426
                        )
1427
                );
1428
1429
1430
                // Add permission field (readonly to avoid complicated group assignment logic).
1431
                // This should only be available for existing records, as new records start
1432
                // with no permissions until they have a group assignment anyway.
1433
                if ($this->ID) {
1434
                    $permissionsField = new PermissionCheckboxSetField_Readonly(
1435
                        'Permissions',
1436
                        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...
1437
                        Permission::class,
1438
                        'GroupID',
1439
                        // we don't want parent relationships, they're automatically resolved in the field
1440
                        $this->getManyManyComponents('Groups')
1441
                    );
1442
                    $fields->findOrMakeTab('Root.Permissions', Permission::singleton()->i18n_plural_name());
1443
                    $fields->addFieldToTab('Root.Permissions', $permissionsField);
1444
                }
1445
            }
1446
1447
            $permissionsTab = $fields->fieldByName("Root")->fieldByName('Permissions');
1448
            if ($permissionsTab) {
1449
                $permissionsTab->addExtraClass('readonly');
1450
            }
1451
        });
1452
1453
        return parent::getCMSFields();
1454
    }
1455
1456
    /**
1457
     * @param bool $includerelations Indicate if the labels returned include relation fields
1458
     * @return array
1459
     */
1460
    public function fieldLabels($includerelations = true)
1461
    {
1462
        $labels = parent::fieldLabels($includerelations);
1463
1464
        $labels['FirstName'] = _t('SilverStripe\\Security\\Member.FIRSTNAME', 'First Name');
1465
        $labels['Surname'] = _t('SilverStripe\\Security\\Member.SURNAME', 'Surname');
1466
        /** @skipUpgrade */
1467
        $labels['Email'] = _t('Member.EMAIL', 'Email');
1468
        $labels['Password'] = _t('SilverStripe\\Security\\Member.db_Password', 'Password');
1469
        $labels['PasswordExpiry'] = _t('SilverStripe\\Security\\Member.db_PasswordExpiry', 'Password Expiry Date', 'Password expiry date');
1470
        $labels['LockedOutUntil'] = _t('SilverStripe\\Security\\Member.db_LockedOutUntil', 'Locked out until', 'Security related date');
1471
        $labels['Locale'] = _t('SilverStripe\\Security\\Member.db_Locale', 'Interface Locale');
1472
        if ($includerelations) {
1473
            $labels['Groups'] = _t(
1474
                'SilverStripe\\Security\\Member.belongs_many_many_Groups',
1475
                'Groups',
1476
                'Security Groups this member belongs to'
1477
            );
1478
        }
1479
        return $labels;
1480
    }
1481
1482
    /**
1483
     * Users can view their own record.
1484
     * Otherwise they'll need ADMIN or CMS_ACCESS_SecurityAdmin permissions.
1485
     * This is likely to be customized for social sites etc. with a looser permission model.
1486
     *
1487
     * @param Member $member
1488
     * @return bool
1489
     */
1490
    public function canView($member = null)
1491
    {
1492
        //get member
1493
        if (!($member instanceof Member)) {
1494
            $member = Member::currentUser();
1495
        }
1496
        //check for extensions, we do this first as they can overrule everything
1497
        $extended = $this->extendedCan(__FUNCTION__, $member);
1498
        if ($extended !== null) {
1499
            return $extended;
1500
        }
1501
1502
        //need to be logged in and/or most checks below rely on $member being a Member
1503
        if (!$member) {
1504
            return false;
1505
        }
1506
        // members can usually view their own record
1507
        if ($this->ID == $member->ID) {
1508
            return true;
1509
        }
1510
        //standard check
1511
        return Permission::checkMember($member, 'CMS_ACCESS_SecurityAdmin');
1512
    }
1513
1514
    /**
1515
     * Users can edit their own record.
1516
     * Otherwise they'll need ADMIN or CMS_ACCESS_SecurityAdmin permissions
1517
     *
1518
     * @param Member $member
1519
     * @return bool
1520
     */
1521 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...
1522
    {
1523
        //get member
1524
        if (!($member instanceof Member)) {
1525
            $member = Member::currentUser();
1526
        }
1527
        //check for extensions, we do this first as they can overrule everything
1528
        $extended = $this->extendedCan(__FUNCTION__, $member);
1529
        if ($extended !== null) {
1530
            return $extended;
1531
        }
1532
1533
        //need to be logged in and/or most checks below rely on $member being a Member
1534
        if (!$member) {
1535
            return false;
1536
        }
1537
1538
        // HACK: we should not allow for an non-Admin to edit an Admin
1539
        if (!Permission::checkMember($member, 'ADMIN') && Permission::checkMember($this, 'ADMIN')) {
1540
            return false;
1541
        }
1542
        // members can usually edit their own record
1543
        if ($this->ID == $member->ID) {
1544
            return true;
1545
        }
1546
        //standard check
1547
        return Permission::checkMember($member, 'CMS_ACCESS_SecurityAdmin');
1548
    }
1549
    /**
1550
     * Users can edit their own record.
1551
     * Otherwise they'll need ADMIN or CMS_ACCESS_SecurityAdmin permissions
1552
     *
1553
     * @param Member $member
1554
     * @return bool
1555
     */
1556 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...
1557
    {
1558
        if (!($member instanceof Member)) {
1559
            $member = Member::currentUser();
1560
        }
1561
        //check for extensions, we do this first as they can overrule everything
1562
        $extended = $this->extendedCan(__FUNCTION__, $member);
1563
        if ($extended !== null) {
1564
            return $extended;
1565
        }
1566
1567
        //need to be logged in and/or most checks below rely on $member being a Member
1568
        if (!$member) {
1569
            return false;
1570
        }
1571
        // Members are not allowed to remove themselves,
1572
        // since it would create inconsistencies in the admin UIs.
1573
        if ($this->ID && $member->ID == $this->ID) {
1574
            return false;
1575
        }
1576
1577
        // HACK: if you want to delete a member, you have to be a member yourself.
1578
        // this is a hack because what this should do is to stop a user
1579
        // deleting a member who has more privileges (e.g. a non-Admin deleting an Admin)
1580
        if (Permission::checkMember($this, 'ADMIN')) {
1581
            if (! Permission::checkMember($member, 'ADMIN')) {
1582
                return false;
1583
            }
1584
        }
1585
        //standard check
1586
        return Permission::checkMember($member, 'CMS_ACCESS_SecurityAdmin');
1587
    }
1588
1589
    /**
1590
     * Validate this member object.
1591
     */
1592
    public function validate()
1593
    {
1594
        $valid = parent::validate();
1595
1596 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...
1597
            if ($this->Password && self::$password_validator) {
1598
                $valid->combineAnd(self::$password_validator->validate($this->Password, $this));
1599
            }
1600
        }
1601
1602 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...
1603
            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...
1604
                $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...
1605
            }
1606
        }
1607
1608
        return $valid;
1609
    }
1610
1611
    /**
1612
     * Change password. This will cause rehashing according to
1613
     * the `PasswordEncryption` property.
1614
     *
1615
     * @param string $password Cleartext password
1616
     * @return ValidationResult
1617
     */
1618
    public function changePassword($password)
1619
    {
1620
        $this->Password = $password;
1621
        $valid = $this->validate();
1622
1623
        if ($valid->isValid()) {
1624
            $this->AutoLoginHash = null;
1625
            $this->write();
1626
        }
1627
1628
        return $valid;
1629
    }
1630
1631
    /**
1632
     * Tell this member that someone made a failed attempt at logging in as them.
1633
     * This can be used to lock the user out temporarily if too many failed attempts are made.
1634
     */
1635
    public function registerFailedLogin()
1636
    {
1637
        if (self::config()->lock_out_after_incorrect_logins) {
1638
            // Keep a tally of the number of failed log-ins so that we can lock people out
1639
            $this->FailedLoginCount = $this->FailedLoginCount + 1;
1640
1641
            if ($this->FailedLoginCount >= self::config()->lock_out_after_incorrect_logins) {
1642
                $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...
1643
                $this->LockedOutUntil = date('Y-m-d H:i:s', DBDatetime::now()->getTimestamp() + $lockoutMins*60);
1644
                $this->FailedLoginCount = 0;
1645
            }
1646
        }
1647
        $this->extend('registerFailedLogin');
1648
        $this->write();
1649
    }
1650
1651
    /**
1652
     * Tell this member that a successful login has been made
1653
     */
1654
    public function registerSuccessfulLogin()
1655
    {
1656
        if (self::config()->lock_out_after_incorrect_logins) {
1657
            // Forgive all past login failures
1658
            $this->FailedLoginCount = 0;
1659
            $this->write();
1660
        }
1661
    }
1662
1663
    /**
1664
     * Get the HtmlEditorConfig for this user to be used in the CMS.
1665
     * This is set by the group. If multiple configurations are set,
1666
     * the one with the highest priority wins.
1667
     *
1668
     * @return string
1669
     */
1670
    public function getHtmlEditorConfigForCMS()
1671
    {
1672
        $currentName = '';
1673
        $currentPriority = 0;
1674
1675
        foreach ($this->Groups() as $group) {
1676
            $configName = $group->HtmlEditorConfig;
1677
            if ($configName) {
1678
                $config = HTMLEditorConfig::get($group->HtmlEditorConfig);
1679
                if ($config && $config->getOption('priority') > $currentPriority) {
1680
                    $currentName = $configName;
1681
                    $currentPriority = $config->getOption('priority');
1682
                }
1683
            }
1684
        }
1685
1686
        // If can't find a suitable editor, just default to cms
1687
        return $currentName ? $currentName : 'cms';
1688
    }
1689
1690
    public static function get_template_global_variables()
1691
    {
1692
        return array(
1693
            'CurrentMember' => 'currentUser',
1694
            'currentUser',
1695
        );
1696
    }
1697
}
1698