Completed
Pull Request — master (#7007)
by Simon
08:19
created

Member::get_title_sql()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 21
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 12
nc 4
nop 0
dl 0
loc 21
rs 9.3142
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\Security;
4
5
use IntlDateFormatter;
6
use InvalidArgumentException;
7
use SilverStripe\Admin\LeftAndMain;
8
use SilverStripe\CMS\Controllers\CMSMain;
9
use SilverStripe\Control\Controller;
10
use SilverStripe\Control\Director;
11
use SilverStripe\Control\Email\Email;
12
use SilverStripe\Control\Email\Mailer;
13
use SilverStripe\Core\Convert;
14
use SilverStripe\Core\Injector\Injector;
15
use SilverStripe\Dev\Deprecation;
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\ORM\ArrayList;
24
use SilverStripe\ORM\DataList;
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\Map;
31
use SilverStripe\ORM\SS_List;
32
use SilverStripe\ORM\ValidationException;
33
use SilverStripe\ORM\ValidationResult;
34
35
/**
36
 * The member class which represents the users of the system
37
 *
38
 * @method HasManyList LoggedPasswords()
39
 * @method HasManyList RememberLoginHashes()
40
 * @property string $FirstName
41
 * @property string $Surname
42
 * @property string $Email
43
 * @property string $Password
44
 * @property string $TempIDHash
45
 * @property string $TempIDExpired
46
 * @property string $AutoLoginHash
47
 * @property string $AutoLoginExpired
48
 * @property string $PasswordEncryption
49
 * @property string $Salt
50
 * @property string $PasswordExpiry
51
 * @property string $LockedOutUntil
52
 * @property string $Locale
53
 * @property int $FailedLoginCount
54
 * @property string $DateFormat
55
 * @property string $TimeFormat
56
 * @property string $SetPassword Pseudo-DB field for temp storage. Not emitted to DB
57
 */
58
class Member extends DataObject
59
{
60
61
    private static $db = array(
62
        'FirstName'          => 'Varchar',
63
        'Surname'            => 'Varchar',
64
        'Email'              => 'Varchar(254)', // See RFC 5321, Section 4.5.3.1.3. (256 minus the < and > character)
65
        'TempIDHash'         => 'Varchar(160)', // Temporary id used for cms re-authentication
66
        'TempIDExpired'      => 'Datetime', // Expiry of temp login
67
        'Password'           => 'Varchar(160)',
68
        'AutoLoginHash'      => 'Varchar(160)', // Used to auto-login the user on password reset
69
        'AutoLoginExpired'   => 'Datetime',
70
        // This is an arbitrary code pointing to a PasswordEncryptor instance,
71
        // not an actual encryption algorithm.
72
        // Warning: Never change this field after its the first password hashing without
73
        // providing a new cleartext password as well.
74
        'PasswordEncryption' => "Varchar(50)",
75
        'Salt'               => 'Varchar(50)',
76
        'PasswordExpiry'     => 'Date',
77
        'LockedOutUntil'     => 'Datetime',
78
        'Locale'             => 'Varchar(6)',
79
        // handled in registerFailedLogin(), only used if $lock_out_after_incorrect_logins is set
80
        'FailedLoginCount'   => 'Int',
81
    );
82
83
    private static $belongs_many_many = array(
84
        'Groups' => Group::class,
85
    );
86
87
    private static $has_many = array(
88
        'LoggedPasswords'     => MemberPassword::class,
89
        'RememberLoginHashes' => RememberLoginHash::class,
90
    );
91
92
    private static $table_name = "Member";
93
94
    private static $default_sort = '"Surname", "FirstName"';
95
96
    private static $indexes = array(
97
        'Email' => true,
98
        //Removed due to duplicate null values causing MSSQL problems
99
        //'AutoLoginHash' => Array('type'=>'unique', 'value'=>'AutoLoginHash', 'ignoreNulls'=>true)
0 ignored issues
show
Unused Code Comprehensibility introduced by
70% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
100
    );
101
102
    /**
103
     * @config
104
     * @var boolean
105
     */
106
    private static $notify_password_change = false;
107
108
    /**
109
     * All searchable database columns
110
     * in this object, currently queried
111
     * with a "column LIKE '%keywords%'
112
     * statement.
113
     *
114
     * @var array
115
     * @todo Generic implementation of $searchable_fields on DataObject,
116
     * with definition for different searching algorithms
117
     * (LIKE, FULLTEXT) and default FormFields to construct a searchform.
118
     */
119
    private static $searchable_fields = array(
120
        'FirstName',
121
        'Surname',
122
        'Email',
123
    );
124
125
    /**
126
     * @config
127
     * @var array
128
     */
129
    private static $summary_fields = array(
130
        'FirstName',
131
        'Surname',
132
        'Email',
133
    );
134
135
    /**
136
     * @config
137
     * @var array
138
     */
139
    private static $casting = array(
140
        'Name' => 'Varchar',
141
    );
142
143
    /**
144
     * Internal-use only fields
145
     *
146
     * @config
147
     * @var array
148
     */
149
    private static $hidden_fields = array(
150
        'AutoLoginHash',
151
        'AutoLoginExpired',
152
        'PasswordEncryption',
153
        'PasswordExpiry',
154
        'LockedOutUntil',
155
        'TempIDHash',
156
        'TempIDExpired',
157
        'Salt',
158
    );
159
160
    /**
161
     * @config
162
     * @var array See {@link set_title_columns()}
163
     */
164
    private static $title_format = null;
165
166
    /**
167
     * The unique field used to identify this member.
168
     * By default, it's "Email", but another common
169
     * field could be Username.
170
     *
171
     * @config
172
     * @var string
173
     * @skipUpgrade
174
     */
175
    private static $unique_identifier_field = 'Email';
176
177
    /**
178
     * Object for validating user's password
179
     *
180
     * @config
181
     * @var PasswordValidator
182
     */
183
    private static $password_validator = null;
184
185
    /**
186
     * @config
187
     * The number of days that a password should be valid for.
188
     * By default, this is null, which means that passwords never expire
189
     */
190
    private static $password_expiry_days = null;
191
192
    /**
193
     * @config
194
     * @var bool enable or disable logging of previously used passwords. See {@link onAfterWrite}
195
     */
196
    private static $password_logging_enabled = true;
197
198
    /**
199
     * @config
200
     * @var Int Number of incorrect logins after which
201
     * the user is blocked from further attempts for the timespan
202
     * defined in {@link $lock_out_delay_mins}.
203
     */
204
    private static $lock_out_after_incorrect_logins = 10;
205
206
    /**
207
     * @config
208
     * @var integer Minutes of enforced lockout after incorrect password attempts.
209
     * Only applies if {@link $lock_out_after_incorrect_logins} greater than 0.
210
     */
211
    private static $lock_out_delay_mins = 15;
212
213
    /**
214
     * @config
215
     * @var String If this is set, then a session cookie with the given name will be set on log-in,
216
     * and cleared on logout.
217
     */
218
    private static $login_marker_cookie = null;
219
220
    /**
221
     * Indicates that when a {@link Member} logs in, Member:session_regenerate_id()
222
     * should be called as a security precaution.
223
     *
224
     * This doesn't always work, especially if you're trying to set session cookies
225
     * across an entire site using the domain parameter to session_set_cookie_params()
226
     *
227
     * @config
228
     * @var boolean
229
     */
230
    private static $session_regenerate_id = true;
231
232
233
    /**
234
     * Default lifetime of temporary ids.
235
     *
236
     * This is the period within which a user can be re-authenticated within the CMS by entering only their password
237
     * and without losing their workspace.
238
     *
239
     * Any session expiration outside of this time will require them to login from the frontend using their full
240
     * username and password.
241
     *
242
     * Defaults to 72 hours. Set to zero to disable expiration.
243
     *
244
     * @config
245
     * @var int Lifetime in seconds
246
     */
247
    private static $temp_id_lifetime = 259200;
248
249
    /**
250
     * Ensure the locale is set to something sensible by default.
251
     */
252
    public function populateDefaults()
253
    {
254
        parent::populateDefaults();
255
        $this->Locale = i18n::get_closest_translation(i18n::get_locale());
256
    }
257
258
    public function requireDefaultRecords()
259
    {
260
        parent::requireDefaultRecords();
261
        // Default groups should've been built by Group->requireDefaultRecords() already
262
        static::default_admin();
263
    }
264
265
    /**
266
     * Get the default admin record if it exists, or creates it otherwise if enabled
267
     *
268
     * @return Member
269
     */
270
    public static function default_admin()
271
    {
272
        // Check if set
273
        if (!Security::has_default_admin()) {
274
            return null;
275
        }
276
277
        // Find or create ADMIN group
278
        Group::singleton()->requireDefaultRecords();
279
        $adminGroup = Permission::get_groups_by_permission('ADMIN')->first();
280
281
        // Find member
282
        /** @skipUpgrade */
283
        $admin = static::get()
284
            ->filter('Email', Security::default_admin_username())
285
            ->first();
286
        if (!$admin) {
287
            // 'Password' is not set to avoid creating
288
            // persistent logins in the database. See Security::setDefaultAdmin().
289
            // Set 'Email' to identify this as the default admin
290
            $admin = Member::create();
0 ignored issues
show
Coding Style introduced by
As per coding style, self should be used for accessing local static members.

This check looks for accesses to local static members using the fully qualified name instead of self::.

<?php

class Certificate {
    const TRIPLEDES_CBC = 'ASDFGHJKL';

    private $key;

    public function __construct()
    {
        $this->key = Certificate::TRIPLEDES_CBC;
    }
}

While this is perfectly valid, the fully qualified name of Certificate::TRIPLEDES_CBC could just as well be replaced by self::TRIPLEDES_CBC. Referencing local members with self:: assured the access will still work when the class is renamed, makes it perfectly clear that the member is in fact local and will usually be shorter.

Loading history...
291
            $admin->FirstName = _t(__CLASS__ . '.DefaultAdminFirstname', 'Default Admin');
292
            $admin->Email = Security::default_admin_username();
293
            $admin->write();
294
        }
295
296
        // Ensure this user is in the admin group
297
        if (!$admin->inGroup($adminGroup)) {
298
            // Add member to group instead of adding group to member
299
            // This bypasses the privilege escallation code in Member_GroupSet
300
            $adminGroup
301
                ->DirectMembers()
302
                ->add($admin);
303
        }
304
305
        return $admin;
306
    }
307
308
    /**
309
     * Check if the passed password matches the stored one (if the member is not locked out).
310
     *
311
     * @param  string $password
312
     * @return ValidationResult
313
     */
314
    public function checkPassword($password)
315
    {
316
        $result = $this->canLogIn();
317
318
        // Short-circuit the result upon failure, no further checks needed.
319
        if (!$result->isValid()) {
320
            return $result;
321
        }
322
323
        // Allow default admin to login as self
324
        if ($this->isDefaultAdmin() && Security::check_default_admin($this->Email, $password)) {
325
            return $result;
326
        }
327
328
        // Check a password is set on this member
329
        if (empty($this->Password) && $this->exists()) {
330
            $result->addError(_t(__CLASS__ . '.NoPassword', 'There is no password on this member.'));
331
332
            return $result;
333
        }
334
335
        $e = PasswordEncryptor::create_for_algorithm($this->PasswordEncryption);
336
        if (!$e->check($this->Password, $password, $this->Salt, $this)) {
337
            $result->addError(_t(
338
                __CLASS__ . '.ERRORWRONGCRED',
339
                'The provided details don\'t seem to be correct. Please try again.'
340
            ));
341
        }
342
343
        return $result;
344
    }
345
346
    /**
347
     * Check if this user is the currently configured default admin
348
     *
349
     * @return bool
350
     */
351
    public function isDefaultAdmin()
352
    {
353
        return Security::has_default_admin()
354
            && $this->Email === Security::default_admin_username();
355
    }
356
357
    /**
358
     * Returns a valid {@link ValidationResult} if this member can currently log in, or an invalid
359
     * one with error messages to display if the member is locked out.
360
     *
361
     * You can hook into this with a "canLogIn" method on an attached extension.
362
     *
363
     * @return ValidationResult
364
     */
365
    public function canLogIn()
366
    {
367
        $result = ValidationResult::create();
368
369
        if ($this->isLockedOut()) {
370
            $result->addError(
371
                _t(
372
                    __CLASS__ . '.ERRORLOCKEDOUT2',
373
                    'Your account has been temporarily disabled because of too many failed attempts at ' .
374
                    'logging in. Please try again in {count} minutes.',
375
                    null,
376
                    array('count' => static::config()->get('lock_out_delay_mins'))
377
                )
378
            );
379
        }
380
381
        $this->extend('canLogIn', $result);
382
383
        return $result;
384
    }
385
386
    /**
387
     * Returns true if this user is locked out
388
     *
389
     * @return bool
390
     */
391
    public function isLockedOut()
392
    {
393
        if (!$this->LockedOutUntil) {
394
            return false;
395
        }
396
397
        return DBDatetime::now()->getTimestamp() < $this->dbObject('LockedOutUntil')->getTimestamp();
398
    }
399
400
    /**
401
     * Set a {@link PasswordValidator} object to use to validate member's passwords.
402
     *
403
     * @param PasswordValidator $pv
404
     */
405
    public static function set_password_validator($pv)
406
    {
407
        self::$password_validator = $pv;
408
    }
409
410
    /**
411
     * Returns the current {@link PasswordValidator}
412
     *
413
     * @return PasswordValidator
414
     */
415
    public static function password_validator()
416
    {
417
        return self::$password_validator;
418
    }
419
420
421
    public function isPasswordExpired()
422
    {
423
        if (!$this->PasswordExpiry) {
424
            return false;
425
        }
426
427
        return strtotime(date('Y-m-d')) >= strtotime($this->PasswordExpiry);
428
    }
429
430
    /**
431
     * @deprecated 5.0.0 Use Security::setCurrentUser() or IdentityStore::logIn()
432
     *
433
     */
434
    public function logIn()
435
    {
436
        Deprecation::notice(
437
            '5.0.0',
438
            'This method is deprecated and only logs in for the current request. Please use Security::setCurrentUser($user) or an IdentityStore'
439
        );
440
        Security::setCurrentUser($this);
441
    }
442
443
    /**
444
     * Called before a member is logged in via session/cookie/etc
445
     */
446
    public function beforeMemberLoggedIn()
447
    {
448
        // @todo Move to middleware on the AuthenticationRequestFilter IdentityStore
449
        $this->extend('beforeMemberLoggedIn');
450
    }
451
452
    /**
453
     * Called after a member is logged in via session/cookie/etc
454
     */
455
    public function afterMemberLoggedIn()
456
    {
457
        // Clear the incorrect log-in count
458
        $this->registerSuccessfulLogin();
459
460
        $this->LockedOutUntil = null;
461
462
        $this->regenerateTempID();
463
464
        $this->write();
465
466
        // Audit logging hook
467
        $this->extend('afterMemberLoggedIn');
468
    }
469
470
    /**
471
     * Trigger regeneration of TempID.
472
     *
473
     * This should be performed any time the user presents their normal identification (normally Email)
474
     * and is successfully authenticated.
475
     */
476
    public function regenerateTempID()
477
    {
478
        $generator = new RandomGenerator();
479
        $lifetime = self::config()->get('temp_id_lifetime');
480
        $this->TempIDHash = $generator->randomToken('sha1');
481
        $this->TempIDExpired = $lifetime
482
            ? date('Y-m-d H:i:s', strtotime(DBDatetime::now()->getValue()) + $lifetime)
483
            : null;
484
        $this->write();
485
    }
486
487
    /**
488
     * Check if the member ID logged in session actually
489
     * has a database record of the same ID. If there is
490
     * no logged in user, FALSE is returned anyway.
491
     *
492
     * @deprecated Not needed anymore, as it returns Security::getCurrentUser();
493
     *
494
     * @return boolean TRUE record found FALSE no record found
495
     */
496
    public static function logged_in_session_exists()
497
    {
498
        Deprecation::notice(
499
            '5.0.0',
500
            'This method is deprecated and now does not add value. Please use Security::getCurrentUser()'
501
        );
502
503
        if ($member = Security::getCurrentUser()) {
504
            if ($member && $member->exists()) {
505
                return true;
506
            }
507
        }
508
509
        return false;
510
    }
511
512
    /**
513
     * @deprecated Use Security::setCurrentUser(null) or an IdentityStore
514
     * Logs this member out.
515
     */
516
    public function logOut()
517
    {
518
        Deprecation::notice(
519
            '5.0.0',
520
            'This method is deprecated and now does not persist. Please use Security::setCurrentUser(null) or an IdenityStore'
521
        );
522
523
        $this->extend('beforeMemberLoggedOut');
524
525
        Injector::inst()->get(IdentityStore::class)->logOut(Controller::curr()->getRequest());
526
        // Audit logging hook
527
        $this->extend('afterMemberLoggedOut');
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
552
        return $e->encrypt($string, $this->Salt);
553
    }
554
555
    /**
556
     * Generate an auto login token which can be used to reset the password,
557
     * at the same time hashing it and storing in the database.
558
     *
559
     * @param int $lifetime The lifetime of the auto login hash in days (by default 2 days)
560
     *
561
     * @returns string Token that should be passed to the client (but NOT persisted).
562
     *
563
     * @todo Make it possible to handle database errors such as a "duplicate key" error
564
     */
565
    public function generateAutologinTokenAndStoreHash($lifetime = 2)
566
    {
567
        do {
568
            $generator = new RandomGenerator();
569
            $token = $generator->randomToken();
570
            $hash = $this->encryptWithUserSettings($token);
571
        } while (DataObject::get_one(Member::class, array(
0 ignored issues
show
Coding Style introduced by
As per coding style, self should be used for accessing local static members.

This check looks for accesses to local static members using the fully qualified name instead of self::.

<?php

class Certificate {
    const TRIPLEDES_CBC = 'ASDFGHJKL';

    private $key;

    public function __construct()
    {
        $this->key = Certificate::TRIPLEDES_CBC;
    }
}

While this is perfectly valid, the fully qualified name of Certificate::TRIPLEDES_CBC could just as well be replaced by self::TRIPLEDES_CBC. Referencing local members with self:: assured the access will still work when the class is renamed, makes it perfectly clear that the member is in fact local and will usually be shorter.

Loading history...
572
            '"Member"."AutoLoginHash"' => $hash
573
        )));
574
575
        $this->AutoLoginHash = $hash;
576
        $this->AutoLoginExpired = date('Y-m-d H:i:s', time() + (86400 * $lifetime));
577
578
        $this->write();
579
580
        return $token;
581
    }
582
583
    /**
584
     * Check the token against the member.
585
     *
586
     * @param string $autologinToken
587
     *
588
     * @returns bool Is token valid?
589
     */
590
    public function validateAutoLoginToken($autologinToken)
591
    {
592
        $hash = $this->encryptWithUserSettings($autologinToken);
593
        $member = self::member_from_autologinhash($hash, false);
594
595
        return (bool)$member;
596
    }
597
598
    /**
599
     * Return the member for the auto login hash
600
     *
601
     * @param string $hash The hash key
602
     * @param bool $login Should the member be logged in?
603
     *
604
     * @return Member the matching member, if valid
605
     * @return Member
606
     */
607
    public static function member_from_autologinhash($hash, $login = false)
608
    {
609
        /** @var Member $member */
610
        $member = static::get()->filter([
611
            'AutoLoginHash'                => $hash,
612
            'AutoLoginExpired:GreaterThan' => DBDatetime::now()->getValue(),
613
        ])->first();
614
615
        if ($login && $member) {
616
            Injector::inst()->get(IdentityStore::class)->logIn($member);
617
        }
618
619
        return $member;
620
    }
621
622
    /**
623
     * Find a member record with the given TempIDHash value
624
     *
625
     * @param string $tempid
626
     * @return Member
627
     */
628
    public static function member_from_tempid($tempid)
629
    {
630
        $members = static::get()
631
            ->filter('TempIDHash', $tempid);
632
633
        // Exclude expired
634
        if (static::config()->get('temp_id_lifetime')) {
635
            /** @var DataList|Member[] $members */
636
            $members = $members->filter('TempIDExpired:GreaterThan', DBDatetime::now()->getValue());
637
        }
638
639
        return $members->first();
640
    }
641
642
    /**
643
     * Returns the fields for the member form - used in the registration/profile module.
644
     * It should return fields that are editable by the admin and the logged-in user.
645
     *
646
     * @todo possibly move this to an extension
647
     *
648
     * @return FieldList Returns a {@link FieldList} containing the fields for
649
     *                   the member form.
650
     */
651
    public function getMemberFormFields()
652
    {
653
        $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...
654
655
        $fields->replaceField('Password', $this->getMemberPasswordField());
656
657
        $fields->replaceField('Locale', new DropdownField(
658
            'Locale',
659
            $this->fieldLabel('Locale'),
660
            i18n::getSources()->getKnownLocales()
661
        ));
662
663
        $fields->removeByName(static::config()->get('hidden_fields'));
664
        $fields->removeByName('FailedLoginCount');
665
666
667
        $this->extend('updateMemberFormFields', $fields);
668
669
        return $fields;
670
    }
671
672
    /**
673
     * Builds "Change / Create Password" field for this member
674
     *
675
     * @return ConfirmedPasswordField
676
     */
677
    public function getMemberPasswordField()
678
    {
679
        $editingPassword = $this->isInDB();
680
        $label = $editingPassword
681
            ? _t(__CLASS__ . '.EDIT_PASSWORD', 'New Password')
682
            : $this->fieldLabel('Password');
683
        /** @var ConfirmedPasswordField $password */
684
        $password = ConfirmedPasswordField::create(
685
            'Password',
686
            $label,
687
            null,
688
            null,
689
            $editingPassword
690
        );
691
692
        // If editing own password, require confirmation of existing
693
        if ($editingPassword && $this->ID == Security::getCurrentUser()->ID) {
694
            $password->setRequireExistingPassword(true);
695
        }
696
697
        $password->setCanBeEmpty(true);
698
        $this->extend('updateMemberPasswordField', $password);
699
700
        return $password;
701
    }
702
703
704
    /**
705
     * Returns the {@link RequiredFields} instance for the Member object. This
706
     * Validator is used when saving a {@link CMSProfileController} or added to
707
     * any form responsible for saving a users data.
708
     *
709
     * To customize the required fields, add a {@link DataExtension} to member
710
     * calling the `updateValidator()` method.
711
     *
712
     * @return Member_Validator
713
     */
714
    public function getValidator()
715
    {
716
        $validator = Member_Validator::create();
717
        $validator->setForMember($this);
718
        $this->extend('updateValidator', $validator);
719
720
        return $validator;
721
    }
722
723
724
    /**
725
     * Returns the current logged in user
726
     *
727
     * @deprecated 5.0.0 use Security::getCurrentUser()
728
     *
729
     * @return Member
730
     */
731
    public static function currentUser()
732
    {
733
        Deprecation::notice(
734
            '5.0.0',
735
            'This method is deprecated. Please use Security::getCurrentUser() or an IdentityStore'
736
        );
737
738
        return Security::getCurrentUser();
739
    }
740
741
    /**
742
     * Temporarily act as the specified user, limited to a $callback, but
743
     * without logging in as that user.
744
     *
745
     * E.g.
746
     * <code>
747
     * Member::logInAs(Security::findAnAdministrator(), function() {
748
     *     $record->write();
749
     * });
750
     * </code>
751
     *
752
     * @param Member|null|int $member Member or member ID to log in as.
753
     * Set to null or 0 to act as a logged out user.
754
     * @param callable $callback
755
     */
756
    public static function actAs($member, $callback)
757
    {
758
        $previousUser = Security::getCurrentUser();
759
760
        // Transform ID to member
761
        if (is_numeric($member)) {
762
            $member = DataObject::get_by_id(Member::class, $member);
0 ignored issues
show
Coding Style introduced by
As per coding style, self should be used for accessing local static members.

This check looks for accesses to local static members using the fully qualified name instead of self::.

<?php

class Certificate {
    const TRIPLEDES_CBC = 'ASDFGHJKL';

    private $key;

    public function __construct()
    {
        $this->key = Certificate::TRIPLEDES_CBC;
    }
}

While this is perfectly valid, the fully qualified name of Certificate::TRIPLEDES_CBC could just as well be replaced by self::TRIPLEDES_CBC. Referencing local members with self:: assured the access will still work when the class is renamed, makes it perfectly clear that the member is in fact local and will usually be shorter.

Loading history...
763
        }
764
        Security::setCurrentUser($member);
0 ignored issues
show
Bug introduced by
It seems like $member can also be of type object<SilverStripe\ORM\DataObject>; however, SilverStripe\Security\Security::setCurrentUser() does only seem to accept null|object<SilverStripe\Security\Member>, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
765
766
        try {
767
            return $callback();
768
        } finally {
769
            Security::setCurrentUser($previousUser);
770
        }
771
    }
772
773
    /**
774
     * Get the ID of the current logged in user
775
     *
776
     * @deprecated 5.0.0 use Security::getCurrentUser()
777
     *
778
     * @return int Returns the ID of the current logged in user or 0.
779
     */
780
    public static function currentUserID()
781
    {
782
        Deprecation::notice(
783
            '5.0.0',
784
            'This method is deprecated. Please use Security::getCurrentUser() or an IdentityStore'
785
        );
786
787
        if ($member = Security::getCurrentUser()) {
788
            return $member->ID;
789
        } else {
790
            return 0;
791
        }
792
    }
793
794
    /**
795
     * Generate a random password, with randomiser to kick in if there's no words file on the
796
     * filesystem.
797
     *
798
     * @return string Returns a random password.
799
     */
800
    public static function create_new_password()
801
    {
802
        $words = Security::config()->uninherited('word_list');
803
804
        if ($words && file_exists($words)) {
805
            $words = file($words);
806
807
            list($usec, $sec) = explode(' ', microtime());
808
            mt_srand($sec + ((float)$usec * 100000));
809
810
            $word = trim($words[random_int(0, count($words) - 1)]);
811
            $number = random_int(10, 999);
812
813
            return $word . $number;
814
        } else {
815
            $random = mt_rand();
816
            $string = md5($random);
817
            $output = substr($string, 0, 8);
818
819
            return $output;
820
        }
821
    }
822
823
    /**
824
     * Event handler called before writing to the database.
825
     */
826
    public function onBeforeWrite()
827
    {
828
        if ($this->SetPassword) {
829
            $this->Password = $this->SetPassword;
830
        }
831
832
        // If a member with the same "unique identifier" already exists with a different ID, don't allow merging.
833
        // Note: This does not a full replacement for safeguards in the controller layer (e.g. in a registration form),
834
        // but rather a last line of defense against data inconsistencies.
835
        $identifierField = Member::config()->get('unique_identifier_field');
0 ignored issues
show
Coding Style introduced by
As per coding style, self should be used for accessing local static members.

This check looks for accesses to local static members using the fully qualified name instead of self::.

<?php

class Certificate {
    const TRIPLEDES_CBC = 'ASDFGHJKL';

    private $key;

    public function __construct()
    {
        $this->key = Certificate::TRIPLEDES_CBC;
    }
}

While this is perfectly valid, the fully qualified name of Certificate::TRIPLEDES_CBC could just as well be replaced by self::TRIPLEDES_CBC. Referencing local members with self:: assured the access will still work when the class is renamed, makes it perfectly clear that the member is in fact local and will usually be shorter.

Loading history...
836
        if ($this->$identifierField) {
837
            // Note: Same logic as Member_Validator class
838
            $filter = [
839
                "\"Member\".\"$identifierField\"" => $this->$identifierField
840
            ];
841
            if ($this->ID) {
842
                $filter[] = array('"Member"."ID" <> ?' => $this->ID);
843
            }
844
            $existingRecord = DataObject::get_one(Member::class, $filter);
0 ignored issues
show
Coding Style introduced by
As per coding style, self should be used for accessing local static members.

This check looks for accesses to local static members using the fully qualified name instead of self::.

<?php

class Certificate {
    const TRIPLEDES_CBC = 'ASDFGHJKL';

    private $key;

    public function __construct()
    {
        $this->key = Certificate::TRIPLEDES_CBC;
    }
}

While this is perfectly valid, the fully qualified name of Certificate::TRIPLEDES_CBC could just as well be replaced by self::TRIPLEDES_CBC. Referencing local members with self:: assured the access will still work when the class is renamed, makes it perfectly clear that the member is in fact local and will usually be shorter.

Loading history...
845
846
            if ($existingRecord) {
847
                throw new ValidationException(_t(
848
                    __CLASS__ . '.ValidationIdentifierFailed',
849
                    'Can\'t overwrite existing member #{id} with identical identifier ({name} = {value}))',
850
                    'Values in brackets show "fieldname = value", usually denoting an existing email address',
851
                    array(
852
                        'id'    => $existingRecord->ID,
853
                        'name'  => $identifierField,
854
                        'value' => $this->$identifierField
855
                    )
856
                ));
857
            }
858
        }
859
860
        // We don't send emails out on dev/tests sites to prevent accidentally spamming users.
861
        // However, if TestMailer is in use this isn't a risk.
862
        // @todo some developers use external tools, so emailing might be a good idea anyway
863
        if ((Director::isLive() || Injector::inst()->get(Mailer::class) instanceof TestMailer)
864
            && $this->isChanged('Password')
865
            && $this->record['Password']
866
            && static::config()->get('notify_password_change')
867
        ) {
868
            Email::create()
869
                ->setHTMLTemplate('SilverStripe\\Control\\Email\\ChangePasswordEmail')
870
                ->setData($this)
871
                ->setTo($this->Email)
872
                ->setSubject(_t(__CLASS__ . '.SUBJECTPASSWORDCHANGED', "Your password has been changed", 'Email subject'))
873
                ->send();
874
        }
875
876
        // The test on $this->ID is used for when records are initially created.
877
        // Note that this only works with cleartext passwords, as we can't rehash
878
        // existing passwords.
879
        if ((!$this->ID && $this->Password) || $this->isChanged('Password')) {
880
            //reset salt so that it gets regenerated - this will invalidate any persistent login cookies
881
            // or other information encrypted with this Member's settings (see self::encryptWithUserSettings)
882
            $this->Salt = '';
883
            // Password was changed: encrypt the password according the settings
884
            $encryption_details = Security::encrypt_password(
885
                $this->Password, // this is assumed to be cleartext
886
                $this->Salt,
887
                ($this->PasswordEncryption) ?
888
                    $this->PasswordEncryption : Security::config()->get('password_encryption_algorithm'),
889
                $this
890
            );
891
892
            // Overwrite the Password property with the hashed value
893
            $this->Password = $encryption_details['password'];
894
            $this->Salt = $encryption_details['salt'];
895
            $this->PasswordEncryption = $encryption_details['algorithm'];
896
897
            // If we haven't manually set a password expiry
898
            if (!$this->isChanged('PasswordExpiry')) {
899
                // then set it for us
900
                if (static::config()->get('password_expiry_days')) {
901
                    $this->PasswordExpiry = date('Y-m-d', time() + 86400 * static::config()->get('password_expiry_days'));
902
                } else {
903
                    $this->PasswordExpiry = null;
904
                }
905
            }
906
        }
907
908
        // save locale
909
        if (!$this->Locale) {
910
            $this->Locale = i18n::get_locale();
911
        }
912
913
        parent::onBeforeWrite();
914
    }
915
916
    public function onAfterWrite()
917
    {
918
        parent::onAfterWrite();
919
920
        Permission::reset();
921
922
        if ($this->isChanged('Password') && static::config()->get('password_logging_enabled')) {
923
            MemberPassword::log($this);
924
        }
925
    }
926
927
    public function onAfterDelete()
928
    {
929
        parent::onAfterDelete();
930
931
        //prevent orphaned records remaining in the DB
932
        $this->deletePasswordLogs();
933
    }
934
935
    /**
936
     * Delete the MemberPassword objects that are associated to this user
937
     *
938
     * @return $this
939
     */
940
    protected function deletePasswordLogs()
941
    {
942
        foreach ($this->LoggedPasswords() as $password) {
943
            $password->delete();
944
            $password->destroy();
945
        }
946
947
        return $this;
948
    }
949
950
    /**
951
     * Filter out admin groups to avoid privilege escalation,
952
     * If any admin groups are requested, deny the whole save operation.
953
     *
954
     * @param array $ids Database IDs of Group records
955
     * @return bool True if the change can be accepted
956
     */
957
    public function onChangeGroups($ids)
958
    {
959
        // unless the current user is an admin already OR the logged in user is an admin
960
        if (Permission::check('ADMIN') || Permission::checkMember($this, 'ADMIN')) {
961
            return true;
962
        }
963
964
        // If there are no admin groups in this set then it's ok
965
        $adminGroups = Permission::get_groups_by_permission('ADMIN');
966
        $adminGroupIDs = ($adminGroups) ? $adminGroups->column('ID') : array();
967
968
        return count(array_intersect($ids, $adminGroupIDs)) == 0;
969
    }
970
971
972
    /**
973
     * Check if the member is in one of the given groups.
974
     *
975
     * @param array|SS_List $groups Collection of {@link Group} DataObjects to check
976
     * @param boolean $strict Only determine direct group membership if set to true (Default: false)
977
     * @return bool Returns TRUE if the member is in one of the given groups, otherwise FALSE.
978
     */
979
    public function inGroups($groups, $strict = false)
980
    {
981
        if ($groups) {
982
            foreach ($groups as $group) {
983
                if ($this->inGroup($group, $strict)) {
984
                    return true;
985
                }
986
            }
987
        }
988
989
        return false;
990
    }
991
992
993
    /**
994
     * Check if the member is in the given group or any parent groups.
995
     *
996
     * @param int|Group|string $group Group instance, Group Code or ID
997
     * @param boolean $strict Only determine direct group membership if set to TRUE (Default: FALSE)
998
     * @return bool Returns TRUE if the member is in the given group, otherwise FALSE.
999
     */
1000
    public function inGroup($group, $strict = false)
1001
    {
1002
        if (is_numeric($group)) {
1003
            $groupCheckObj = DataObject::get_by_id(Group::class, $group);
1004
        } elseif (is_string($group)) {
1005
            $groupCheckObj = DataObject::get_one(Group::class, array(
1006
                '"Group"."Code"' => $group
1007
            ));
1008
        } elseif ($group instanceof Group) {
1009
            $groupCheckObj = $group;
1010
        } else {
1011
            throw new InvalidArgumentException('Member::inGroup(): Wrong format for $group parameter');
1012
        }
1013
1014
        if (!$groupCheckObj) {
1015
            return false;
1016
        }
1017
1018
        $groupCandidateObjs = ($strict) ? $this->getManyManyComponents("Groups") : $this->Groups();
1019
        if ($groupCandidateObjs) {
1020
            foreach ($groupCandidateObjs as $groupCandidateObj) {
1021
                if ($groupCandidateObj->ID == $groupCheckObj->ID) {
1022
                    return true;
1023
                }
1024
            }
1025
        }
1026
1027
        return false;
1028
    }
1029
1030
    /**
1031
     * Adds the member to a group. This will create the group if the given
1032
     * group code does not return a valid group object.
1033
     *
1034
     * @param string $groupcode
1035
     * @param string $title Title of the group
1036
     */
1037
    public function addToGroupByCode($groupcode, $title = "")
1038
    {
1039
        $group = DataObject::get_one(Group::class, array(
1040
            '"Group"."Code"' => $groupcode
1041
        ));
1042
1043
        if ($group) {
1044
            $this->Groups()->add($group);
1045
        } else {
1046
            if (!$title) {
1047
                $title = $groupcode;
1048
            }
1049
1050
            $group = new Group();
1051
            $group->Code = $groupcode;
1052
            $group->Title = $title;
1053
            $group->write();
1054
1055
            $this->Groups()->add($group);
1056
        }
1057
    }
1058
1059
    /**
1060
     * Removes a member from a group.
1061
     *
1062
     * @param string $groupcode
1063
     */
1064
    public function removeFromGroupByCode($groupcode)
1065
    {
1066
        $group = Group::get()->filter(array('Code' => $groupcode))->first();
1067
1068
        if ($group) {
1069
            $this->Groups()->remove($group);
1070
        }
1071
    }
1072
1073
    /**
1074
     * @param array $columns Column names on the Member record to show in {@link getTitle()}.
1075
     * @param String $sep Separator
1076
     */
1077
    public static function set_title_columns($columns, $sep = ' ')
1078
    {
1079
        Deprecation::notice('5.0', 'Use Member.title_format config instead');
1080
        if (!is_array($columns)) {
1081
            $columns = array($columns);
1082
        }
1083
        self::config()->set(
1084
            'title_format',
1085
            [
1086
                'columns' => $columns,
1087
                'sep' => $sep
1088
            ]
1089
        );
1090
    }
1091
1092
    //------------------- HELPER METHODS -----------------------------------//
1093
1094
    /**
1095
     * Get the complete name of the member, by default in the format "<Surname>, <FirstName>".
1096
     * Falls back to showing either field on its own.
1097
     *
1098
     * You can overload this getter with {@link set_title_format()}
1099
     * and {@link set_title_sql()}.
1100
     *
1101
     * @return string Returns the first- and surname of the member. If the ID
1102
     *  of the member is equal 0, only the surname is returned.
1103
     */
1104
    public function getTitle()
1105
    {
1106
        $format = static::config()->get('title_format');
1107
        if ($format) {
1108
            $values = array();
1109
            foreach ($format['columns'] as $col) {
1110
                $values[] = $this->getField($col);
1111
            }
1112
1113
            return implode($format['sep'], $values);
1114
        }
1115
        if ($this->getField('ID') === 0) {
1116
            return $this->getField('Surname');
1117
        } else {
1118
            if ($this->getField('Surname') && $this->getField('FirstName')) {
1119
                return $this->getField('Surname') . ', ' . $this->getField('FirstName');
1120
            } elseif ($this->getField('Surname')) {
1121
                return $this->getField('Surname');
1122
            } elseif ($this->getField('FirstName')) {
1123
                return $this->getField('FirstName');
1124
            } else {
1125
                return null;
1126
            }
1127
        }
1128
    }
1129
1130
    /**
1131
     * Return a SQL CONCAT() fragment suitable for a SELECT statement.
1132
     * Useful for custom queries which assume a certain member title format.
1133
     *
1134
     * @return String SQL
1135
     */
1136
    public static function get_title_sql()
1137
    {
1138
1139
        // Get title_format with fallback to default
1140
        $format = static::config()->get('title_format');
1141
        if (!$format) {
1142
            $format = [
1143
                'columns' => ['Surname', 'FirstName'],
1144
                'sep'     => ' ',
1145
            ];
1146
        }
1147
1148
        $columnsWithTablename = array();
1149
        foreach ($format['columns'] as $column) {
1150
            $columnsWithTablename[] = static::getSchema()->sqlColumnForField(__CLASS__, $column);
1151
        }
1152
1153
        $sepSQL = Convert::raw2sql($format['sep'], true);
1154
        $op = DB::get_conn()->concatOperator();
1155
        return "(" . join(" $op $sepSQL $op ", $columnsWithTablename) . ")";
1156
    }
1157
1158
1159
    /**
1160
     * Get the complete name of the member
1161
     *
1162
     * @return string Returns the first- and surname of the member.
1163
     */
1164
    public function getName()
1165
    {
1166
        return ($this->Surname) ? trim($this->FirstName . ' ' . $this->Surname) : $this->FirstName;
1167
    }
1168
1169
1170
    /**
1171
     * Set first- and surname
1172
     *
1173
     * This method assumes that the last part of the name is the surname, e.g.
1174
     * <i>A B C</i> will result in firstname <i>A B</i> and surname <i>C</i>
1175
     *
1176
     * @param string $name The name
1177
     */
1178
    public function setName($name)
1179
    {
1180
        $nameParts = explode(' ', $name);
1181
        $this->Surname = array_pop($nameParts);
1182
        $this->FirstName = join(' ', $nameParts);
1183
    }
1184
1185
1186
    /**
1187
     * Alias for {@link setName}
1188
     *
1189
     * @param string $name The name
1190
     * @see setName()
1191
     */
1192
    public function splitName($name)
1193
    {
1194
        return $this->setName($name);
1195
    }
1196
1197
    /**
1198
     * Return the date 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
    public function getDateFormat()
1204
    {
1205
        $formatter = new IntlDateFormatter(
1206
            $this->getLocale(),
1207
            IntlDateFormatter::MEDIUM,
1208
            IntlDateFormatter::NONE
1209
        );
1210
        $format = $formatter->getPattern();
1211
1212
        $this->extend('updateDateFormat', $format);
1213
1214
        return $format;
1215
    }
1216
1217
    /**
1218
     * Get user locale
1219
     */
1220
    public function getLocale()
1221
    {
1222
        $locale = $this->getField('Locale');
1223
        if ($locale) {
1224
            return $locale;
1225
        }
1226
1227
        return i18n::get_locale();
1228
    }
1229
1230
    /**
1231
     * Return the time format based on the user's chosen locale,
1232
     * falling back to the default format defined by the {@link i18n.get_locale()} setting.
1233
     *
1234
     * @return string ISO date format
1235
     */
1236
    public function getTimeFormat()
1237
    {
1238
        $formatter = new IntlDateFormatter(
1239
            $this->getLocale(),
1240
            IntlDateFormatter::NONE,
1241
            IntlDateFormatter::MEDIUM
1242
        );
1243
        $format = $formatter->getPattern();
1244
1245
        $this->extend('updateTimeFormat', $format);
1246
1247
        return $format;
1248
    }
1249
1250
    //---------------------------------------------------------------------//
1251
1252
1253
    /**
1254
     * Get a "many-to-many" map that holds for all members their group memberships,
1255
     * including any parent groups where membership is implied.
1256
     * Use {@link DirectGroups()} to only retrieve the group relations without inheritance.
1257
     *
1258
     * @todo Push all this logic into Member_GroupSet's getIterator()?
1259
     * @return Member_Groupset
1260
     */
1261
    public function Groups()
1262
    {
1263
        $groups = Member_GroupSet::create(Group::class, 'Group_Members', 'GroupID', 'MemberID');
1264
        $groups = $groups->forForeignID($this->ID);
1265
1266
        $this->extend('updateGroups', $groups);
1267
1268
        return $groups;
1269
    }
1270
1271
    /**
1272
     * @return ManyManyList
1273
     */
1274
    public function DirectGroups()
1275
    {
1276
        return $this->getManyManyComponents('Groups');
1277
    }
1278
1279
    /**
1280
     * Get a member SQLMap of members in specific groups
1281
     *
1282
     * If no $groups is passed, all members will be returned
1283
     *
1284
     * @param mixed $groups - takes a SS_List, an array or a single Group.ID
1285
     * @return Map Returns an Map that returns all Member data.
1286
     */
1287
    public static function map_in_groups($groups = null)
1288
    {
1289
        $groupIDList = array();
1290
1291
        if ($groups instanceof SS_List) {
1292
            foreach ($groups as $group) {
1293
                $groupIDList[] = $group->ID;
1294
            }
1295
        } elseif (is_array($groups)) {
1296
            $groupIDList = $groups;
1297
        } elseif ($groups) {
1298
            $groupIDList[] = $groups;
1299
        }
1300
1301
        // No groups, return all Members
1302
        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...
1303
            return static::get()->sort(array('Surname' => 'ASC', 'FirstName' => 'ASC'))->map();
1304
        }
1305
1306
        $membersList = new ArrayList();
1307
        // This is a bit ineffective, but follow the ORM style
1308
        /** @var Group $group */
1309
        foreach (Group::get()->byIDs($groupIDList) as $group) {
1310
            $membersList->merge($group->Members());
1311
        }
1312
1313
        $membersList->removeDuplicates('ID');
1314
1315
        return $membersList->map();
1316
    }
1317
1318
1319
    /**
1320
     * Get a map of all members in the groups given that have CMS permissions
1321
     *
1322
     * If no groups are passed, all groups with CMS permissions will be used.
1323
     *
1324
     * @param array $groups Groups to consider or NULL to use all groups with
1325
     *                      CMS permissions.
1326
     * @return Map Returns a map of all members in the groups given that
1327
     *                have CMS permissions.
1328
     */
1329
    public static function mapInCMSGroups($groups = null)
1330
    {
1331
        // Check CMS module exists
1332
        if (!class_exists(LeftAndMain::class)) {
1333
            return ArrayList::create()->map();
1334
        }
1335
1336
        if (count($groups) == 0) {
1337
            $perms = array('ADMIN', 'CMS_ACCESS_AssetAdmin');
1338
1339
            if (class_exists(CMSMain::class)) {
1340
                $cmsPerms = CMSMain::singleton()->providePermissions();
1341
            } else {
1342
                $cmsPerms = LeftAndMain::singleton()->providePermissions();
1343
            }
1344
1345
            if (!empty($cmsPerms)) {
1346
                $perms = array_unique(array_merge($perms, array_keys($cmsPerms)));
1347
            }
1348
1349
            $permsClause = DB::placeholders($perms);
1350
            /** @skipUpgrade */
1351
            $groups = Group::get()
1352
                ->innerJoin("Permission", '"Permission"."GroupID" = "Group"."ID"')
1353
                ->where(array(
1354
                    "\"Permission\".\"Code\" IN ($permsClause)" => $perms
1355
                ));
1356
        }
1357
1358
        $groupIDList = array();
1359
1360
        if ($groups instanceof SS_List) {
1361
            foreach ($groups as $group) {
1362
                $groupIDList[] = $group->ID;
1363
            }
1364
        } elseif (is_array($groups)) {
1365
            $groupIDList = $groups;
1366
        }
1367
1368
        /** @skipUpgrade */
1369
        $members = static::get()
1370
            ->innerJoin("Group_Members", '"Group_Members"."MemberID" = "Member"."ID"')
1371
            ->innerJoin("Group", '"Group"."ID" = "Group_Members"."GroupID"');
1372
        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...
1373
            $groupClause = DB::placeholders($groupIDList);
1374
            $members = $members->where(array(
1375
                "\"Group\".\"ID\" IN ($groupClause)" => $groupIDList
1376
            ));
1377
        }
1378
1379
        return $members->sort('"Member"."Surname", "Member"."FirstName"')->map();
1380
    }
