Completed
Pull Request — master (#7026)
by Damian
08:24
created

Member::onAfterWrite()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 5
nc 2
nop 0
dl 0
loc 10
rs 9.4285
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\Forms\Tab;
23
use SilverStripe\Forms\TabSet;
24
use SilverStripe\i18n\i18n;
25
use SilverStripe\ORM\ArrayList;
26
use SilverStripe\ORM\DataList;
27
use SilverStripe\ORM\DataObject;
28
use SilverStripe\ORM\DB;
29
use SilverStripe\ORM\FieldType\DBDatetime;
30
use SilverStripe\ORM\HasManyList;
31
use SilverStripe\ORM\ManyManyList;
32
use SilverStripe\ORM\Map;
33
use SilverStripe\ORM\SS_List;
34
use SilverStripe\ORM\ValidationException;
35
use SilverStripe\ORM\ValidationResult;
36
37
/**
38
 * The member class which represents the users of the system
39
 *
40
 * @method HasManyList LoggedPasswords()
41
 * @method HasManyList RememberLoginHashes()
42
 * @property string $FirstName
43
 * @property string $Surname
44
 * @property string $Email
45
 * @property string $Password
46
 * @property string $TempIDHash
47
 * @property string $TempIDExpired
48
 * @property string $AutoLoginHash
49
 * @property string $AutoLoginExpired
50
 * @property string $PasswordEncryption
51
 * @property string $Salt
52
 * @property string $PasswordExpiry
53
 * @property string $LockedOutUntil
54
 * @property string $Locale
55
 * @property int $FailedLoginCount
56
 * @property string $DateFormat
57
 * @property string $TimeFormat
58
 * @property string $SetPassword Pseudo-DB field for temp storage. Not emitted to DB
59
 */
60
class Member extends DataObject
61
{
62
63
    private static $db = array(
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(
86
        'Groups' => Group::class,
87
    );
88
89
    private static $has_many = array(
90
        'LoggedPasswords'     => MemberPassword::class,
91
        'RememberLoginHashes' => RememberLoginHash::class,
92
    );
93
94
    private static $table_name = "Member";
95
96
    private static $default_sort = '"Surname", "FirstName"';
97
98
    private static $indexes = array(
99
        'Email' => true,
100
        //Removed due to duplicate null values causing MSSQL problems
101
        //'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...
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(
122
        'FirstName',
123
        'Surname',
124
        'Email',
125
    );
126
127
    /**
128
     * @config
129
     * @var array
130
     */
131
    private static $summary_fields = array(
132
        'FirstName',
133
        'Surname',
134
        'Email',
135
    );
136
137
    /**
138
     * @config
139
     * @var array
140
     */
141
    private static $casting = array(
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 bool enable or disable logging of previously used passwords. See {@link onAfterWrite}
197
     */
198
    private static $password_logging_enabled = true;
199
200
    /**
201
     * @config
202
     * @var Int Number of incorrect logins after which
203
     * the user is blocked from further attempts for the timespan
204
     * defined in {@link $lock_out_delay_mins}.
205
     */
206
    private static $lock_out_after_incorrect_logins = 10;
207
208
    /**
209
     * @config
210
     * @var integer Minutes of enforced lockout after incorrect password attempts.
211
     * Only applies if {@link $lock_out_after_incorrect_logins} greater than 0.
212
     */
213
    private static $lock_out_delay_mins = 15;
214
215
    /**
216
     * @config
217
     * @var String If this is set, then a session cookie with the given name will be set on log-in,
218
     * and cleared on logout.
219
     */
220
    private static $login_marker_cookie = null;
221
222
    /**
223
     * Indicates that when a {@link Member} logs in, Member:session_regenerate_id()
224
     * should be called as a security precaution.
225
     *
226
     * This doesn't always work, especially if you're trying to set session cookies
227
     * across an entire site using the domain parameter to session_set_cookie_params()
228
     *
229
     * @config
230
     * @var boolean
231
     */
232
    private static $session_regenerate_id = true;
233
234
235
    /**
236
     * Default lifetime of temporary ids.
237
     *
238
     * This is the period within which a user can be re-authenticated within the CMS by entering only their password
239
     * and without losing their workspace.
240
     *
241
     * Any session expiration outside of this time will require them to login from the frontend using their full
242
     * username and password.
243
     *
244
     * Defaults to 72 hours. Set to zero to disable expiration.
245
     *
246
     * @config
247
     * @var int Lifetime in seconds
248
     */
249
    private static $temp_id_lifetime = 259200;
250
251
    /**
252
     * Ensure the locale is set to something sensible by default.
253
     */
254
    public function populateDefaults()
255
    {
256
        parent::populateDefaults();
257
        $this->Locale = i18n::get_closest_translation(i18n::get_locale());
258
    }
259
260
    public function requireDefaultRecords()
261
    {
262
        parent::requireDefaultRecords();
263
        // Default groups should've been built by Group->requireDefaultRecords() already
264
        $service = DefaultAdminService::singleton();
265
        $service->findOrCreateDefaultAdmin();
266
    }
267
268
    /**
269
     * Get the default admin record if it exists, or creates it otherwise if enabled
270
     *
271
     * @deprecated 4.0.0...5.0.0 Use DefaultAdminService::findOrCreateDefaultAdmin() instead
272
     * @return Member
273
     */
274
    public static function default_admin()
275
    {
276
        Deprecation::notice('5.0', 'Use DefaultAdminService::findOrCreateDefaultAdmin() instead');
277
        return DefaultAdminService::singleton()->findOrCreateDefaultAdmin();
278
    }
279
280
    /**
281
     * Check if the passed password matches the stored one (if the member is not locked out).
282
     *
283
     * @deprecated 4.0.0...5.0.0 Use Authenticator::checkPassword() instead
284
     *
285
     * @param string $password
286
     * @return ValidationResult
287
     */
288
    public function checkPassword($password)
289
    {
290
        Deprecation::notice('5.0', 'Use Authenticator::checkPassword() instead');
291
292
        // With a valid user and password, check the password is correct
293
        $result = ValidationResult::create();
294
        $authenticators = Security::singleton()->getApplicableAuthenticators(Authenticator::CHECK_PASSWORD);
295
        foreach ($authenticators as $authenticator) {
296
            $authenticator->checkPassword($this, $password, $result);
297
            if (!$result->isValid()) {
298
                break;
299
            }
300
        }
301
        return $result;
302
    }
303
304
    /**
305
     * Check if this user is the currently configured default admin
306
     *
307
     * @return bool
308
     */
309
    public function isDefaultAdmin()
310
    {
311
        return DefaultAdminService::isDefaultAdmin($this->Email);
312
    }
313
314
    /**
315
     * Check if this user can login
316
     *
317
     * @return bool
318
     */
319
    public function canLogin()
320
    {
321
        return $this->validateCanLogin()->isValid();
322
    }
323
324
    /**
325
     * Returns a valid {@link ValidationResult} if this member can currently log in, or an invalid
326
     * one with error messages to display if the member is locked out.
327
     *
328
     * You can hook into this with a "canLogIn" method on an attached extension.
329
     *
330
     * @param ValidationResult $result Optional result to add errors to
331
     * @return ValidationResult
332
     */
333
    public function validateCanLogin(ValidationResult &$result = null)
334
    {
335
        $result = $result ?: ValidationResult::create();
336
        if ($this->isLockedOut()) {
337
            $result->addError(
338
                _t(
339
                    __CLASS__ . '.ERRORLOCKEDOUT2',
340
                    'Your account has been temporarily disabled because of too many failed attempts at ' .
341
                    'logging in. Please try again in {count} minutes.',
342
                    null,
343
                    array('count' => static::config()->get('lock_out_delay_mins'))
344
                )
345
            );
346
        }
347
348
        $this->extend('canLogIn', $result);
349
350
        return $result;
351
    }
352
353
    /**
354
     * Returns true if this user is locked out
355
     *
356
     * @return bool
357
     */
358
    public function isLockedOut()
359
    {
360
        if (!$this->LockedOutUntil) {
361
            return false;
362
        }
363
364
        /** @var DBDatetime $lockedOutUntil */
365
        $lockedOutUntil = $this->dbObject('LockedOutUntil');
366
        return DBDatetime::now()->getTimestamp() < $lockedOutUntil->getTimestamp();
367
    }
368
369
    /**
370
     * Set a {@link PasswordValidator} object to use to validate member's passwords.
371
     *
372
     * @param PasswordValidator $pv
373
     */
374
    public static function set_password_validator($pv)
375
    {
376
        self::$password_validator = $pv;
377
    }
378
379
    /**
380
     * Returns the current {@link PasswordValidator}
381
     *
382
     * @return PasswordValidator
383
     */
384
    public static function password_validator()
385
    {
386
        return self::$password_validator;
387
    }
388
389
390
    public function isPasswordExpired()
391
    {
392
        if (!$this->PasswordExpiry) {
393
            return false;
394
        }
395
396
        return strtotime(date('Y-m-d')) >= strtotime($this->PasswordExpiry);
397
    }
398
399
    /**
400
     * @deprecated 5.0.0 Use Security::setCurrentUser() or IdentityStore::logIn()
401
     *
402
     */
403
    public function logIn()
404
    {
405
        Deprecation::notice(
406
            '5.0.0',
407
            'This method is deprecated and only logs in for the current request. Please use Security::setCurrentUser($user) or an IdentityStore'
408
        );
409
        Security::setCurrentUser($this);
410
    }
411
412
    /**
413
     * Called before a member is logged in via session/cookie/etc
414
     */
415
    public function beforeMemberLoggedIn()
416
    {
417
        // @todo Move to middleware on the AuthenticationRequestFilter IdentityStore
418
        $this->extend('beforeMemberLoggedIn');
419
    }
420
421
    /**
422
     * Called after a member is logged in via session/cookie/etc
423
     */
424
    public function afterMemberLoggedIn()
425
    {
426
        // Clear the incorrect log-in count
427
        $this->registerSuccessfulLogin();
428
429
        $this->LockedOutUntil = null;
430
431
        $this->regenerateTempID();
432
433
        $this->write();
434
435
        // Audit logging hook
436
        $this->extend('afterMemberLoggedIn');
437
    }
438
439
    /**
440
     * Trigger regeneration of TempID.
441
     *
442
     * This should be performed any time the user presents their normal identification (normally Email)
443
     * and is successfully authenticated.
444
     */
445
    public function regenerateTempID()
446
    {
447
        $generator = new RandomGenerator();
448
        $lifetime = self::config()->get('temp_id_lifetime');
449
        $this->TempIDHash = $generator->randomToken('sha1');
450
        $this->TempIDExpired = $lifetime
451
            ? date('Y-m-d H:i:s', strtotime(DBDatetime::now()->getValue()) + $lifetime)
452
            : null;
453
        $this->write();
454
    }
455
456
    /**
457
     * Check if the member ID logged in session actually
458
     * has a database record of the same ID. If there is
459
     * no logged in user, FALSE is returned anyway.
460
     *
461
     * @deprecated Not needed anymore, as it returns Security::getCurrentUser();
462
     *
463
     * @return boolean TRUE record found FALSE no record found
464
     */
465
    public static function logged_in_session_exists()
466
    {
467
        Deprecation::notice(
468
            '5.0.0',
469
            'This method is deprecated and now does not add value. Please use Security::getCurrentUser()'
470
        );
471
472
        if ($member = Security::getCurrentUser()) {
473
            if ($member && $member->exists()) {
474
                return true;
475
            }
476
        }
477
478
        return false;
479
    }
480
481
    /**
482
     * @deprecated Use Security::setCurrentUser(null) or an IdentityStore
483
     * Logs this member out.
484
     */
485
    public function logOut()
486
    {
487
        Deprecation::notice(
488
            '5.0.0',
489
            'This method is deprecated and now does not persist. Please use Security::setCurrentUser(null) or an IdenityStore'
490
        );
491
492
        $this->extend('beforeMemberLoggedOut');
493
494
        Injector::inst()->get(IdentityStore::class)->logOut(Controller::curr()->getRequest());
495
        // Audit logging hook
496
        $this->extend('afterMemberLoggedOut');
497
    }
498
499
    /**
500
     * Utility for generating secure password hashes for this member.
501
     *
502
     * @param string $string
503
     * @return string
504
     * @throws PasswordEncryptor_NotFoundException
505
     */
506
    public function encryptWithUserSettings($string)
507
    {
508
        if (!$string) {
509
            return null;
510
        }
511
512
        // If the algorithm or salt is not available, it means we are operating
513
        // on legacy account with unhashed password. Do not hash the string.
514
        if (!$this->PasswordEncryption) {
515
            return $string;
516
        }
517
518
        // We assume we have PasswordEncryption and Salt available here.
519
        $e = PasswordEncryptor::create_for_algorithm($this->PasswordEncryption);
520
521
        return $e->encrypt($string, $this->Salt);
522
    }
523
524
    /**
525
     * Generate an auto login token which can be used to reset the password,
526
     * at the same time hashing it and storing in the database.
527
     *
528
     * @param int $lifetime The lifetime of the auto login hash in days (by default 2 days)
529
     *
530
     * @returns string Token that should be passed to the client (but NOT persisted).
531
     *
532
     * @todo Make it possible to handle database errors such as a "duplicate key" error
533
     */
534
    public function generateAutologinTokenAndStoreHash($lifetime = 2)
535
    {
536
        do {
537
            $generator = new RandomGenerator();
538
            $token = $generator->randomToken();
539
            $hash = $this->encryptWithUserSettings($token);
540
        } 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...
541
            '"Member"."AutoLoginHash"' => $hash
542
        )));
543
544
        $this->AutoLoginHash = $hash;
545
        $this->AutoLoginExpired = date('Y-m-d H:i:s', time() + (86400 * $lifetime));
546
547
        $this->write();
548
549
        return $token;
550
    }
551
552
    /**
553
     * Check the token against the member.
554
     *
555
     * @param string $autologinToken
556
     *
557
     * @returns bool Is token valid?
558
     */
559
    public function validateAutoLoginToken($autologinToken)
560
    {
561
        $hash = $this->encryptWithUserSettings($autologinToken);
562
        $member = self::member_from_autologinhash($hash, false);
563
564
        return (bool)$member;
565
    }
566
567
    /**
568
     * Return the member for the auto login hash
569
     *
570
     * @param string $hash The hash key
571
     * @param bool $login Should the member be logged in?
572
     *
573
     * @return Member the matching member, if valid
574
     * @return Member
575
     */
576
    public static function member_from_autologinhash($hash, $login = false)
577
    {
578
        /** @var Member $member */
579
        $member = static::get()->filter([
580
            'AutoLoginHash'                => $hash,
581
            'AutoLoginExpired:GreaterThan' => DBDatetime::now()->getValue(),
582
        ])->first();
583
584
        if ($login && $member) {
585
            Injector::inst()->get(IdentityStore::class)->logIn($member);
586
        }
587
588
        return $member;
589
    }
590
591
    /**
592
     * Find a member record with the given TempIDHash value
593
     *
594
     * @param string $tempid
595
     * @return Member
596
     */
597
    public static function member_from_tempid($tempid)
598
    {
599
        $members = static::get()
600
            ->filter('TempIDHash', $tempid);
601
602
        // Exclude expired
603
        if (static::config()->get('temp_id_lifetime')) {
604
            /** @var DataList|Member[] $members */
605
            $members = $members->filter('TempIDExpired:GreaterThan', DBDatetime::now()->getValue());
606
        }
607
608
        return $members->first();
609
    }
610
611
    /**
612
     * Returns the fields for the member form - used in the registration/profile module.
613
     * It should return fields that are editable by the admin and the logged-in user.
614
     *
615
     * @todo possibly move this to an extension
616
     *
617
     * @return FieldList Returns a {@link FieldList} containing the fields for
618
     *                   the member form.
619
     */
620
    public function getMemberFormFields()
621
    {
622
        $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...
623
624
        $fields->replaceField('Password', $this->getMemberPasswordField());
625
626
        $fields->replaceField('Locale', new DropdownField(
627
            'Locale',
628
            $this->fieldLabel('Locale'),
629
            i18n::getSources()->getKnownLocales()
630
        ));
631
632
        $fields->removeByName(static::config()->get('hidden_fields'));
633
        $fields->removeByName('FailedLoginCount');
634
635
636
        $this->extend('updateMemberFormFields', $fields);
637
638
        return $fields;
639
    }
640
641
    /**
642
     * Builds "Change / Create Password" field for this member
643
     *
644
     * @return ConfirmedPasswordField
645
     */
646
    public function getMemberPasswordField()
647
    {
648
        $editingPassword = $this->isInDB();
649
        $label = $editingPassword
650
            ? _t(__CLASS__ . '.EDIT_PASSWORD', 'New Password')
651
            : $this->fieldLabel('Password');
652
        /** @var ConfirmedPasswordField $password */
653
        $password = ConfirmedPasswordField::create(
654
            'Password',
655
            $label,
656
            null,
657
            null,
658
            $editingPassword
659
        );
660
661
        // If editing own password, require confirmation of existing
662
        if ($editingPassword && $this->ID == Security::getCurrentUser()->ID) {
663
            $password->setRequireExistingPassword(true);
664
        }
665
666
        $password->setCanBeEmpty(true);
667
        $this->extend('updateMemberPasswordField', $password);
668
669
        return $password;
670
    }
671
672
673
    /**
674
     * Returns the {@link RequiredFields} instance for the Member object. This
675
     * Validator is used when saving a {@link CMSProfileController} or added to
676
     * any form responsible for saving a users data.
677
     *
678
     * To customize the required fields, add a {@link DataExtension} to member
679
     * calling the `updateValidator()` method.
680
     *
681
     * @return Member_Validator
682
     */
683
    public function getValidator()
684
    {
685
        $validator = Member_Validator::create();
686
        $validator->setForMember($this);
687
        $this->extend('updateValidator', $validator);
688
689
        return $validator;
690
    }
691
692
693
    /**
694
     * Returns the current logged in user
695
     *
696
     * @deprecated 5.0.0 use Security::getCurrentUser()
697
     *
698
     * @return Member
699
     */
700
    public static function currentUser()
701
    {
702
        Deprecation::notice(
703
            '5.0.0',
704
            'This method is deprecated. Please use Security::getCurrentUser() or an IdentityStore'
705
        );
706
707
        return Security::getCurrentUser();
708
    }
709
710
    /**
711
     * Temporarily act as the specified user, limited to a $callback, but
712
     * without logging in as that user.
713
     *
714
     * E.g.
715
     * <code>
716
     * Member::logInAs(Security::findAnAdministrator(), function() {
717
     *     $record->write();
718
     * });
719
     * </code>
720
     *
721
     * @param Member|null|int $member Member or member ID to log in as.
722
     * Set to null or 0 to act as a logged out user.
723
     * @param callable $callback
724
     */
725
    public static function actAs($member, $callback)
726
    {
727
        $previousUser = Security::getCurrentUser();
728
729
        // Transform ID to member
730
        if (is_numeric($member)) {
731
            $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...
732
        }
733
        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...
734
735
        try {
736
            return $callback();
737
        } finally {
738
            Security::setCurrentUser($previousUser);
739
        }
740
    }
741
742
    /**
743
     * Get the ID of the current logged in user
744
     *
745
     * @deprecated 5.0.0 use Security::getCurrentUser()
746
     *
747
     * @return int Returns the ID of the current logged in user or 0.
748
     */
749
    public static function currentUserID()
750
    {
751
        Deprecation::notice(
752
            '5.0.0',
753
            'This method is deprecated. Please use Security::getCurrentUser() or an IdentityStore'
754
        );
755
756
        if ($member = Security::getCurrentUser()) {
757
            return $member->ID;
758
        } else {
759
            return 0;
760
        }
761
    }
762
763
    /**
764
     * Generate a random password, with randomiser to kick in if there's no words file on the
765
     * filesystem.
766
     *
767
     * @return string Returns a random password.
768
     */
769
    public static function create_new_password()
770
    {
771
        $words = Security::config()->uninherited('word_list');
772
773
        if ($words && file_exists($words)) {
774
            $words = file($words);
775
776
            list($usec, $sec) = explode(' ', microtime());
777
            mt_srand($sec + ((float)$usec * 100000));
778
779
            $word = trim($words[random_int(0, count($words) - 1)]);
780
            $number = random_int(10, 999);
781
782
            return $word . $number;
783
        } else {
784
            $random = mt_rand();
785
            $string = md5($random);
786
            $output = substr($string, 0, 8);
787
788
            return $output;
789
        }
790
    }
791
792
    /**
793
     * Event handler called before writing to the database.
794
     */
795
    public function onBeforeWrite()
796
    {
797
        if ($this->SetPassword) {
798
            $this->Password = $this->SetPassword;
799
        }
800
801
        // If a member with the same "unique identifier" already exists with a different ID, don't allow merging.
802
        // Note: This does not a full replacement for safeguards in the controller layer (e.g. in a registration form),
803
        // but rather a last line of defense against data inconsistencies.
804
        $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...
805
        if ($this->$identifierField) {
806
            // Note: Same logic as Member_Validator class
807
            $filter = [
808
                "\"Member\".\"$identifierField\"" => $this->$identifierField
809
            ];
810
            if ($this->ID) {
811
                $filter[] = array('"Member"."ID" <> ?' => $this->ID);
812
            }
813
            $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...
814
815
            if ($existingRecord) {
816
                throw new ValidationException(_t(
817
                    __CLASS__ . '.ValidationIdentifierFailed',
818
                    'Can\'t overwrite existing member #{id} with identical identifier ({name} = {value}))',
819
                    'Values in brackets show "fieldname = value", usually denoting an existing email address',
820
                    array(
821
                        'id'    => $existingRecord->ID,
822
                        'name'  => $identifierField,
823
                        'value' => $this->$identifierField
824
                    )
825
                ));
826
            }
827
        }
828
829
        // We don't send emails out on dev/tests sites to prevent accidentally spamming users.
830
        // However, if TestMailer is in use this isn't a risk.
831
        // @todo some developers use external tools, so emailing might be a good idea anyway
832
        if ((Director::isLive() || Injector::inst()->get(Mailer::class) instanceof TestMailer)
833
            && $this->isChanged('Password')
834
            && $this->record['Password']
835
            && static::config()->get('notify_password_change')
836
        ) {
837
            Email::create()
838
                ->setHTMLTemplate('SilverStripe\\Control\\Email\\ChangePasswordEmail')
839
                ->setData($this)
840
                ->setTo($this->Email)
841
                ->setSubject(_t(__CLASS__ . '.SUBJECTPASSWORDCHANGED', "Your password has been changed", 'Email subject'))
842
                ->send();
843
        }
844
845
        // The test on $this->ID is used for when records are initially created.
846
        // Note that this only works with cleartext passwords, as we can't rehash
847
        // existing passwords.
848
        if ((!$this->ID && $this->Password) || $this->isChanged('Password')) {
849
            //reset salt so that it gets regenerated - this will invalidate any persistent login cookies
850
            // or other information encrypted with this Member's settings (see self::encryptWithUserSettings)
851
            $this->Salt = '';
852
            // Password was changed: encrypt the password according the settings
853
            $encryption_details = Security::encrypt_password(
854
                $this->Password, // this is assumed to be cleartext
855
                $this->Salt,
856
                ($this->PasswordEncryption) ?
857
                    $this->PasswordEncryption : Security::config()->get('password_encryption_algorithm'),
858
                $this
859
            );
860
861
            // Overwrite the Password property with the hashed value
862
            $this->Password = $encryption_details['password'];
863
            $this->Salt = $encryption_details['salt'];
864
            $this->PasswordEncryption = $encryption_details['algorithm'];
865
866
            // If we haven't manually set a password expiry
867
            if (!$this->isChanged('PasswordExpiry')) {
868
                // then set it for us
869
                if (static::config()->get('password_expiry_days')) {
870
                    $this->PasswordExpiry = date('Y-m-d', time() + 86400 * static::config()->get('password_expiry_days'));
871
                } else {
872
                    $this->PasswordExpiry = null;
873
                }
874
            }
875
        }
876
877
        // save locale
878
        if (!$this->Locale) {
879
            $this->Locale = i18n::get_locale();
880
        }
881
882
        parent::onBeforeWrite();
883
    }
884
885
    public function onAfterWrite()
886
    {
887
        parent::onAfterWrite();
888
889
        Permission::reset();
890
891
        if ($this->isChanged('Password') && static::config()->get('password_logging_enabled')) {
892
            MemberPassword::log($this);
893
        }
894
    }
895
896
    public function onAfterDelete()
897
    {
898
        parent::onAfterDelete();
899
900
        //prevent orphaned records remaining in the DB
901
        $this->deletePasswordLogs();
902
    }
903
904
    /**
905
     * Delete the MemberPassword objects that are associated to this user
906
     *
907
     * @return $this
908
     */
909
    protected function deletePasswordLogs()
910
    {
911
        foreach ($this->LoggedPasswords() as $password) {
912
            $password->delete();
913
            $password->destroy();
914
        }
915
916
        return $this;
917
    }
918
919
    /**
920
     * Filter out admin groups to avoid privilege escalation,
921
     * If any admin groups are requested, deny the whole save operation.
922
     *
923
     * @param array $ids Database IDs of Group records
924
     * @return bool True if the change can be accepted
925
     */
926
    public function onChangeGroups($ids)
927
    {
928
        // unless the current user is an admin already OR the logged in user is an admin
929
        if (Permission::check('ADMIN') || Permission::checkMember($this, 'ADMIN')) {
930
            return true;
931
        }
932
933
        // If there are no admin groups in this set then it's ok
934
        $adminGroups = Permission::get_groups_by_permission('ADMIN');
935
        $adminGroupIDs = ($adminGroups) ? $adminGroups->column('ID') : array();
936
937
        return count(array_intersect($ids, $adminGroupIDs)) == 0;
938
    }
939
940
941
    /**
942
     * Check if the member is in one of the given groups.
943
     *
944
     * @param array|SS_List $groups Collection of {@link Group} DataObjects to check
945
     * @param boolean $strict Only determine direct group membership if set to true (Default: false)
946
     * @return bool Returns TRUE if the member is in one of the given groups, otherwise FALSE.
947
     */
948
    public function inGroups($groups, $strict = false)
949
    {
950
        if ($groups) {
951
            foreach ($groups as $group) {
952
                if ($this->inGroup($group, $strict)) {
953
                    return true;
954
                }
955
            }
956
        }
957
958
        return false;
959
    }
960
961
962
    /**
963
     * Check if the member is in the given group or any parent groups.
964
     *
965
     * @param int|Group|string $group Group instance, Group Code or ID
966
     * @param boolean $strict Only determine direct group membership if set to TRUE (Default: FALSE)
967
     * @return bool Returns TRUE if the member is in the given group, otherwise FALSE.
968
     */
969
    public function inGroup($group, $strict = false)
970
    {
971
        if (is_numeric($group)) {
972
            $groupCheckObj = DataObject::get_by_id(Group::class, $group);
973
        } elseif (is_string($group)) {
974
            $groupCheckObj = DataObject::get_one(Group::class, array(
975
                '"Group"."Code"' => $group
976
            ));
977
        } elseif ($group instanceof Group) {
978
            $groupCheckObj = $group;
979
        } else {
980
            throw new InvalidArgumentException('Member::inGroup(): Wrong format for $group parameter');
981
        }
982
983
        if (!$groupCheckObj) {
984
            return false;
985
        }
986
987
        $groupCandidateObjs = ($strict) ? $this->getManyManyComponents("Groups") : $this->Groups();
988
        if ($groupCandidateObjs) {
989
            foreach ($groupCandidateObjs as $groupCandidateObj) {
990
                if ($groupCandidateObj->ID == $groupCheckObj->ID) {
991
                    return true;
992
                }
993
            }
994
        }
995
996
        return false;
997
    }
998
999
    /**
1000
     * Adds the member to a group. This will create the group if the given
1001
     * group code does not return a valid group object.
1002
     *
1003
     * @param string $groupcode
1004
     * @param string $title Title of the group
1005
     */
1006
    public function addToGroupByCode($groupcode, $title = "")
1007
    {
1008
        $group = DataObject::get_one(Group::class, array(
1009
            '"Group"."Code"' => $groupcode
1010
        ));
1011
1012
        if ($group) {
1013
            $this->Groups()->add($group);
1014
        } else {
1015
            if (!$title) {
1016
                $title = $groupcode;
1017
            }
1018
1019
            $group = new Group();
1020
            $group->Code = $groupcode;
1021
            $group->Title = $title;
1022
            $group->write();
1023
1024
            $this->Groups()->add($group);
1025
        }
1026
    }
1027
1028
    /**
1029
     * Removes a member from a group.
1030
     *
1031
     * @param string $groupcode
1032
     */
1033
    public function removeFromGroupByCode($groupcode)
1034
    {
1035
        $group = Group::get()->filter(array('Code' => $groupcode))->first();
1036
1037
        if ($group) {
1038
            $this->Groups()->remove($group);
1039
        }
1040
    }
1041
1042
    /**
1043
     * @param array $columns Column names on the Member record to show in {@link getTitle()}.
1044
     * @param String $sep Separator
1045
     */
1046
    public static function set_title_columns($columns, $sep = ' ')
1047
    {
1048
        Deprecation::notice('5.0', 'Use Member.title_format config instead');
1049
        if (!is_array($columns)) {
1050
            $columns = array($columns);
1051
        }
1052
        self::config()->set(
1053
            'title_format',
1054
            [
1055
                'columns' => $columns,
1056
                'sep' => $sep
1057
            ]
1058
        );
1059
    }
1060
1061
    //------------------- HELPER METHODS -----------------------------------//
1062
1063
    /**
1064
     * Get the complete name of the member, by default in the format "<Surname>, <FirstName>".
1065
     * Falls back to showing either field on its own.
1066
     *
1067
     * You can overload this getter with {@link set_title_format()}
1068
     * and {@link set_title_sql()}.
1069
     *
1070
     * @return string Returns the first- and surname of the member. If the ID
1071
     *  of the member is equal 0, only the surname is returned.
1072
     */
1073
    public function getTitle()
1074
    {
1075
        $format = static::config()->get('title_format');
1076
        if ($format) {
1077
            $values = array();
1078
            foreach ($format['columns'] as $col) {
1079
                $values[] = $this->getField($col);
1080
            }
1081
1082
            return implode($format['sep'], $values);
1083
        }
1084
        if ($this->getField('ID') === 0) {
1085
            return $this->getField('Surname');
1086
        } else {
1087
            if ($this->getField('Surname') && $this->getField('FirstName')) {
1088
                return $this->getField('Surname') . ', ' . $this->getField('FirstName');
1089
            } elseif ($this->getField('Surname')) {
1090
                return $this->getField('Surname');
1091
            } elseif ($this->getField('FirstName')) {
1092
                return $this->getField('FirstName');
1093
            } else {
1094
                return null;
1095
            }
1096
        }
1097
    }
1098
1099
    /**
1100
     * Return a SQL CONCAT() fragment suitable for a SELECT statement.
1101
     * Useful for custom queries which assume a certain member title format.
1102
     *
1103
     * @return String SQL
1104
     */
1105
    public static function get_title_sql()
1106
    {
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
        $op = DB::get_conn()->concatOperator();
1124
        return "(" . join(" $op $sepSQL $op ", $columnsWithTablename) . ")";
1125
    }
1126
1127
1128
    /**
1129
     * Get the complete name of the member
1130
     *
1131
     * @return string Returns the first- and surname of the member.
1132
     */
1133
    public function getName()
1134
    {
1135
        return ($this->Surname) ? trim($this->FirstName . ' ' . $this->Surname) : $this->FirstName;
1136
    }
1137
1138
1139
    /**
1140
     * Set first- and surname
1141
     *
1142
     * This method assumes that the last part of the name is the surname, e.g.
1143
     * <i>A B C</i> will result in firstname <i>A B</i> and surname <i>C</i>
1144
     *
1145
     * @param string $name The name
1146
     */
1147
    public function setName($name)
1148
    {
1149
        $nameParts = explode(' ', $name);
1150
        $this->Surname = array_pop($nameParts);
1151
        $this->FirstName = join(' ', $nameParts);
1152
    }
1153
1154
1155
    /**
1156
     * Alias for {@link setName}
1157
     *
1158
     * @param string $name The name
1159
     * @see setName()
1160
     */
1161
    public function splitName($name)
1162
    {
1163
        return $this->setName($name);
1164
    }
1165
1166
    /**
1167
     * Return the date format based on the user's chosen locale,
1168
     * falling back to the default format defined by the {@link i18n.get_locale()} setting.
1169
     *
1170
     * @return string ISO date format
1171
     */
1172
    public function getDateFormat()
1173
    {
1174
        $formatter = new IntlDateFormatter(
1175
            $this->getLocale(),
1176
            IntlDateFormatter::MEDIUM,
1177
            IntlDateFormatter::NONE
1178
        );
1179
        $format = $formatter->getPattern();
1180
1181
        $this->extend('updateDateFormat', $format);
1182
1183
        return $format;
1184
    }
1185
1186
    /**
1187
     * Get user locale
1188
     */
1189
    public function getLocale()
1190
    {
1191
        $locale = $this->getField('Locale');
1192
        if ($locale) {
1193
            return $locale;
1194
        }
1195
1196
        return i18n::get_locale();
1197
    }
1198
1199
    /**
1200
     * Return the time format based on the user's chosen locale,
1201
     * falling back to the default format defined by the {@link i18n.get_locale()} setting.
1202
     *
1203
     * @return string ISO date format
1204
     */
1205
    public function getTimeFormat()
1206
    {
1207
        $formatter = new IntlDateFormatter(
1208
            $this->getLocale(),
1209
            IntlDateFormatter::NONE,
1210
            IntlDateFormatter::MEDIUM
1211
        );
1212
        $format = $formatter->getPattern();
1213
1214
        $this->extend('updateTimeFormat', $format);
1215
1216
        return $format;
1217
    }
1218
1219
    //---------------------------------------------------------------------//
1220
1221
1222
    /**
1223
     * Get a "many-to-many" map that holds for all members their group memberships,
1224
     * including any parent groups where membership is implied.
1225
     * Use {@link DirectGroups()} to only retrieve the group relations without inheritance.
1226
     *
1227
     * @todo Push all this logic into Member_GroupSet's getIterator()?
1228
     * @return Member_Groupset
1229
     */
1230
    public function Groups()
1231
    {
1232
        $groups = Member_GroupSet::create(Group::class, 'Group_Members', 'GroupID', 'MemberID');
1233
        $groups = $groups->forForeignID($this->ID);
1234
1235
        $this->extend('updateGroups', $groups);
1236
1237
        return $groups;
1238
    }
1239
1240
    /**
1241
     * @return ManyManyList
1242
     */
1243
    public function DirectGroups()
1244
    {
1245
        return $this->getManyManyComponents('Groups');
1246
    }
1247
1248
    /**
1249
     * Get a member SQLMap of members in specific groups
1250
     *
1251
     * If no $groups is passed, all members will be returned
1252
     *
1253
     * @param mixed $groups - takes a SS_List, an array or a single Group.ID
1254
     * @return Map Returns an Map that returns all Member data.
1255
     */
1256
    public static function map_in_groups($groups = null)
1257
    {
1258
        $groupIDList = array();
1259
1260
        if ($groups instanceof SS_List) {
1261
            foreach ($groups as $group) {
1262
                $groupIDList[] = $group->ID;
1263
            }
1264
        } elseif (is_array($groups)) {
1265
            $groupIDList = $groups;
1266
        } elseif ($groups) {
1267
            $groupIDList[] = $groups;
1268
        }
1269
1270
        // No groups, return all Members
1271
        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...
1272
            return static::get()->sort(array('Surname' => 'ASC', 'FirstName' => 'ASC'))->map();
1273
        }
1274
1275
        $membersList = new ArrayList();
1276
        // This is a bit ineffective, but follow the ORM style
1277
        /** @var Group $group */
1278
        foreach (Group::get()->byIDs($groupIDList) as $group) {
1279
            $membersList->merge($group->Members());
1280
        }
1281
1282
        $membersList->removeDuplicates('ID');
1283
1284
        return $membersList->map();
1285
    }
1286
1287
1288
    /**
1289
     * Get a map of all members in the groups given that have CMS permissions
1290
     *
1291
     * If no groups are passed, all groups with CMS permissions will be used.
1292
     *
1293
     * @param array $groups Groups to consider or NULL to use all groups with
1294
     *                      CMS permissions.
1295
     * @return Map Returns a map of all members in the groups given that
1296
     *                have CMS permissions.
1297
     */
1298
    public static function mapInCMSGroups($groups = null)
1299
    {
1300
        // Check CMS module exists
1301
        if (!class_exists(LeftAndMain::class)) {
1302
            return ArrayList::create()->map();
1303
        }
1304
1305
        if (count($groups) == 0) {
1306
            $perms = array('ADMIN', 'CMS_ACCESS_AssetAdmin');
1307
1308
            if (class_exists(CMSMain::class)) {
1309
                $cmsPerms = CMSMain::singleton()->providePermissions();
1310
            } else {
1311
                $cmsPerms = LeftAndMain::singleton()->providePermissions();
1312
            }
1313
1314
            if (!empty($cmsPerms)) {
1315
                $perms = array_unique(array_merge($perms, array_keys($cmsPerms)));
1316
            }
1317
1318
            $permsClause = DB::placeholders($perms);
1319
            /** @skipUpgrade */
1320
            $groups = Group::get()
1321
                ->innerJoin("Permission", '"Permission"."GroupID" = "Group"."ID"')
1322
                ->where(array(
1323
                    "\"Permission\".\"Code\" IN ($permsClause)" => $perms
1324
                ));
1325
        }
1326
1327
        $groupIDList = array();
1328
1329
        if ($groups instanceof SS_List) {
1330
            foreach ($groups as $group) {
1331
                $groupIDList[] = $group->ID;
1332
            }
1333
        } elseif (is_array($groups)) {
1334
            $groupIDList = $groups;
1335
        }
1336
1337
        /** @skipUpgrade */
1338
        $members = static::get()
1339
            ->innerJoin("Group_Members", '"Group_Members"."MemberID" = "Member"."ID"')
1340
            ->innerJoin("Group", '"Group"."ID" = "Group_Members"."GroupID"');
1341
        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...
1342
            $groupClause = DB::placeholders($groupIDList);
1343
            $members = $members->where(array(
1344
                "\"Group\".\"ID\" IN ($groupClause)" => $groupIDList
1345
            ));
1346
        }
1347
1348
        return $members->sort('"Member"."Surname", "Member"."FirstName"')->map();
1349
    }
