Passed
Push — 4.1 ( 62631d...6d98a9 )
by Robbie
06:34
created

Member::disallowedGroups()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 9
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 3
nc 2
nop 0
dl 0
loc 9
rs 9.6666
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;
0 ignored issues
show
Bug introduced by
The type SilverStripe\Admin\LeftAndMain was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
8
use SilverStripe\CMS\Controllers\CMSMain;
0 ignored issues
show
Bug introduced by
The type SilverStripe\CMS\Controllers\CMSMain was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
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\Control\HTTPRequest;
14
use SilverStripe\Core\Config\Config;
15
use SilverStripe\Core\Convert;
16
use SilverStripe\Core\Injector\Injector;
17
use SilverStripe\Dev\Deprecation;
18
use SilverStripe\Dev\TestMailer;
19
use SilverStripe\Forms\ConfirmedPasswordField;
20
use SilverStripe\Forms\DropdownField;
21
use SilverStripe\Forms\FieldList;
22
use SilverStripe\Forms\HTMLEditor\HTMLEditorConfig;
23
use SilverStripe\Forms\ListboxField;
24
use SilverStripe\Forms\Tab;
25
use SilverStripe\Forms\TabSet;
26
use SilverStripe\i18n\i18n;
27
use SilverStripe\ORM\ArrayList;
28
use SilverStripe\ORM\DataList;
29
use SilverStripe\ORM\DataObject;
30
use SilverStripe\ORM\DB;
31
use SilverStripe\ORM\FieldType\DBDatetime;
32
use SilverStripe\ORM\HasManyList;
33
use SilverStripe\ORM\ManyManyList;
34
use SilverStripe\ORM\Map;
35
use SilverStripe\ORM\SS_List;
36
use SilverStripe\ORM\UnsavedRelationList;
37
use SilverStripe\ORM\ValidationException;
38
use SilverStripe\ORM\ValidationResult;
39
40
/**
41
 * The member class which represents the users of the system
42
 *
43
 * @method HasManyList LoggedPasswords()
44
 * @method HasManyList RememberLoginHashes()
45
 * @property string $FirstName
46
 * @property string $Surname
47
 * @property string $Email
48
 * @property string $Password
49
 * @property string $TempIDHash
50
 * @property string $TempIDExpired
51
 * @property string $AutoLoginHash
52
 * @property string $AutoLoginExpired
53
 * @property string $PasswordEncryption
54
 * @property string $Salt
55
 * @property string $PasswordExpiry
56
 * @property string $LockedOutUntil
57
 * @property string $Locale
58
 * @property int $FailedLoginCount
59
 * @property string $DateFormat
60
 * @property string $TimeFormat
61
 */