1381
1382
1383
    /**
1384
     * Get the groups in which the member is NOT in
1385
     *
1386
     * When passed an array of groups, and a component set of groups, this
1387
     * function will return the array of groups the member is NOT in.
1388
     *
1389
     * @param array $groupList An array of group code names.
1390
     * @param array $memberGroups A component set of groups (if set to NULL,
1391
     *                            $this->groups() will be used)
1392
     * @return array Groups in which the member is NOT in.
1393
     */
1394
    public function memberNotInGroups($groupList, $memberGroups = null)
1395
    {
1396
        if (!$memberGroups) {
1397
            $memberGroups = $this->Groups();
1398
        }
1399
1400
        foreach ($memberGroups as $group) {
1401
            if (in_array($group->Code, $groupList)) {
1402
                $index = array_search($group->Code, $groupList);
1403
                unset($groupList[$index]);
1404
            }
1405
        }
1406
1407
        return $groupList;
1408
    }
1409
1410
1411
    /**
1412
     * Return a {@link FieldList} of fields that would appropriate for editing
1413
     * this member.
1414
     *
1415
     * @return FieldList Return a FieldList of fields that would appropriate for
1416
     *                   editing this member.
1417
     */
1418
    public function getCMSFields()
1419
    {
1420
        $this->beforeUpdateCMSFields(function (FieldList $fields) {
1421
            /** @var FieldList $mainFields */
1422
            $mainFields = $fields->fieldByName("Root")->fieldByName("Main")->getChildren();
1423
1424
            // Build change password field
1425
            $mainFields->replaceField('Password', $this->getMemberPasswordField());
1426
1427
            $mainFields->replaceField('Locale', new DropdownField(
1428
                "Locale",
1429
                _t(__CLASS__ . '.INTERFACELANG', "Interface Language", 'Language of the CMS'),
1430
                i18n::getSources()->getKnownLocales()
1431
            ));
1432
            $mainFields->removeByName(static::config()->get('hidden_fields'));
1433
1434
            if (!static::config()->get('lock_out_after_incorrect_logins')) {
1435
                $mainFields->removeByName('FailedLoginCount');
1436
            }
1437
1438
            // Groups relation will get us into logical conflicts because
1439
            // Members are displayed within  group edit form in SecurityAdmin
1440
            $fields->removeByName('Groups');
1441
1442
            // Members shouldn't be able to directly view/edit logged passwords
1443
            $fields->removeByName('LoggedPasswords');
1444
1445
            $fields->removeByName('RememberLoginHashes');
1446
1447
            if (Permission::check('EDIT_PERMISSIONS')) {
1448
                $groupsMap = array();
1449
                foreach (Group::get() as $group) {
1450
                    // Listboxfield values are escaped, use ASCII char instead of &raquo;
1451
                    $groupsMap[$group->ID] = $group->getBreadcrumbs(' > ');
1452
                }
1453
                asort($groupsMap);
1454
                $fields->addFieldToTab(
1455
                    'Root.Main',
1456
                    ListboxField::create('DirectGroups', Group::singleton()->i18n_plural_name())
1457
                        ->setSource($groupsMap)
1458
                        ->setAttribute(
1459
                            'data-placeholder',
1460
                            _t(__CLASS__ . '.ADDGROUP', 'Add group', 'Placeholder text for a dropdown')
1461
                        )
1462
                );
1463
1464
1465
                // Add permission field (readonly to avoid complicated group assignment logic).
1466
                // This should only be available for existing records, as new records start
1467
                // with no permissions until they have a group assignment anyway.
1468
                if ($this->ID) {
1469
                    $permissionsField = new PermissionCheckboxSetField_Readonly(
1470
                        'Permissions',
1471
                        false,
1472
                        Permission::class,
1473
                        'GroupID',
1474
                        // we don't want parent relationships, they're automatically resolved in the field
1475
                        $this->getManyManyComponents('Groups')
1476
                    );
1477
                    $fields->findOrMakeTab('Root.Permissions', Permission::singleton()->i18n_plural_name());
1478
                    $fields->addFieldToTab('Root.Permissions', $permissionsField);
1479
                }
1480
            }
1481
1482
            $permissionsTab = $fields->fieldByName("Root")->fieldByName('Permissions');
1483
            if ($permissionsTab) {
1484
                $permissionsTab->addExtraClass('readonly');
1485
            }
1486
        });
1487
1488
        return parent::getCMSFields();
1489
    }