1350
1351
1352
    /**
1353
     * Get the groups in which the member is NOT in
1354
     *
1355
     * When passed an array of groups, and a component set of groups, this
1356
     * function will return the array of groups the member is NOT in.
1357
     *
1358
     * @param array $groupList An array of group code names.
1359
     * @param array $memberGroups A component set of groups (if set to NULL,
1360
     *                            $this->groups() will be used)
1361
     * @return array Groups in which the member is NOT in.
1362
     */
1363
    public function memberNotInGroups($groupList, $memberGroups = null)
1364
    {
1365
        if (!$memberGroups) {
1366
            $memberGroups = $this->Groups();
1367
        }
1368
1369
        foreach ($memberGroups as $group) {
1370
            if (in_array($group->Code, $groupList)) {
1371
                $index = array_search($group->Code, $groupList);
1372
                unset($groupList[$index]);
1373
            }
1374
        }
1375
1376
        return $groupList;
1377
    }
1378
1379
1380
    /**
1381
     * Return a {@link FieldList} of fields that would appropriate for editing
1382
     * this member.
1383
     *
1384
     * @return FieldList Return a FieldList of fields that would appropriate for
1385
     *                   editing this member.
1386
     */
1387
    public function getCMSFields()
1388
    {
1389
        $this->beforeUpdateCMSFields(function (FieldList $fields) {
1390
            /** @var TabSet $rootTabSet */
1391
            $rootTabSet = $fields->fieldByName("Root");
1392
            /** @var Tab $mainTab */
1393
            $mainTab = $rootTabSet->fieldByName("Main");
1394
            /** @var FieldList $mainFields */
1395
            $mainFields = $mainTab->getChildren();
1396
1397
            // Build change password field
1398
            $mainFields->replaceField('Password', $this->getMemberPasswordField());
1399
1400
            $mainFields->replaceField('Locale', new DropdownField(
1401
                "Locale",
1402
                _t(__CLASS__ . '.INTERFACELANG', "Interface Language", 'Language of the CMS'),
1403
                i18n::getSources()->getKnownLocales()
1404
            ));
1405
            $mainFields->removeByName(static::config()->get('hidden_fields'));
1406
1407
            if (!static::config()->get('lock_out_after_incorrect_logins')) {
1408
                $mainFields->removeByName('FailedLoginCount');
1409
            }
1410
1411
            // Groups relation will get us into logical conflicts because
1412
            // Members are displayed within  group edit form in SecurityAdmin
1413
            $fields->removeByName('Groups');
1414
1415
            // Members shouldn't be able to directly view/edit logged passwords
1416
            $fields->removeByName('LoggedPasswords');
1417
1418
            $fields->removeByName('RememberLoginHashes');
1419
1420
            if (Permission::check('EDIT_PERMISSIONS')) {
1421
                $groupsMap = array();
1422
                foreach (Group::get() as $group) {
1423
                    // Listboxfield values are escaped, use ASCII char instead of &raquo;
1424
                    $groupsMap[$group->ID] = $group->getBreadcrumbs(' > ');
1425
                }
1426
                asort($groupsMap);
1427
                $fields->addFieldToTab(
1428
                    'Root.Main',
1429
                    ListboxField::create('DirectGroups', Group::singleton()->i18n_plural_name())
1430
                        ->setSource($groupsMap)
1431
                        ->setAttribute(
1432
                            'data-placeholder',
1433
                            _t(__CLASS__ . '.ADDGROUP', 'Add group', 'Placeholder text for a dropdown')
1434
                        )
1435
                );
1436
1437
1438
                // Add permission field (readonly to avoid complicated group assignment logic).
1439
                // This should only be available for existing records, as new records start
1440
                // with no permissions until they have a group assignment anyway.
1441
                if ($this->ID) {
1442
                    $permissionsField = new PermissionCheckboxSetField_Readonly(
1443
                        'Permissions',
1444
                        false,
1445
                        Permission::class,
1446
                        'GroupID',
1447
                        // we don't want parent relationships, they're automatically resolved in the field
1448
                        $this->getManyManyComponents('Groups')
1449
                    );
1450
                    $fields->findOrMakeTab('Root.Permissions', Permission::singleton()->i18n_plural_name());
1451
                    $fields->addFieldToTab('Root.Permissions', $permissionsField);
1452
                }
1453
            }
1454
1455
            $permissionsTab = $rootTabSet->fieldByName('Permissions');
1456
            if ($permissionsTab) {
1457
                $permissionsTab->addExtraClass('readonly');
1458
            }
1459
        });
1460
1461
        return parent::getCMSFields();
1462
    }