62
class Member extends DataObject
63
{
64
    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
     * Default lifetime of auto login token.
246
     *
247
     * This is the maximum allowed period between a user requesting a password reset link and using it to reset
248
     * their password.
249
     *
250
     * Defaults to 2 days.
251
     *
252
     * @config
253
     * @var int Lifetime in seconds
254
     */
255
    private static $auto_login_token_lifetime = 172800;
256
257
    /**
258
     * Used to track whether {@link Member::changePassword} has made changed that need to be written. Used to prevent
259
     * the write from calling changePassword again.
260
     *
261
     * @var bool
262
     */
263
    protected $passwordChangesToWrite = false;
264
265
    /**
266
     * Ensure the locale is set to something sensible by default.
267
     */
268
    public function populateDefaults()
269
    {
270
        parent::populateDefaults();
271
        $this->Locale = i18n::get_locale();
272
    }
273
274
    public function requireDefaultRecords()
275
    {
276
        parent::requireDefaultRecords();
277
        // Default groups should've been built by Group->requireDefaultRecords() already
278
        $service = DefaultAdminService::singleton();
279
        $service->findOrCreateDefaultAdmin();
280
    }
281
282
    /**
283
     * Get the default admin record if it exists, or creates it otherwise if enabled
284
     *
285
     * @deprecated 4.0.0...5.0.0 Use DefaultAdminService::findOrCreateDefaultAdmin() instead
286
     * @return Member
287
     */
288
    public static function default_admin()
289
    {
290
        Deprecation::notice('5.0', 'Use DefaultAdminService::findOrCreateDefaultAdmin() instead');
291
        return DefaultAdminService::singleton()->findOrCreateDefaultAdmin();
292
    }
293
294
    /**
295
     * Check if the passed password matches the stored one (if the member is not locked out).
296
     *
297
     * @deprecated 4.0.0...5.0.0 Use Authenticator::checkPassword() instead
298
     *
299
     * @param  string $password
300
     * @return ValidationResult
301
     */
302
    public function checkPassword($password)
303
    {
304
        Deprecation::notice('5.0', 'Use Authenticator::checkPassword() instead');
305
306
        // With a valid user and password, check the password is correct
307
        $result = ValidationResult::create();
308
        $authenticators = Security::singleton()->getApplicableAuthenticators(Authenticator::CHECK_PASSWORD);
309
        foreach ($authenticators as $authenticator) {
310
            $authenticator->checkPassword($this, $password, $result);
311
            if (!$result->isValid()) {
312
                break;
313
            }
314
        }
315
        return $result;
316
    }
317
318
    /**
319
     * Check if this user is the currently configured default admin
320
     *
321
     * @return bool
322
     */
323
    public function isDefaultAdmin()
324
    {
325
        return DefaultAdminService::isDefaultAdmin($this->Email);
326
    }
327
328
    /**
329
     * Check if this user can login
330
     *
331
     * @return bool
332
     */
333
    public function canLogin()
334
    {
335
        return $this->validateCanLogin()->isValid();
336
    }
337
338
    /**
339
     * Returns a valid {@link ValidationResult} if this member can currently log in, or an invalid
340
     * one with error messages to display if the member is locked out.
341
     *
342
     * You can hook into this with a "canLogIn" method on an attached extension.
343
     *
344
     * @param ValidationResult $result Optional result to add errors to
345
     * @return ValidationResult
346
     */
347
    public function validateCanLogin(ValidationResult &$result = null)
348
    {
349
        $result = $result ?: ValidationResult::create();
350
        if ($this->isLockedOut()) {
351
            $result->addError(
352
                _t(
353
                    __CLASS__ . '.ERRORLOCKEDOUT2',
354
                    'Your account has been temporarily disabled because of too many failed attempts at ' . 'logging in. Please try again in {count} minutes.',
355
                    null,
356
                    array('count' => static::config()->get('lock_out_delay_mins'))
357
                )
358
            );
359
        }
360
361
        $this->extend('canLogIn', $result);
362
363
        return $result;
364
    }
365
366
    /**
367
     * Returns true if this user is locked out
368
     *
369
     * @skipUpgrade
370
     * @return bool
371
     */
372
    public function isLockedOut()
373
    {
374
        /** @var DBDatetime $lockedOutUntilObj */
375
        $lockedOutUntilObj = $this->dbObject('LockedOutUntil');
376
        if ($lockedOutUntilObj->InFuture()) {
377
            return true;
378
        }
379
380
        $maxAttempts = $this->config()->get('lock_out_after_incorrect_logins');
381
        if ($maxAttempts <= 0) {
382
            return false;
383
        }
384
385
        $idField = static::config()->get('unique_identifier_field');
386
        $attempts = LoginAttempt::getByEmail($this->{$idField})
387
            ->sort('Created', 'DESC')
388
            ->limit($maxAttempts);
389
390
        if ($attempts->count() < $maxAttempts) {
391
            return false;
392
        }
393
394
        foreach ($attempts as $attempt) {
395
            if ($attempt->Status === 'Success') {
396
                return false;
397
            }
398
        }
399
400
        // Calculate effective LockedOutUntil
401
        /** @var DBDatetime $firstFailureDate */
402
        $firstFailureDate = $attempts->first()->dbObject('Created');
403
        $maxAgeSeconds = $this->config()->get('lock_out_delay_mins') * 60;
404
        $lockedOutUntil = $firstFailureDate->getTimestamp() + $maxAgeSeconds;
405
        $now = DBDatetime::now()->getTimestamp();
406
        if ($now < $lockedOutUntil) {
407
            return true;
408
        }
409
410
        return false;
411
    }
412
413
    /**
414
     * Set a {@link PasswordValidator} object to use to validate member's passwords.
415
     *
416
     * @param PasswordValidator $validator
417
     */
418
    public static function set_password_validator(PasswordValidator $validator = null)
419
    {
420
        // Override existing config
421
        Config::modify()->remove(Injector::class, PasswordValidator::class);
422
        if ($validator) {
423
            Injector::inst()->registerService($validator, PasswordValidator::class);
424
        } else {
425
            Injector::inst()->unregisterNamedObject(PasswordValidator::class);
426
        }
427
    }
428
429
    /**
430
     * Returns the default {@link PasswordValidator}
431
     *
432
     * @return PasswordValidator
433
     */
434
    public static function password_validator()
435
    {
436
        if (Injector::inst()->has(PasswordValidator::class)) {
437
            return Injector::inst()->get(PasswordValidator::class);
438
        }
439
        return null;
440
    }
441
442
    public function isPasswordExpired()
443
    {
444
        if (!$this->PasswordExpiry) {
445
            return false;
446
        }
447
448
        return strtotime(date('Y-m-d')) >= strtotime($this->PasswordExpiry);
449
    }
450
451
    /**
452
     * @deprecated 5.0.0 Use Security::setCurrentUser() or IdentityStore::logIn()
453
     *
454
     */
455
    public function logIn()
456
    {
457
        Deprecation::notice(
458
            '5.0.0',
459
            'This method is deprecated and only logs in for the current request. Please use Security::setCurrentUser($user) or an IdentityStore'
460
        );
461
        Security::setCurrentUser($this);
462
    }
463
464
    /**
465
     * Called before a member is logged in via session/cookie/etc
466
     */
467
    public function beforeMemberLoggedIn()
468
    {
469
        // @todo Move to middleware on the AuthenticationMiddleware IdentityStore
470
        $this->extend('beforeMemberLoggedIn');
471
    }
472
473
    /**
474
     * Called after a member is logged in via session/cookie/etc
475
     */
476
    public function afterMemberLoggedIn()
477
    {
478
        // Clear the incorrect log-in count
479
        $this->registerSuccessfulLogin();
480
481
        $this->LockedOutUntil = null;
482
483
        $this->regenerateTempID();
484
485
        $this->write();
486
487
        // Audit logging hook
488
        $this->extend('afterMemberLoggedIn');
489
    }
490
491
    /**
492
     * Trigger regeneration of TempID.
493
     *
494
     * This should be performed any time the user presents their normal identification (normally Email)
495
     * and is successfully authenticated.
496
     */
497
    public function regenerateTempID()
498
    {
499
        $generator = new RandomGenerator();
500
        $lifetime = self::config()->get('temp_id_lifetime');
501
        $this->TempIDHash = $generator->randomToken('sha1');
502
        $this->TempIDExpired = $lifetime
503
            ? date('Y-m-d H:i:s', strtotime(DBDatetime::now()->getValue()) + $lifetime)
504
            : null;
505
        $this->write();
506
    }
507
508
    /**
509
     * Check if the member ID logged in session actually
510
     * has a database record of the same ID. If there is
511
     * no logged in user, FALSE is returned anyway.
512
     *
513
     * @deprecated Not needed anymore, as it returns Security::getCurrentUser();
514
     *
515
     * @return boolean TRUE record found FALSE no record found
516
     */
517
    public static function logged_in_session_exists()
518
    {
519
        Deprecation::notice(
520
            '5.0.0',
521
            'This method is deprecated and now does not add value. Please use Security::getCurrentUser()'
522
        );
523
524
        $member = Security::getCurrentUser();
525
        if ($member && $member->exists()) {
526
            return true;
527
        }
528
529
        return false;
530
    }
531
532
    /**
533
     * @deprecated Use Security::setCurrentUser(null) or an IdentityStore
534
     * Logs this member out.
535
     */
536
    public function logOut()
537
    {
538
        Deprecation::notice(
539
            '5.0.0',
540
            'This method is deprecated and now does not persist. Please use Security::setCurrentUser(null) or an IdentityStore'
541
        );
542
543
        Injector::inst()->get(IdentityStore::class)->logOut(Controller::curr()->getRequest());
544
    }
545
546
    /**
547
     * Audit logging hook, called before a member is logged out
548
     *
549
     * @param HTTPRequest|null $request
550
     */
551
    public function beforeMemberLoggedOut(HTTPRequest $request = null)
552
    {
553
        $this->extend('beforeMemberLoggedOut', $request);
554
    }
555
556
    /**
557
     * Audit logging hook, called after a member is logged out
558
     *
559
     * @param HTTPRequest|null $request
560
     */
561
    public function afterMemberLoggedOut(HTTPRequest $request = null)
562
    {
563
        $this->extend('afterMemberLoggedOut', $request);
564
    }
565
566
    /**
567
     * Utility for generating secure password hashes for this member.
568
     *
569
     * @param string $string
570
     * @return string
571
     * @throws PasswordEncryptor_NotFoundException
572
     */
573
    public function encryptWithUserSettings($string)
574
    {
575
        if (!$string) {
576
            return null;
577
        }
578
579
        // If the algorithm or salt is not available, it means we are operating
580
        // on legacy account with unhashed password. Do not hash the string.
581
        if (!$this->PasswordEncryption) {
582
            return $string;
583
        }
584
585
        // We assume we have PasswordEncryption and Salt available here.
586
        $e = PasswordEncryptor::create_for_algorithm($this->PasswordEncryption);
587
588
        return $e->encrypt($string, $this->Salt);
589
    }
590
591
    /**
592
     * Generate an auto login token which can be used to reset the password,
593
     * at the same time hashing it and storing in the database.
594
     *
595
     * @param int|null $lifetime DEPRECATED: The lifetime of the auto login hash in days. Overrides
596
     *                           the Member.auto_login_token_lifetime config value
597
     * @return string Token that should be passed to the client (but NOT persisted).
598
     */
599
    public function generateAutologinTokenAndStoreHash($lifetime = null)
600
    {
601
        if ($lifetime !== null) {
602
            Deprecation::notice(
603
                '5.0',
604
                'Passing a $lifetime to Member::generateAutologinTokenAndStoreHash() is deprecated,
605
                    use the Member.auto_login_token_lifetime config setting instead',
606
                Deprecation::SCOPE_GLOBAL
607
            );
608
            $lifetime = (86400 * $lifetime); // Method argument is days, convert to seconds
609
        } else {
610
            $lifetime = $this->config()->auto_login_token_lifetime;
0 ignored issues
show
Bug Best Practice introduced by
The property auto_login_token_lifetime does not exist on SilverStripe\Core\Config\Config_ForClass. Since you implemented __get, consider adding a @property annotation.
Loading history...
611
        }
612
613
        do {
614
            $generator = new RandomGenerator();
615
            $token = $generator->randomToken();
616
            $hash = $this->encryptWithUserSettings($token);
617
        } 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...
618
            '"Member"."AutoLoginHash"' => $hash
619
        )));