1490
1491
    /**
1492
     * @param bool $includerelations Indicate if the labels returned include relation fields
1493
     * @return array
1494
     */
1495
    public function fieldLabels($includerelations = true)
1496
    {
1497
        $labels = parent::fieldLabels($includerelations);
1498
1499
        $labels['FirstName'] = _t(__CLASS__ . '.FIRSTNAME', 'First Name');
1500
        $labels['Surname'] = _t(__CLASS__ . '.SURNAME', 'Surname');
1501
        /** @skipUpgrade */
1502
        $labels['Email'] = _t(__CLASS__ . '.EMAIL', 'Email');
1503
        $labels['Password'] = _t(__CLASS__ . '.db_Password', 'Password');
1504
        $labels['PasswordExpiry'] = _t(__CLASS__ . '.db_PasswordExpiry', 'Password Expiry Date', 'Password expiry date');
1505
        $labels['LockedOutUntil'] = _t(__CLASS__ . '.db_LockedOutUntil', 'Locked out until', 'Security related date');
1506
        $labels['Locale'] = _t(__CLASS__ . '.db_Locale', 'Interface Locale');
1507
        if ($includerelations) {
1508
            $labels['Groups'] = _t(
1509
                __CLASS__ . '.belongs_many_many_Groups',
1510
                'Groups',
1511
                'Security Groups this member belongs to'
1512
            );
1513
        }
1514
1515
        return $labels;
1516
    }