1463
1464
    /**
1465
     * @param bool $includerelations Indicate if the labels returned include relation fields
1466
     * @return array
1467
     */
1468
    public function fieldLabels($includerelations = true)
1469
    {
1470
        $labels = parent::fieldLabels($includerelations);
1471
1472
        $labels['FirstName'] = _t(__CLASS__ . '.FIRSTNAME', 'First Name');
1473
        $labels['Surname'] = _t(__CLASS__ . '.SURNAME', 'Surname');
1474
        /** @skipUpgrade */
1475
        $labels['Email'] = _t(__CLASS__ . '.EMAIL', 'Email');
1476
        $labels['Password'] = _t(__CLASS__ . '.db_Password', 'Password');
1477
        $labels['PasswordExpiry'] = _t(__CLASS__ . '.db_PasswordExpiry', 'Password Expiry Date', 'Password expiry date');
1478
        $labels['LockedOutUntil'] = _t(__CLASS__ . '.db_LockedOutUntil', 'Locked out until', 'Security related date');
1479
        $labels['Locale'] = _t(__CLASS__ . '.db_Locale', 'Interface Locale');
1480
        if ($includerelations) {
1481
            $labels['Groups'] = _t(
1482
                __CLASS__ . '.belongs_many_many_Groups',
1483
                'Groups',
1484
                'Security Groups this member belongs to'
1485
            );
1486
        }
1487
1488
        return $labels;
1489
    }