620
621
        $this->AutoLoginHash = $hash;
622
        $this->AutoLoginExpired = date('Y-m-d H:i:s', time() + $lifetime);
623
624
        $this->write();
625
626
        return $token;
627
    }
628
629
    /**
630
     * Check the token against the member.
631
     *
632
     * @param string $autologinToken
633
     *
634
     * @returns bool Is token valid?
635
     */
636
    public function validateAutoLoginToken($autologinToken)
637
    {
638
        $hash = $this->encryptWithUserSettings($autologinToken);
639
        $member = self::member_from_autologinhash($hash, false);
640
641
        return (bool)$member;
642
    }
643
644
    /**
645
     * Return the member for the auto login hash
646
     *
647
     * @param string $hash The hash key
648
     * @param bool $login Should the member be logged in?
649
     *
650
     * @return Member the matching member, if valid
651
     * @return Member
652
     */
653
    public static function member_from_autologinhash($hash, $login = false)
654
    {
655
        /** @var Member $member */
656
        $member = static::get()->filter([
657
            'AutoLoginHash' => $hash,
658
            'AutoLoginExpired:GreaterThan' => DBDatetime::now()->getValue(),
659
        ])->first();
660
661
        if ($login && $member) {
662
            Injector::inst()->get(IdentityStore::class)->logIn($member);
663
        }
664
665
        return $member;
666
    }
667
668
    /**
669
     * Find a member record with the given TempIDHash value
670
     *
671
     * @param string $tempid
672
     * @return Member
673
     */
674
    public static function member_from_tempid($tempid)
675
    {
676
        $members = static::get()
677
            ->filter('TempIDHash', $tempid);
678
679
        // Exclude expired
680
        if (static::config()->get('temp_id_lifetime')) {
681
            /** @var DataList|Member[] $members */
682
            $members = $members->filter('TempIDExpired:GreaterThan', DBDatetime::now()->getValue());
683
        }
684
685
        return $members->first();
686
    }
687
688
    /**
689
     * Returns the fields for the member form - used in the registration/profile module.
690
     * It should return fields that are editable by the admin and the logged-in user.
691
     *
692
     * @todo possibly move this to an extension
693
     *
694
     * @return FieldList Returns a {@link FieldList} containing the fields for
695
     *                   the member form.
696
     */
697
    public function getMemberFormFields()
698
    {
699
        $fields = parent::getFrontEndFields();
700
701
        $fields->replaceField('Password', $this->getMemberPasswordField());
702
703
        $fields->replaceField('Locale', new DropdownField(
704
            'Locale',
705
            $this->fieldLabel('Locale'),
706
            i18n::getSources()->getKnownLocales()
707
        ));
708
709
        $fields->removeByName(static::config()->get('hidden_fields'));
710
        $fields->removeByName('FailedLoginCount');
711
712
713
        $this->extend('updateMemberFormFields', $fields);
714
715
        return $fields;
716
    }
717
718
    /**
719
     * Builds "Change / Create Password" field for this member
720
     *
721
     * @return ConfirmedPasswordField
722
     */
723
    public function getMemberPasswordField()
724
    {
725
        $editingPassword = $this->isInDB();
726
        $label = $editingPassword
727
            ? _t(__CLASS__ . '.EDIT_PASSWORD', 'New Password')
728
            : $this->fieldLabel('Password');
729
        /** @var ConfirmedPasswordField $password */
730
        $password = ConfirmedPasswordField::create(
731
            'Password',
732
            $label,
733
            null,
734
            null,
735
            $editingPassword
736
        );
737
738
        // If editing own password, require confirmation of existing
739
        if ($editingPassword && $this->ID == Security::getCurrentUser()->ID) {
740
            $password->setRequireExistingPassword(true);
741
        }
742
743
        $password->setCanBeEmpty(true);
744
        $this->extend('updateMemberPasswordField', $password);
745
746
        return $password;
747
    }
748
749
750
    /**
751
     * Returns the {@link RequiredFields} instance for the Member object. This
752
     * Validator is used when saving a {@link CMSProfileController} or added to
753
     * any form responsible for saving a users data.
754
     *
755
     * To customize the required fields, add a {@link DataExtension} to member
756
     * calling the `updateValidator()` method.
757
     *
758
     * @return Member_Validator
759
     */
760
    public function getValidator()
761
    {
762
        $validator = Member_Validator::create();
763
        $validator->setForMember($this);
764
        $this->extend('updateValidator', $validator);
765
766
        return $validator;
767
    }
768
769
770
    /**
771
     * Returns the current logged in user
772
     *
773
     * @deprecated 5.0.0 use Security::getCurrentUser()
774
     *
775
     * @return Member
776
     */
777
    public static function currentUser()
778
    {
779
        Deprecation::notice(
780
            '5.0.0',
781
            'This method is deprecated. Please use Security::getCurrentUser() or an IdentityStore'
782
        );
783
784
        return Security::getCurrentUser();
785
    }
786
787
    /**
788
     * Temporarily act as the specified user, limited to a $callback, but
789
     * without logging in as that user.
790
     *
791
     * E.g.
792
     * <code>
793
     * Member::logInAs(Security::findAnAdministrator(), function() {
794
     *     $record->write();
795
     * });
796
     * </code>
797
     *
798
     * @param Member|null|int $member Member or member ID to log in as.
799
     * Set to null or 0 to act as a logged out user.
800
     * @param callable $callback
801
     * @return mixed Result of $callback
802
     */
803
    public static function actAs($member, $callback)
804
    {
805
        $previousUser = Security::getCurrentUser();
806
807
        // Transform ID to member
808
        if (is_numeric($member)) {
809
            $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...
810
        }
811
        Security::setCurrentUser($member);
812
813
        try {
814
            return $callback();
815
        } finally {
816
            Security::setCurrentUser($previousUser);
817
        }
818
    }
819
820
    /**
821
     * Get the ID of the current logged in user
822
     *
823
     * @deprecated 5.0.0 use Security::getCurrentUser()
824
     *
825
     * @return int Returns the ID of the current logged in user or 0.
826
     */
827
    public static function currentUserID()
828
    {
829
        Deprecation::notice(
830
            '5.0.0',
831
            'This method is deprecated. Please use Security::getCurrentUser() or an IdentityStore'
832
        );
833
834
        $member = Security::getCurrentUser();
835
        if ($member) {
0 ignored issues
show
introduced by
$member is of type SilverStripe\Security\Member, thus it always evaluated to true.
Loading history...
836
            return $member->ID;
837
        }
838
        return 0;
839
    }
840
841
    /**
842
     * Generate a random password, with randomiser to kick in if there's no words file on the
843
     * filesystem.
844
     *
845
     * @return string Returns a random password.
846
     */
847
    public static function create_new_password()
