Completed
Push — master ( f024a0...d4b41b )
by
unknown
07:44
created

Member::isLockedOut()   C

Complexity

Conditions 7
Paths 8

Size

Total Lines 41
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Importance

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