1490
1491
    /**
1492
     * Users can view their own record.
1493
     * Otherwise they'll need ADMIN or CMS_ACCESS_SecurityAdmin permissions.
1494
     * This is likely to be customized for social sites etc. with a looser permission model.
1495
     *
1496
     * @param Member $member
1497
     * @return bool
1498
     */
1499
    public function canView($member = null)
1500
    {
1501
        //get member
1502
        if (!$member) {
1503
            $member = Security::getCurrentUser();
1504
        }
1505
        //check for extensions, we do this first as they can overrule everything
1506
        $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...
1507
        if ($extended !== null) {
1508
            return $extended;
1509
        }
1510
1511
        //need to be logged in and/or most checks below rely on $member being a Member
1512
        if (!$member) {
1513
            return false;
1514
        }
1515
        // members can usually view their own record
1516
        if ($this->ID == $member->ID) {
1517
            return true;
1518
        }
1519
1520
        //standard check
1521
        return Permission::checkMember($member, 'CMS_ACCESS_SecurityAdmin');
1522
    }
1523
1524
    /**
1525
     * Users can edit their own record.
1526
     * Otherwise they'll need ADMIN or CMS_ACCESS_SecurityAdmin permissions
1527
     *
1528
     * @param Member $member
1529
     * @return bool
1530
     */