1517
1518
    /**
1519
     * Users can view their own record.
1520
     * Otherwise they'll need ADMIN or CMS_ACCESS_SecurityAdmin permissions.
1521
     * This is likely to be customized for social sites etc. with a looser permission model.
1522
     *
1523
     * @param Member $member
1524
     * @return bool
1525
     */
1526
    public function canView($member = null)
1527
    {
1528
        //get member
1529
        if (!$member) {
1530
            $member = Security::getCurrentUser();
1531
        }
1532
        //check for extensions, we do this first as they can overrule everything
1533
        $extended = $this->extendedCan(__FUNCTION__, $member);
0 ignored issues
show
Bug introduced by
It seems like $member can be null; however, extendedCan() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
1534
        if ($extended !== null) {
1535
            return $extended;
1536
        }
1537
1538
        //need to be logged in and/or most checks below rely on $member being a Member
1539
        if (!$member) {
1540
            return false;
1541
        }
1542
        // members can usually view their own record
1543
        if ($this->ID == $member->ID) {
1544
            return true;
1545
        }
1546
1547
        //standard check
1548
        return Permission::checkMember($member, 'CMS_ACCESS_SecurityAdmin');
1549
    }
1550
1551
    /**
1552
     * Users can edit their own record.
1553
     * Otherwise they'll need ADMIN or CMS_ACCESS_SecurityAdmin permissions
1554
     *
1555
     * @param Member $member
1556
     * @return bool
1557
     */