848
    {
849
        $words = Security::config()->uninherited('word_list');
850
851
        if ($words && file_exists($words)) {
852
            $words = file($words);
853
854
            list($usec, $sec) = explode(' ', microtime());
855
            mt_srand($sec + ((float)$usec * 100000));
0 ignored issues
show
Bug introduced by
$sec + (double)$usec * 100000 of type double is incompatible with the type integer expected by parameter $seed of mt_srand(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

855
            mt_srand(/** @scrutinizer ignore-type */ $sec + ((float)$usec * 100000));
Loading history...
856
857
            $word = trim($words[random_int(0, count($words) - 1)]);
0 ignored issues
show
Bug introduced by
It seems like $words can also be of type false; however, parameter $var of count() does only seem to accept Countable|array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

857
            $word = trim($words[random_int(0, count(/** @scrutinizer ignore-type */ $words) - 1)]);
Loading history...
858
            $number = random_int(10, 999);
859
860
            return $word . $number;
861
        } else {
862
            $random = mt_rand();
863
            $string = md5($random);
864
            $output = substr($string, 0, 8);
865
866
            return $output;
867
        }
868
    }
869
870
    /**
871
     * Event handler called before writing to the database.
872
     */
873
    public function onBeforeWrite()
874
    {
875
        // If a member with the same "unique identifier" already exists with a different ID, don't allow merging.
876
        // Note: This does not a full replacement for safeguards in the controller layer (e.g. in a registration form),
877
        // but rather a last line of defense against data inconsistencies.
878
        $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...
879
        if ($this->$identifierField) {
880
            // Note: Same logic as Member_Validator class
881
            $filter = [
882
                "\"Member\".\"$identifierField\"" => $this->$identifierField
883
            ];
884
            if ($this->ID) {
885
                $filter[] = array('"Member"."ID" <> ?' => $this->ID);
886
            }
887
            $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...
888
889
            if ($existingRecord) {
890
                throw new ValidationException(_t(
891
                    __CLASS__ . '.ValidationIdentifierFailed',
892
                    'Can\'t overwrite existing member #{id} with identical identifier ({name} = {value}))',
893
                    'Values in brackets show "fieldname = value", usually denoting an existing email address',
894
                    array(
895
                        'id' => $existingRecord->ID,
896
                        'name' => $identifierField,
897
                        'value' => $this->$identifierField
898
                    )
899
                ));
900
            }
901
        }
902
903
        // We don't send emails out on dev/tests sites to prevent accidentally spamming users.
904
        // However, if TestMailer is in use this isn't a risk.
905
        // @todo some developers use external tools, so emailing might be a good idea anyway
906
        if ((Director::isLive() || Injector::inst()->get(Mailer::class) instanceof TestMailer)
907
            && $this->isChanged('Password')
908
            && $this->record['Password']
909
            && static::config()->get('notify_password_change')
910
        ) {
911
            Email::create()
912
                ->setHTMLTemplate('SilverStripe\\Control\\Email\\ChangePasswordEmail')
913
                ->setData($this)
914
                ->setTo($this->Email)
915
                ->setSubject(_t(
916
                    __CLASS__ . '.SUBJECTPASSWORDCHANGED',
917
                    "Your password has been changed",
918
                    'Email subject'
919
                ))
920
                ->send();
921
        }
922
923
        // The test on $this->ID is used for when records are initially created. Note that this only works with
924
        // cleartext passwords, as we can't rehash existing passwords. Checking passwordChangesToWrite prevents
925
        // recursion between changePassword and this method.
926
        if (!$this->ID || ($this->isChanged('Password') && !$this->passwordChangesToWrite)) {
927
            $this->changePassword($this->Password, false);
928
        }
929
930
        // save locale
931
        if (!$this->Locale) {
932
            $this->Locale = i18n::get_locale();
933
        }
934
935
        parent::onBeforeWrite();
936
    }
937
938
    public function onAfterWrite()
939
    {
940
        parent::onAfterWrite();
941
942
        Permission::reset();
943
944
        if ($this->isChanged('Password') && static::config()->get('password_logging_enabled')) {
945
            MemberPassword::log($this);
946
        }
947
    }
948
949
    public function onAfterDelete()
950
    {
951
        parent::onAfterDelete();
952
953
        // prevent orphaned records remaining in the DB
954
        $this->deletePasswordLogs();
955
        $this->Groups()->removeAll();
956
    }
957
958
    /**
959
     * Delete the MemberPassword objects that are associated to this user
960
     *
961
     * @return $this
962
     */
963
    protected function deletePasswordLogs()
964
    {
965
        foreach ($this->LoggedPasswords() as $password) {
966
            $password->delete();
967
            $password->destroy();
968
        }
969
970
        return $this;
971
    }
972
973
    /**
974
     * Filter out admin groups to avoid privilege escalation,
975
     * If any admin groups are requested, deny the whole save operation.
976
     *
977
     * @param array $ids Database IDs of Group records
978
     * @return bool True if the change can be accepted
979
     */
980
    public function onChangeGroups($ids)
981
    {
982
        // Ensure none of these match disallowed list
983
        $disallowedGroupIDs = $this->disallowedGroups();
984
        return count(array_intersect($ids, $disallowedGroupIDs)) == 0;
985
    }
986
987
    /**
988
     * List of group IDs this user is disallowed from
989
     *
990
     * @return int[] List of group IDs
991
     */
992
    protected function disallowedGroups()
993
    {
994
        // unless the current user is an admin already OR the logged in user is an admin
995
        if (Permission::check('ADMIN') || Permission::checkMember($this, 'ADMIN')) {
996
            return [];
997
        }
998
999
        // Non-admins may not belong to admin groups
1000
        return Permission::get_groups_by_permission('ADMIN')->column('ID');
1001
    }
1002
1003
1004
    /**
1005
     * Check if the member is in one of the given groups.
1006
     *
1007
     * @param array|SS_List $groups Collection of {@link Group} DataObjects to check
1008
     * @param boolean $strict Only determine direct group membership if set to true (Default: false)
1009
     * @return bool Returns TRUE if the member is in one of the given groups, otherwise FALSE.
1010
     */
1011
    public function inGroups($groups, $strict = false)
1012
    {
1013
        if ($groups) {
1014
            foreach ($groups as $group) {
1015
                if ($this->inGroup($group, $strict)) {
1016
                    return true;
1017
                }
1018
            }
1019
        }
1020
1021
        return false;
1022
    }
1023
1024
1025
    /**
1026
     * Check if the member is in the given group or any parent groups.
1027
     *
1028
     * @param int|Group|string $group Group instance, Group Code or ID
1029
     * @param boolean $strict Only determine direct group membership if set to TRUE (Default: FALSE)
1030
     * @return bool Returns TRUE if the member is in the given group, otherwise FALSE.
1031
     */
1032
    public function inGroup($group, $strict = false)
1033
    {
1034
        if (is_numeric($group)) {
1035
            $groupCheckObj = DataObject::get_by_id(Group::class, $group);
0 ignored issues
show
Bug introduced by
It seems like $group can also be of type string; however, parameter $id of SilverStripe\ORM\DataObject::get_by_id() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1035
            $groupCheckObj = DataObject::get_by_id(Group::class, /** @scrutinizer ignore-type */ $group);
Loading history...
1036
        } elseif (is_string($group)) {
1037
            $groupCheckObj = DataObject::get_one(Group::class, array(
1038
                '"Group"."Code"' => $group
1039
            ));
1040
        } elseif ($group instanceof Group) {
0 ignored issues
show
introduced by
$group is always a sub-type of SilverStripe\Security\Group.
Loading history...
1041
            $groupCheckObj = $group;
1042
        } else {
1043
            throw new InvalidArgumentException('Member::inGroup(): Wrong format for $group parameter');
1044
        }
1045
1046
        if (!$groupCheckObj) {
1047
            return false;
1048
        }
1049
1050
        $groupCandidateObjs = ($strict) ? $this->getManyManyComponents("Groups") : $this->Groups();
1051
        if ($groupCandidateObjs) {
1052
            foreach ($groupCandidateObjs as $groupCandidateObj) {
1053
                if ($groupCandidateObj->ID == $groupCheckObj->ID) {
1054
                    return true;
1055
                }
1056
            }
1057
        }
1058
1059
        return false;
1060
    }
1061
1062
    /**
1063
     * Adds the member to a group. This will create the group if the given
1064
     * group code does not return a valid group object.
1065
     *
1066
     * @param string $groupcode
1067
     * @param string $title Title of the group
1068
     */
1069
    public function addToGroupByCode($groupcode, $title = "")
1070
    {
1071
        $group = DataObject::get_one(Group::class, array(
1072
            '"Group"."Code"' => $groupcode
1073
        ));
1074
1075
        if ($group) {
1076
            $this->Groups()->add($group);
1077
        } else {
1078
            if (!$title) {
1079
                $title = $groupcode;
1080
            }
1081
1082
            $group = new Group();
1083
            $group->Code = $groupcode;
1084
            $group->Title = $title;
1085
            $group->write();
1086
1087
            $this->Groups()->add($group);
1088
        }
1089
    }
1090
1091
    /**
1092
     * Removes a member from a group.
1093
     *
1094
     * @param string $groupcode
1095
     */
1096
    public function removeFromGroupByCode($groupcode)
1097
    {
1098
        $group = Group::get()->filter(array('Code' => $groupcode))->first();
1099
1100
        if ($group) {
0 ignored issues
show
introduced by
$group is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
1101
            $this->Groups()->remove($group);
1102
        }
1103
    }
1104
1105
    /**
1106
     * @param array $columns Column names on the Member record to show in {@link getTitle()}.
1107
     * @param String $sep Separator
1108
     */
1109
    public static function set_title_columns($columns, $sep = ' ')
1110
    {
1111
        Deprecation::notice('5.0', 'Use Member.title_format config instead');
1112
        if (!is_array($columns)) {
0 ignored issues
show
introduced by
The condition is_array($columns) is always true.
Loading history...
1113
            $columns = array($columns);
1114
        }
1115
        self::config()->set(
1116
            'title_format',
1117
            [
1118
                'columns' => $columns,
1119
                'sep' => $sep
1120
            ]
1121
        );
1122
    }
1123
1124
    //------------------- HELPER METHODS -----------------------------------//
1125
1126
    /**
1127
     * Get the complete name of the member, by default in the format "<Surname>, <FirstName>".
1128
     * Falls back to showing either field on its own.
1129
     *
1130
     * You can overload this getter with {@link set_title_format()}
1131
     * and {@link set_title_sql()}.
1132
     *
1133
     * @return string Returns the first- and surname of the member. If the ID
1134
     *  of the member is equal 0, only the surname is returned.
1135
     */
1136
    public function getTitle()
1137
    {
1138
        $format = static::config()->get('title_format');
1139
        if ($format) {
1140
            $values = array();
1141
            foreach ($format['columns'] as $col) {
1142
                $values[] = $this->getField($col);
1143
            }
1144
1145
            return implode($format['sep'], $values);
1146
        }
1147
        if ($this->getField('ID') === 0) {
1148
            return $this->getField('Surname');
1149
        } else {
1150
            if ($this->getField('Surname') && $this->getField('FirstName')) {
1151
                return $this->getField('Surname') . ', ' . $this->getField('FirstName');
1152
            } elseif ($this->getField('Surname')) {
1153
                return $this->getField('Surname');
1154
            } elseif ($this->getField('FirstName')) {
1155
                return $this->getField('FirstName');
1156
            } else {
1157
                return null;
1158
            }
1159
        }
1160
    }
1161
1162
    /**
1163
     * Return a SQL CONCAT() fragment suitable for a SELECT statement.
1164
     * Useful for custom queries which assume a certain member title format.
1165
     *
1166
     * @return String SQL
1167
     */
1168
    public static function get_title_sql()
1169
    {
1170
1171
        // Get title_format with fallback to default
1172
        $format = static::config()->get('title_format');
1173
        if (!$format) {
1174
            $format = [
1175
                'columns' => ['Surname', 'FirstName'],
1176
                'sep' => ' ',
1177
            ];
1178
        }
1179
1180
        $columnsWithTablename = array();
1181
        foreach ($format['columns'] as $column) {
1182
            $columnsWithTablename[] = static::getSchema()->sqlColumnForField(__CLASS__, $column);
1183
        }
1184
1185
        $sepSQL = Convert::raw2sql($format['sep'], true);
1186
        $op = DB::get_conn()->concatOperator();
1187
        return "(" . join(" $op $sepSQL $op ", $columnsWithTablename) . ")";
1188
    }
1189
1190
1191
    /**
1192
     * Get the complete name of the member
1193
     *
1194
     * @return string Returns the first- and surname of the member.
1195
     */
1196
    public function getName()
1197
    {
1198
        return ($this->Surname) ? trim($this->FirstName . ' ' . $this->Surname) : $this->FirstName;
1199
    }
1200
1201
1202
    /**
1203
     * Set first- and surname
1204
     *
1205
     * This method assumes that the last part of the name is the surname, e.g.
1206
     * <i>A B C</i> will result in firstname <i>A B</i> and surname <i>C</i>
1207
     *
1208
     * @param string $name The name
1209
     */
1210
    public function setName($name)
1211
    {
1212
        $nameParts = explode(' ', $name);
1213
        $this->Surname = array_pop($nameParts);
1214
        $this->FirstName = join(' ', $nameParts);
1215
    }
1216
1217
1218
    /**
1219
     * Alias for {@link setName}
1220
     *
1221
     * @param string $name The name
1222
     * @see setName()
1223
     */
1224
    public function splitName($name)
1225
    {
1226
        return $this->setName($name);
0 ignored issues
show
Bug introduced by
Are you sure the usage of $this->setName($name) targeting SilverStripe\Security\Member::setName() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
1227
    }
1228
1229
    /**
1230
     * Return the date format based on the user's chosen locale,
1231
     * falling back to the default format defined by the {@link i18n.get_locale()} setting.
1232
     *
1233
     * @return string ISO date format
1234
     */
1235
    public function getDateFormat()
1236
    {
1237
        $formatter = new IntlDateFormatter(
1238
            $this->getLocale(),
1239
            IntlDateFormatter::MEDIUM,
1240
            IntlDateFormatter::NONE
1241
        );
1242
        $format = $formatter->getPattern();
1243
1244
        $this->extend('updateDateFormat', $format);
1245
1246
        return $format;
1247
    }
1248
1249
    /**
1250
     * Get user locale
1251
     */
1252
    public function getLocale()
1253
    {
1254
        $locale = $this->getField('Locale');
1255
        if ($locale) {
1256
            return $locale;
1257
        }
1258
1259
        return i18n::get_locale();
1260
    }
1261
1262
    /**
1263
     * Return the time format based on the user's chosen locale,
1264
     * falling back to the default format defined by the {@link i18n.get_locale()} setting.
1265
     *
1266
     * @return string ISO date format
1267
     */
1268
    public function getTimeFormat()
1269
    {
1270
        $formatter = new IntlDateFormatter(
1271
            $this->getLocale(),
1272
            IntlDateFormatter::NONE,
1273
            IntlDateFormatter::MEDIUM
1274
        );
1275
        $format = $formatter->getPattern();
1276
1277
        $this->extend('updateTimeFormat', $format);
1278
1279
        return $format;
1280
    }
1281
1282
    //---------------------------------------------------------------------//
1283
1284
1285
    /**
1286
     * Get a "many-to-many" map that holds for all members their group memberships,
1287
     * including any parent groups where membership is implied.
1288
     * Use {@link DirectGroups()} to only retrieve the group relations without inheritance.
1289
     *
1290
     * @todo Push all this logic into Member_GroupSet's getIterator()?
1291
     * @return Member_Groupset
1292
     */
1293
    public function Groups()
1294
    {
1295
        $groups = Member_GroupSet::create(Group::class, 'Group_Members', 'GroupID', 'MemberID');
1296
        $groups = $groups->forForeignID($this->ID);
1297
1298
        $this->extend('updateGroups', $groups);
1299
1300
        return $groups;
1301
    }
1302
1303
    /**
1304
     * @return ManyManyList|UnsavedRelationList
1305
     */
1306
    public function DirectGroups()
1307
    {
1308
        return $this->getManyManyComponents('Groups');
1309
    }
1310
1311
    /**
1312
     * Get a member SQLMap of members in specific groups
1313
     *
1314
     * If no $groups is passed, all members will be returned
1315
     *
1316
     * @param mixed $groups - takes a SS_List, an array or a single Group.ID
1317
     * @return Map Returns an Map that returns all Member data.
1318
     */
1319
    public static function map_in_groups($groups = null)
1320
    {
1321
        $groupIDList = array();
1322
1323
        if ($groups instanceof SS_List) {
1324
            foreach ($groups as $group) {
1325
                $groupIDList[] = $group->ID;
1326
            }
1327
        } elseif (is_array($groups)) {
1328
            $groupIDList = $groups;
1329
        } elseif ($groups) {
1330
            $groupIDList[] = $groups;
1331
        }
1332
1333
        // No groups, return all Members
1334
        if (!$groupIDList) {
1335
            return static::get()->sort(array('Surname' => 'ASC', 'FirstName' => 'ASC'))->map();
1336
        }
1337
1338
        $membersList = new ArrayList();
1339
        // This is a bit ineffective, but follow the ORM style
1340
        /** @var Group $group */
1341
        foreach (Group::get()->byIDs($groupIDList) as $group) {
1342
            $membersList->merge($group->Members());
1343
        }
1344
1345
        $membersList->removeDuplicates('ID');
1346
1347
        return $membersList->map();
1348
    }
1349
1350
1351
    /**
1352
     * Get a map of all members in the groups given that have CMS permissions
1353
     *
1354
     * If no groups are passed, all groups with CMS permissions will be used.
1355
     *
1356
     * @param array $groups Groups to consider or NULL to use all groups with
1357
     *                      CMS permissions.
1358
     * @return Map Returns a map of all members in the groups given that
1359
     *                have CMS permissions.
1360
     */
1361
    public static function mapInCMSGroups($groups = null)
1362
    {
1363
        // Check CMS module exists
1364
        if (!class_exists(LeftAndMain::class)) {
1365
            return ArrayList::create()->map();
1366
        }
1367
1368
        if (count($groups) == 0) {
1369
            $perms = array('ADMIN', 'CMS_ACCESS_AssetAdmin');
1370
1371
            if (class_exists(CMSMain::class)) {
1372
                $cmsPerms = CMSMain::singleton()->providePermissions();
1373
            } else {
1374
                $cmsPerms = LeftAndMain::singleton()->providePermissions();
1375
            }
1376
1377
            if (!empty($cmsPerms)) {
1378
                $perms = array_unique(array_merge($perms, array_keys($cmsPerms)));
1379
            }
1380
1381
            $permsClause = DB::placeholders($perms);
1382
            /** @skipUpgrade */
1383
            $groups = Group::get()
1384
                ->innerJoin("Permission", '"Permission"."GroupID" = "Group"."ID"')
1385
                ->where(array(
1386
                    "\"Permission\".\"Code\" IN ($permsClause)" => $perms
1387
                ));
1388
        }
1389
1390
        $groupIDList = array();
1391
1392
        if ($groups instanceof SS_List) {
1393
            foreach ($groups as $group) {
1394
                $groupIDList[] = $group->ID;
1395
            }
1396
        } elseif (is_array($groups)) {
1397
            $groupIDList = $groups;
1398
        }
1399
1400
        /** @skipUpgrade */
1401
        $members = static::get()
1402
            ->innerJoin("Group_Members", '"Group_Members"."MemberID" = "Member"."ID"')
1403
            ->innerJoin("Group", '"Group"."ID" = "Group_Members"."GroupID"');
1404
        if ($groupIDList) {
1405
            $groupClause = DB::placeholders($groupIDList);
1406
            $members = $members->where(array(
1407
                "\"Group\".\"ID\" IN ($groupClause)" => $groupIDList
1408
            ));
1409
        }
1410
1411
        return $members->sort('"Member"."Surname", "Member"."FirstName"')->map();
1412
    }
1413
1414
1415
    /**
1416
     * Get the groups in which the member is NOT in
1417
     *
1418
     * When passed an array of groups, and a component set of groups, this
1419
     * function will return the array of groups the member is NOT in.
1420
     *
1421
     * @param array $groupList An array of group code names.
1422
     * @param array $memberGroups A component set of groups (if set to NULL,
1423
     *                            $this->groups() will be used)
1424
     * @return array Groups in which the member is NOT in.
1425
     */
1426
    public function memberNotInGroups($groupList, $memberGroups = null)
1427
    {
1428
        if (!$memberGroups) {
1429
            $memberGroups = $this->Groups();
1430
        }
1431
1432
        foreach ($memberGroups as $group) {
1433
            if (in_array($group->Code, $groupList)) {
1434
                $index = array_search($group->Code, $groupList);
1435
                unset($groupList[$index]);
1436
            }
1437
        }
1438
1439
        return $groupList;
1440
    }
1441
1442
1443
    /**
1444
     * Return a {@link FieldList} of fields that would appropriate for editing
1445
     * this member.
1446
     *
1447
     * @skipUpgrade
1448
     * @return FieldList Return a FieldList of fields that would appropriate for
1449
     *                   editing this member.
1450
     */
1451
    public function getCMSFields()
1452
    {
1453
        $this->beforeUpdateCMSFields(function (FieldList $fields) {
1454
            /** @var TabSet $rootTabSet */
1455
            $rootTabSet = $fields->fieldByName("Root");
1456
            /** @var Tab $mainTab */
1457
            $mainTab = $rootTabSet->fieldByName("Main");
1458
            /** @var FieldList $mainFields */
1459
            $mainFields = $mainTab->getChildren();
1460
1461
            // Build change password field
1462
            $mainFields->replaceField('Password', $this->getMemberPasswordField());
1463
1464
            $mainFields->replaceField('Locale', new DropdownField(
1465
                "Locale",
1466
                _t(__CLASS__ . '.INTERFACELANG', "Interface Language", 'Language of the CMS'),
1467
                i18n::getSources()->getKnownLocales()
1468
            ));
1469
            $mainFields->removeByName(static::config()->get('hidden_fields'));
1470
1471
            if (!static::config()->get('lock_out_after_incorrect_logins')) {
1472
                $mainFields->removeByName('FailedLoginCount');
1473
            }
1474
1475
            // Groups relation will get us into logical conflicts because
1476
            // Members are displayed within  group edit form in SecurityAdmin
1477
            $fields->removeByName('Groups');
1478
1479
            // Members shouldn't be able to directly view/edit logged passwords
1480
            $fields->removeByName('LoggedPasswords');
1481
1482
            $fields->removeByName('RememberLoginHashes');
1483
1484
            if (Permission::check('EDIT_PERMISSIONS')) {
1485
                // Filter allowed groups
1486
                $groups = Group::get();
1487
                $disallowedGroupIDs = $this->disallowedGroups();
1488
                if ($disallowedGroupIDs) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $disallowedGroupIDs of type integer[] 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...
1489
                    $groups = $groups->exclude('ID', $disallowedGroupIDs);
1490
                }
1491
                $groupsMap = array();
1492
                foreach ($groups as $group) {
1493
                    // Listboxfield values are escaped, use ASCII char instead of &raquo;
1494
                    $groupsMap[$group->ID] = $group->getBreadcrumbs(' > ');
1495
                }
1496
                asort($groupsMap);
1497
                $fields->addFieldToTab(
1498
                    'Root.Main',
1499
                    ListboxField::create('DirectGroups', Group::singleton()->i18n_plural_name())
1500
                        ->setSource($groupsMap)
1501
                        ->setAttribute(
1502
                            'data-placeholder',
1503
                            _t(__CLASS__ . '.ADDGROUP', 'Add group', 'Placeholder text for a dropdown')
1504
                        )
1505
                );
1506
1507
1508
                // Add permission field (readonly to avoid complicated group assignment logic).
1509
                // This should only be available for existing records, as new records start
1510
                // with no permissions until they have a group assignment anyway.
1511
                if ($this->ID) {
1512
                    $permissionsField = new PermissionCheckboxSetField_Readonly(
1513
                        'Permissions',
1514
                        false,
0 ignored issues
show
Bug introduced by
false of type false is incompatible with the type string expected by parameter $title of SilverStripe\Security\Pe...Readonly::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1514
                        /** @scrutinizer ignore-type */ false,
Loading history...
1515
                        Permission::class,
1516
                        'GroupID',
1517
                        // we don't want parent relationships, they're automatically resolved in the field
1518
                        $this->getManyManyComponents('Groups')
1519
                    );
1520
                    $fields->findOrMakeTab('Root.Permissions', Permission::singleton()->i18n_plural_name());
1521
                    $fields->addFieldToTab('Root.Permissions', $permissionsField);
1522
                }
1523
            }
1524
1525
            $permissionsTab = $rootTabSet->fieldByName('Permissions');
1526
            if ($permissionsTab) {
0 ignored issues
show
introduced by
$permissionsTab is of type SilverStripe\Forms\FormField, thus it always evaluated to true.
Loading history...
1527
                $permissionsTab->addExtraClass('readonly');
1528
            }
1529
        });
1530
1531
        return parent::getCMSFields();
1532
    }