1531
    public function canEdit($member = null)
1532
    {
1533
        //get member
1534
        if (!$member) {
1535
            $member = Security::getCurrentUser();
1536
        }
1537
        //check for extensions, we do this first as they can overrule everything
1538
        $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...
1539
        if ($extended !== null) {
1540
            return $extended;
1541
        }
1542
1543
        //need to be logged in and/or most checks below rely on $member being a Member
1544
        if (!$member) {
1545
            return false;
1546
        }
1547
1548
        // HACK: we should not allow for an non-Admin to edit an Admin
1549
        if (!Permission::checkMember($member, 'ADMIN') && Permission::checkMember($this, 'ADMIN')) {
1550
            return false;
1551
        }
1552
        // members can usually edit their own record
1553
        if ($this->ID == $member->ID) {
1554
            return true;
1555
        }
1556
1557
        //standard check
1558
        return Permission::checkMember($member, 'CMS_ACCESS_SecurityAdmin');
1559
    }
1560
1561
    /**
1562
     * Users can edit their own record.
1563
     * Otherwise they'll need ADMIN or CMS_ACCESS_SecurityAdmin permissions
1564
     *
1565
     * @param Member $member
1566
     * @return bool
1567
     */
1568
    public function canDelete($member = null)
1569
    {
1570
        if (!$member) {
1571
            $member = Security::getCurrentUser();
1572
        }
1573
        //check for extensions, we do this first as they can overrule everything
1574
        $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...
1575
        if ($extended !== null) {
1576
            return $extended;
1577
        }
1578
1579
        //need to be logged in and/or most checks below rely on $member being a Member
1580
        if (!$member) {
1581
            return false;
1582
        }
1583
        // Members are not allowed to remove themselves,
1584
        // since it would create inconsistencies in the admin UIs.
1585
        if ($this->ID && $member->ID == $this->ID) {
1586
            return false;
1587
        }
1588
1589
        // HACK: if you want to delete a member, you have to be a member yourself.
1590
        // this is a hack because what this should do is to stop a user
1591
        // deleting a member who has more privileges (e.g. a non-Admin deleting an Admin)
1592
        if (Permission::checkMember($this, 'ADMIN')) {
1593
            if (!Permission::checkMember($member, 'ADMIN')) {
1594
                return false;
1595
            }
1596
        }
1597
1598
        //standard check
1599
        return Permission::checkMember($member, 'CMS_ACCESS_SecurityAdmin');
1600
    }