1558
    public function canEdit($member = null)
1559
    {
1560
        //get member
1561
        if (!$member) {
1562
            $member = Security::getCurrentUser();
1563
        }
1564
        //check for extensions, we do this first as they can overrule everything
1565
        $extended = $this->extendedCan(__FUNCTION__, $member);
0 ignored issues
show
Bug introduced by
It seems like $member can be null; however, extendedCan() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
1566
        if ($extended !== null) {
1567
            return $extended;
1568
        }
1569
1570
        //need to be logged in and/or most checks below rely on $member being a Member
1571
        if (!$member) {
1572
            return false;
1573
        }
1574
1575
        // HACK: we should not allow for an non-Admin to edit an Admin
1576
        if (!Permission::checkMember($member, 'ADMIN') && Permission::checkMember($this, 'ADMIN')) {
1577
            return false;
1578
        }
1579
        // members can usually edit their own record
1580
        if ($this->ID == $member->ID) {
1581
            return true;
1582
        }
1583
1584
        //standard check
1585
        return Permission::checkMember($member, 'CMS_ACCESS_SecurityAdmin');
1586
    }
1587
1588
    /**
1589
     * Users can edit their own record.
1590
     * Otherwise they'll need ADMIN or CMS_ACCESS_SecurityAdmin permissions
1591
     *
1592
     * @param Member $member
1593
     * @return bool
1594
     */