1533
1534
    /**
1535
     * @param bool $includerelations Indicate if the labels returned include relation fields
1536
     * @return array
1537
     */
1538
    public function fieldLabels($includerelations = true)
1539
    {
1540
        $labels = parent::fieldLabels($includerelations);
1541
1542
        $labels['FirstName'] = _t(__CLASS__ . '.FIRSTNAME', 'First Name');
1543
        $labels['Surname'] = _t(__CLASS__ . '.SURNAME', 'Surname');
1544
        /** @skipUpgrade */
1545
        $labels['Email'] = _t(__CLASS__ . '.EMAIL', 'Email');
1546
        $labels['Password'] = _t(__CLASS__ . '.db_Password', 'Password');
1547
        $labels['PasswordExpiry'] = _t(
1548
            __CLASS__ . '.db_PasswordExpiry',
1549
            'Password Expiry Date',
1550
            'Password expiry date'
1551
        );
1552
        $labels['LockedOutUntil'] = _t(__CLASS__ . '.db_LockedOutUntil', 'Locked out until', 'Security related date');
1553
        $labels['Locale'] = _t(__CLASS__ . '.db_Locale', 'Interface Locale');
1554
        if ($includerelations) {
1555
            $labels['Groups'] = _t(
1556
                __CLASS__ . '.belongs_many_many_Groups',
1557
                'Groups',
1558
                'Security Groups this member belongs to'
1559
            );
1560
        }
1561
1562
        return $labels;
1563
    }