1601
1602
    /**
1603
     * Validate this member object.
1604
     */
1605
    public function validate()
1606
    {
1607
        $valid = parent::validate();
1608
1609
        if (!$this->ID || $this->isChanged('Password')) {
1610
            if ($this->Password && self::$password_validator) {
1611
                $valid->combineAnd(self::$password_validator->validate($this->Password, $this));
1612
            }
1613
        }
1614
1615
        if ((!$this->ID && $this->SetPassword) || $this->isChanged('SetPassword')) {
1616
            if ($this->SetPassword && self::$password_validator) {
1617
                $valid->combineAnd(self::$password_validator->validate($this->SetPassword, $this));
1618
            }
1619
        }
1620
1621
        return $valid;
1622
    }
1623
1624
    /**
1625
     * Change password. This will cause rehashing according to
1626
     * the `PasswordEncryption` property.
1627
     *
1628
     * @param string $password Cleartext password
1629
     * @return ValidationResult
1630
     */
1631
    public function changePassword($password)
1632
    {
1633
        $this->Password = $password;
1634
        $valid = $this->validate();
1635
1636
        if ($valid->isValid()) {
1637
            $this->AutoLoginHash = null;
1638
            $this->write();
1639
        }
1640
1641
        return $valid;
1642
    }
1643
1644
    /**
1645
     * Tell this member that someone made a failed attempt at logging in as them.
1646
     * This can be used to lock the user out temporarily if too many failed attempts are made.
1647
     */