1595
    public function canDelete($member = null)
1596
    {
1597
        if (!$member) {
1598
            $member = Security::getCurrentUser();
1599
        }
1600
        //check for extensions, we do this first as they can overrule everything
1601
        $extended = $this->extendedCan(__FUNCTION__, $member);
0 ignored issues
show
Bug introduced by
It seems like $member can be null; however, extendedCan() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
1602
        if ($extended !== null) {
1603
            return $extended;
1604
        }
1605
1606
        //need to be logged in and/or most checks below rely on $member being a Member
1607
        if (!$member) {
1608
            return false;
1609
        }
1610
        // Members are not allowed to remove themselves,
1611
        // since it would create inconsistencies in the admin UIs.
1612
        if ($this->ID && $member->ID == $this->ID) {
1613
            return false;
1614
        }
1615
1616
        // HACK: if you want to delete a member, you have to be a member yourself.
1617
        // this is a hack because what this should do is to stop a user
1618
        // deleting a member who has more privileges (e.g. a non-Admin deleting an Admin)
1619
        if (Permission::checkMember($this, 'ADMIN')) {
1620
            if (!Permission::checkMember($member, 'ADMIN')) {
1621
                return false;
1622
            }
1623
        }
1624
1625
        //standard check
1626
        return Permission::checkMember($member, 'CMS_ACCESS_SecurityAdmin');
1627
    }