1564
1565
    /**
1566
     * Users can view their own record.
1567
     * Otherwise they'll need ADMIN or CMS_ACCESS_SecurityAdmin permissions.
1568
     * This is likely to be customized for social sites etc. with a looser permission model.
1569
     *
1570
     * @param Member $member
1571
     * @return bool
1572
     */
1573
    public function canView($member = null)
1574
    {
1575
        //get member
1576
        if (!$member) {
1577
            $member = Security::getCurrentUser();
1578
        }
1579
        //check for extensions, we do this first as they can overrule everything
1580
        $extended = $this->extendedCan(__FUNCTION__, $member);
1581
        if ($extended !== null) {
1582
            return $extended;
1583
        }
1584
1585
        //need to be logged in and/or most checks below rely on $member being a Member
1586
        if (!$member) {
0 ignored issues
show
introduced by
$member is of type SilverStripe\Security\Member, thus it always evaluated to true.
Loading history...
1587
            return false;
1588
        }
1589
        // members can usually view their own record
1590
        if ($this->ID == $member->ID) {
1591
            return true;
1592
        }
1593
1594
        //standard check
1595
        return Permission::checkMember($member, 'CMS_ACCESS_SecurityAdmin');
1596
    }
1597
1598
    /**
1599
     * Users can edit their own record.
1600
     * Otherwise they'll need ADMIN or CMS_ACCESS_SecurityAdmin permissions
1601
     *
1602
     * @param Member $member
1603
     * @return bool
1604
     */