1648
    public function registerFailedLogin()
1649
    {
1650
        $lockOutAfterCount = self::config()->get('lock_out_after_incorrect_logins');
1651
        if ($lockOutAfterCount) {
1652
            // Keep a tally of the number of failed log-ins so that we can lock people out
1653
            $this->FailedLoginCount = $this->FailedLoginCount + 1;
1654
1655
            if ($this->FailedLoginCount >= $lockOutAfterCount) {
1656
                $lockoutMins = self::config()->get('lock_out_delay_mins');
1657
                $this->LockedOutUntil = date('Y-m-d H:i:s', DBDatetime::now()->getTimestamp() + $lockoutMins * 60);
1658
                $this->FailedLoginCount = 0;
1659
            }
1660
        }
1661
        $this->extend('registerFailedLogin');
1662
        $this->write();
1663
    }
1664
1665
    /**
1666
     * Tell this member that a successful login has been made
1667
     */
1668
    public function registerSuccessfulLogin()
1669
    {
1670
        if (self::config()->get('lock_out_after_incorrect_logins')) {
1671
            // Forgive all past login failures
1672
            $this->FailedLoginCount = 0;
1673
            $this->write();
1674
        }
1675
    }
1676
1677
    /**
1678
     * Get the HtmlEditorConfig for this user to be used in the CMS.
1679
     * This is set by the group. If multiple configurations are set,
1680
     * the one with the highest priority wins.
1681
     *
1682
     * @return string
1683
     */
1684
    public function getHtmlEditorConfigForCMS()
1685
    {
1686
        $currentName = '';
1687
        $currentPriority = 0;
1688
1689
        foreach ($this->Groups() as $group) {
1690
            $configName = $group->HtmlEditorConfig;
1691
            if ($configName) {
1692
                $config = HTMLEditorConfig::get($group->HtmlEditorConfig);
1693
                if ($config && $config->getOption('priority') > $currentPriority) {
1694
                    $currentName = $configName;
1695
                    $currentPriority = $config->getOption('priority');
1696
                }
1697
            }
1698
        }
1699
1700
        // If can't find a suitable editor, just default to cms
1701
        return $currentName ? $currentName : 'cms';
1702
    }
1703
}
1704