1628
1629
    /**
1630
     * Validate this member object.
1631
     */
1632
    public function validate()
1633
    {
1634
        $valid = parent::validate();
1635
1636
        if (!$this->ID || $this->isChanged('Password')) {
1637
            if ($this->Password && self::$password_validator) {
1638
                $valid->combineAnd(self::$password_validator->validate($this->Password, $this));
1639
            }
1640
        }
1641
1642
        if ((!$this->ID && $this->SetPassword) || $this->isChanged('SetPassword')) {
1643
            if ($this->SetPassword && self::$password_validator) {
1644
                $valid->combineAnd(self::$password_validator->validate($this->SetPassword, $this));
1645
            }
1646
        }
1647
1648
        return $valid;
1649
    }
1650
1651
    /**
1652
     * Change password. This will cause rehashing according to
1653
     * the `PasswordEncryption` property.
1654
     *
1655
     * @param string $password Cleartext password
1656
     * @return ValidationResult
1657
     */
1658
    public function changePassword($password)
1659
    {
1660
        $this->Password = $password;
1661
        $valid = $this->validate();
1662
1663
        if ($valid->isValid()) {
1664
            $this->AutoLoginHash = null;
1665
            $this->write();
1666
        }
1667
1668
        return $valid;
1669
    }
1670
1671
    /**
1672
     * Tell this member that someone made a failed attempt at logging in as them.
1673
     * This can be used to lock the user out temporarily if too many failed attempts are made.
1674
     */