1605
    public function canEdit($member = null)
1606
    {
1607
        //get member
1608
        if (!$member) {
1609
            $member = Security::getCurrentUser();
1610
        }
1611
        //check for extensions, we do this first as they can overrule everything
1612
        $extended = $this->extendedCan(__FUNCTION__, $member);
1613
        if ($extended !== null) {
1614
            return $extended;
1615
        }
1616
1617
        //need to be logged in and/or most checks below rely on $member being a Member
1618
        if (!$member) {
0 ignored issues
show
introduced by
$member is of type SilverStripe\Security\Member, thus it always evaluated to true.
Loading history...
1619
            return false;
1620
        }
1621
1622
        // HACK: we should not allow for an non-Admin to edit an Admin
1623
        if (!Permission::checkMember($member, 'ADMIN') && Permission::checkMember($this, 'ADMIN')) {
1624
            return false;
1625
        }
1626
        // members can usually edit their own record
1627
        if ($this->ID == $member->ID) {
1628
            return true;
1629
        }
1630
1631
        //standard check
1632
        return Permission::checkMember($member, 'CMS_ACCESS_SecurityAdmin');
1633
    }
1634
1635
    /**
1636
     * Users can edit their own record.
1637
     * Otherwise they'll need ADMIN or CMS_ACCESS_SecurityAdmin permissions
1638
     *
1639
     * @param Member $member
1640
     * @return bool
1641
     */
1642
    public function canDelete($member = null)
1643
    {
1644
        if (!$member) {
1645
            $member = Security::getCurrentUser();
1646
        }
1647
        //check for extensions, we do this first as they can overrule everything
1648
        $extended = $this->extendedCan(__FUNCTION__, $member);
1649
        if ($extended !== null) {
1650
            return $extended;
1651
        }
1652
1653
        //need to be logged in and/or most checks below rely on $member being a Member
1654
        if (!$member) {
0 ignored issues
show
introduced by
$member is of type SilverStripe\Security\Member, thus it always evaluated to true.
Loading history...
1655
            return false;
1656
        }
1657
        // Members are not allowed to remove themselves,
1658
        // since it would create inconsistencies in the admin UIs.
1659
        if ($this->ID && $member->ID == $this->ID) {
1660
            return false;
1661
        }
1662
1663
        // HACK: if you want to delete a member, you have to be a member yourself.
1664
        // this is a hack because what this should do is to stop a user
1665
        // deleting a member who has more privileges (e.g. a non-Admin deleting an Admin)
1666
        if (Permission::checkMember($this, 'ADMIN')) {
1667
            if (!Permission::checkMember($member, 'ADMIN')) {
1668
                return false;
1669
            }
1670
        }
1671
1672
        //standard check
1673
        return Permission::checkMember($member, 'CMS_ACCESS_SecurityAdmin');
1674
    }
1675
1676
    /**
1677
     * Validate this member object.
1678
     */
1679
    public function validate()
1680
    {
1681
        // If validation is disabled, skip this step
1682
        if (!DataObject::config()->uninherited('validation_enabled')) {
1683
            return ValidationResult::create();
1684
        }
1685
1686
        $valid = parent::validate();
1687
        $validator = static::password_validator();
1688
1689
        if (!$this->ID || $this->isChanged('Password')) {
1690
            if ($this->Password && $validator) {
1691
                $userValid = $validator->validate($this->Password, $this);
1692
                $valid->combineAnd($userValid);
1693
            }
1694
        }
1695
1696
        return $valid;
1697
    }
1698
1699
    /**
1700
     * Change password. This will cause rehashing according to the `PasswordEncryption` property. This method will
1701
     * allow extensions to perform actions and augment the validation result if required before the password is written
1702
     * and can check it after the write also.
1703
     *
1704
     * This method will encrypt the password prior to writing.
1705
     *
1706
     * @param string $password Cleartext password
1707
     * @param bool $write Whether to write the member afterwards
1708
     * @return ValidationResult
1709
     */
1710
    public function changePassword($password, $write = true)
1711
    {
1712
        $this->Password = $password;
1713
        $valid = $this->validate();
1714
1715
        $this->extend('onBeforeChangePassword', $password, $valid);
1716
1717
        if ($valid->isValid()) {
1718
            $this->AutoLoginHash = null;
1719
1720
            $this->encryptPassword();
1721
1722
            if ($write) {
1723
                $this->passwordChangesToWrite = true;
1724
                $this->write();
1725
            }
1726
        }
1727
1728
        $this->extend('onAfterChangePassword', $password, $valid);
1729
1730
        return $valid;
1731
    }
1732
1733
    /**
1734
     * Takes a plaintext password (on the Member object) and encrypts it
1735
     *
1736
     * @return $this
1737
     */
1738
    protected function encryptPassword()
1739
    {
1740
        // reset salt so that it gets regenerated - this will invalidate any persistent login cookies
1741
        // or other information encrypted with this Member's settings (see self::encryptWithUserSettings)
1742
        $this->Salt = '';
1743
1744
        // Password was changed: encrypt the password according the settings
1745
        $encryption_details = Security::encrypt_password(
1746
            $this->Password,
1747
            $this->Salt,
1748
            $this->isChanged('PasswordEncryption') ? $this->PasswordEncryption : null,
1749
            $this
1750
        );
1751
1752
        // Overwrite the Password property with the hashed value
1753
        $this->Password = $encryption_details['password'];
1754
        $this->Salt = $encryption_details['salt'];
1755
        $this->PasswordEncryption = $encryption_details['algorithm'];
1756
1757
        // If we haven't manually set a password expiry
1758
        if (!$this->isChanged('PasswordExpiry')) {
1759
            // then set it for us
1760
            if (static::config()->get('password_expiry_days')) {
1761
                $this->PasswordExpiry = date('Y-m-d', time() + 86400 * static::config()->get('password_expiry_days'));
1762
            } else {
1763
                $this->PasswordExpiry = null;
1764
            }
1765
        }
1766
1767
        return $this;
1768
    }
1769
1770
    /**
1771
     * Tell this member that someone made a failed attempt at logging in as them.
1772
     * This can be used to lock the user out temporarily if too many failed attempts are made.
1773
     */
1774
    public function registerFailedLogin()
1775
    {
1776
        $lockOutAfterCount = self::config()->get('lock_out_after_incorrect_logins');
1777
        if ($lockOutAfterCount) {
1778
            // Keep a tally of the number of failed log-ins so that we can lock people out
1779
            ++$this->FailedLoginCount;
1780
1781
            if ($this->FailedLoginCount >= $lockOutAfterCount) {
1782
                $lockoutMins = self::config()->get('lock_out_delay_mins');
1783
                $this->LockedOutUntil = date('Y-m-d H:i:s', DBDatetime::now()->getTimestamp() + $lockoutMins * 60);
1784
                $this->FailedLoginCount = 0;
1785
            }
1786
        }
1787
        $this->extend('registerFailedLogin');
1788
        $this->write();
1789
    }
1790
1791
    /**
1792
     * Tell this member that a successful login has been made
1793
     */
1794
    public function registerSuccessfulLogin()
1795
    {
1796
        if (self::config()->get('lock_out_after_incorrect_logins')) {
1797
            // Forgive all past login failures
1798
            $this->FailedLoginCount = 0;
1799
            $this->LockedOutUntil = null;
1800
            $this->write();
1801
        }
1802
    }
1803
1804
    /**
1805
     * Get the HtmlEditorConfig for this user to be used in the CMS.
1806
     * This is set by the group. If multiple configurations are set,
1807
     * the one with the highest priority wins.
1808
     *
1809
     * @return string
1810
     */
1811
    public function getHtmlEditorConfigForCMS()
1812
    {
1813
        $currentName = '';
1814
        $currentPriority = 0;
1815
1816
        foreach ($this->Groups() as $group) {
1817
            $configName = $group->HtmlEditorConfig;
1818
            if ($configName) {
1819
                $config = HTMLEditorConfig::get($group->HtmlEditorConfig);
1820
                if ($config && $config->getOption('priority') > $currentPriority) {
1821
                    $currentName = $configName;
1822
                    $currentPriority = $config->getOption('priority');
1823
                }
1824
            }
1825
        }
1826
1827
        // If can't find a suitable editor, just default to cms
1828
        return $currentName ? $currentName : 'cms';
1829
    }
1830
}
1831