1675
    public function registerFailedLogin()
1676
    {
1677
        $lockOutAfterCount = self::config()->get('lock_out_after_incorrect_logins');
1678
        if ($lockOutAfterCount) {
1679
            // Keep a tally of the number of failed log-ins so that we can lock people out
1680
            $this->FailedLoginCount = $this->FailedLoginCount + 1;
1681
1682
            if ($this->FailedLoginCount >= $lockOutAfterCount) {
1683
                $lockoutMins = self::config()->get('lock_out_delay_mins');
1684
                $this->LockedOutUntil = date('Y-m-d H:i:s', DBDatetime::now()->getTimestamp() + $lockoutMins * 60);
1685
                $this->FailedLoginCount = 0;
1686
            }
1687
        }
1688
        $this->extend('registerFailedLogin');
1689
        $this->write();
1690
    }
1691
1692
    /**
1693
     * Tell this member that a successful login has been made
1694
     */
1695
    public function registerSuccessfulLogin()
1696
    {
1697
        if (self::config()->get('lock_out_after_incorrect_logins')) {
1698
            // Forgive all past login failures
1699
            $this->FailedLoginCount = 0;
1700
            $this->write();
1701
        }
1702
    }
1703
1704
    /**
1705
     * Get the HtmlEditorConfig for this user to be used in the CMS.
1706
     * This is set by the group. If multiple configurations are set,
1707
     * the one with the highest priority wins.
1708
     *
1709
     * @return string
1710
     */
1711
    public function getHtmlEditorConfigForCMS()
1712
    {
1713
        $currentName = '';
1714
        $currentPriority = 0;
1715
1716
        foreach ($this->Groups() as $group) {
1717
            $configName = $group->HtmlEditorConfig;
1718
            if ($configName) {
1719
                $config = HTMLEditorConfig::get($group->HtmlEditorConfig);
1720
                if ($config && $config->getOption('priority') > $currentPriority) {
1721
                    $currentName = $configName;
1722
                    $currentPriority = $config->getOption('priority');
1723
                }
1724
            }
1725
        }
1726
1727
        // If can't find a suitable editor, just default to cms
1728
        return $currentName ? $currentName : 'cms';
1729
    }
1730
}
1731