Passed
Push — 4.0.4 ( fe4f6f )
by Robbie
09:31
created

Member::getLocale()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 4
nc 2
nop 0
dl 0
loc 8
rs 9.4285
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\Security;
4
5
use IntlDateFormatter;
6
use InvalidArgumentException;
7
use SilverStripe\Admin\LeftAndMain;
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->Password) || ($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
    }
956
957
    /**
958
     * Delete the MemberPassword objects that are associated to this user
959
     *
960
     * @return $this
961
     */
962
    protected function deletePasswordLogs()
963
    {
964
        foreach ($this->LoggedPasswords() as $password) {
965
            $password->delete();
966
            $password->destroy();
967
        }
968
969
        return $this;
970
    }
971
972
    /**
973
     * Filter out admin groups to avoid privilege escalation,
974
     * If any admin groups are requested, deny the whole save operation.
975
     *
976
     * @param array $ids Database IDs of Group records
977
     * @return bool True if the change can be accepted
978
     */
979
    public function onChangeGroups($ids)
980
    {
981
        // Ensure none of these match disallowed list
982
        $disallowedGroupIDs = $this->disallowedGroups();
983
        return count(array_intersect($ids, $disallowedGroupIDs)) == 0;
984
    }
985
986
    /**
987
     * List of group IDs this user is disallowed from
988
     *
989
     * @return int[] List of group IDs
990
     */
991
    protected function disallowedGroups()
992
    {
993
        // unless the current user is an admin already OR the logged in user is an admin
994
        if (Permission::check('ADMIN') || Permission::checkMember($this, 'ADMIN')) {
995
            return [];
996
        }
997
998
        // Non-admins may not belong to admin groups
999
        return Permission::get_groups_by_permission('ADMIN')->column('ID');
1000
    }
1001
1002
1003
    /**
1004
     * Check if the member is in one of the given groups.
1005
     *
1006
     * @param array|SS_List $groups Collection of {@link Group} DataObjects to check
1007
     * @param boolean $strict Only determine direct group membership if set to true (Default: false)
1008
     * @return bool Returns TRUE if the member is in one of the given groups, otherwise FALSE.
1009
     */
1010
    public function inGroups($groups, $strict = false)
1011
    {
1012
        if ($groups) {
1013
            foreach ($groups as $group) {
1014
                if ($this->inGroup($group, $strict)) {
1015
                    return true;
1016
                }
1017
            }
1018
        }
1019
1020
        return false;
1021
    }
1022
1023
1024
    /**
1025
     * Check if the member is in the given group or any parent groups.
1026
     *
1027
     * @param int|Group|string $group Group instance, Group Code or ID
1028
     * @param boolean $strict Only determine direct group membership if set to TRUE (Default: FALSE)
1029
     * @return bool Returns TRUE if the member is in the given group, otherwise FALSE.
1030
     */
1031
    public function inGroup($group, $strict = false)
1032
    {
1033
        if (is_numeric($group)) {
1034
            $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

1034
            $groupCheckObj = DataObject::get_by_id(Group::class, /** @scrutinizer ignore-type */ $group);
Loading history...
1035
        } elseif (is_string($group)) {
1036
            $groupCheckObj = DataObject::get_one(Group::class, array(
1037
                '"Group"."Code"' => $group
1038
            ));
1039
        } elseif ($group instanceof Group) {
0 ignored issues
show
introduced by
$group is always a sub-type of SilverStripe\Security\Group.
Loading history...
1040
            $groupCheckObj = $group;
1041
        } else {
1042
            throw new InvalidArgumentException('Member::inGroup(): Wrong format for $group parameter');
1043
        }
1044
1045
        if (!$groupCheckObj) {
1046
            return false;
1047
        }
1048
1049
        $groupCandidateObjs = ($strict) ? $this->getManyManyComponents("Groups") : $this->Groups();
1050
        if ($groupCandidateObjs) {
1051
            foreach ($groupCandidateObjs as $groupCandidateObj) {
1052
                if ($groupCandidateObj->ID == $groupCheckObj->ID) {
1053
                    return true;
1054
                }
1055
            }
1056
        }
1057
1058
        return false;
1059
    }
1060
1061
    /**
1062
     * Adds the member to a group. This will create the group if the given
1063
     * group code does not return a valid group object.
1064
     *
1065
     * @param string $groupcode
1066
     * @param string $title Title of the group
1067
     */
1068
    public function addToGroupByCode($groupcode, $title = "")
1069
    {
1070
        $group = DataObject::get_one(Group::class, array(
1071
            '"Group"."Code"' => $groupcode
1072
        ));
1073
1074
        if ($group) {
1075
            $this->Groups()->add($group);
1076
        } else {
1077
            if (!$title) {
1078
                $title = $groupcode;
1079
            }
1080
1081
            $group = new Group();
1082
            $group->Code = $groupcode;
1083
            $group->Title = $title;
1084
            $group->write();
1085
1086
            $this->Groups()->add($group);
1087
        }
1088
    }
1089
1090
    /**
1091
     * Removes a member from a group.
1092
     *
1093
     * @param string $groupcode
1094
     */
1095
    public function removeFromGroupByCode($groupcode)
1096
    {
1097
        $group = Group::get()->filter(array('Code' => $groupcode))->first();
1098
1099
        if ($group) {
0 ignored issues
show
introduced by
$group is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
1100
            $this->Groups()->remove($group);
1101
        }
1102
    }
1103
1104
    /**
1105
     * @param array $columns Column names on the Member record to show in {@link getTitle()}.
1106
     * @param String $sep Separator
1107
     */
1108
    public static function set_title_columns($columns, $sep = ' ')
1109
    {
1110
        Deprecation::notice('5.0', 'Use Member.title_format config instead');
1111
        if (!is_array($columns)) {
0 ignored issues
show
introduced by
The condition is_array($columns) is always true.
Loading history...
1112
            $columns = array($columns);
1113
        }
1114
        self::config()->set(
1115
            'title_format',
1116
            [
1117
                'columns' => $columns,
1118
                'sep' => $sep
1119
            ]
1120
        );
1121
    }
1122
1123
    //------------------- HELPER METHODS -----------------------------------//
1124
1125
    /**
1126
     * Get the complete name of the member, by default in the format "<Surname>, <FirstName>".
1127
     * Falls back to showing either field on its own.
1128
     *
1129
     * You can overload this getter with {@link set_title_format()}
1130
     * and {@link set_title_sql()}.
1131
     *
1132
     * @return string Returns the first- and surname of the member. If the ID
1133
     *  of the member is equal 0, only the surname is returned.
1134
     */
1135
    public function getTitle()
1136
    {
1137
        $format = static::config()->get('title_format');
1138
        if ($format) {
1139
            $values = array();
1140
            foreach ($format['columns'] as $col) {
1141
                $values[] = $this->getField($col);
1142
            }
1143
1144
            return implode($format['sep'], $values);
1145
        }
1146
        if ($this->getField('ID') === 0) {
1147
            return $this->getField('Surname');
1148
        } else {
1149
            if ($this->getField('Surname') && $this->getField('FirstName')) {
1150
                return $this->getField('Surname') . ', ' . $this->getField('FirstName');
1151
            } elseif ($this->getField('Surname')) {
1152
                return $this->getField('Surname');
1153
            } elseif ($this->getField('FirstName')) {
1154
                return $this->getField('FirstName');
1155
            } else {
1156
                return null;
1157
            }
1158
        }
1159
    }
1160
1161
    /**
1162
     * Return a SQL CONCAT() fragment suitable for a SELECT statement.
1163
     * Useful for custom queries which assume a certain member title format.
1164
     *
1165
     * @return String SQL
1166
     */
1167
    public static function get_title_sql()
1168
    {
1169
1170
        // Get title_format with fallback to default
1171
        $format = static::config()->get('title_format');
1172
        if (!$format) {
1173
            $format = [
1174
                'columns' => ['Surname', 'FirstName'],
1175
                'sep' => ' ',
1176
            ];
1177
        }
1178
1179
        $columnsWithTablename = array();
1180
        foreach ($format['columns'] as $column) {
1181
            $columnsWithTablename[] = static::getSchema()->sqlColumnForField(__CLASS__, $column);
1182
        }
1183
1184
        $sepSQL = Convert::raw2sql($format['sep'], true);
1185
        $op = DB::get_conn()->concatOperator();
1186
        return "(" . join(" $op $sepSQL $op ", $columnsWithTablename) . ")";
1187
    }
1188
1189
1190
    /**
1191
     * Get the complete name of the member
1192
     *
1193
     * @return string Returns the first- and surname of the member.
1194
     */
1195
    public function getName()
1196
    {
1197
        return ($this->Surname) ? trim($this->FirstName . ' ' . $this->Surname) : $this->FirstName;
1198
    }
1199
1200
1201
    /**
1202
     * Set first- and surname
1203
     *
1204
     * This method assumes that the last part of the name is the surname, e.g.
1205
     * <i>A B C</i> will result in firstname <i>A B</i> and surname <i>C</i>
1206
     *
1207
     * @param string $name The name
1208
     */
1209
    public function setName($name)
1210
    {
1211
        $nameParts = explode(' ', $name);
1212
        $this->Surname = array_pop($nameParts);
1213
        $this->FirstName = join(' ', $nameParts);
1214
    }
1215
1216
1217
    /**
1218
     * Alias for {@link setName}
1219
     *
1220
     * @param string $name The name
1221
     * @see setName()
1222
     */
1223
    public function splitName($name)
1224
    {
1225
        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...
1226
    }
1227
1228
    /**
1229
     * Return the date format based on the user's chosen locale,
1230
     * falling back to the default format defined by the {@link i18n.get_locale()} setting.
1231
     *
1232
     * @return string ISO date format
1233
     */
1234
    public function getDateFormat()
1235
    {
1236
        $formatter = new IntlDateFormatter(
1237
            $this->getLocale(),
1238
            IntlDateFormatter::MEDIUM,
1239
            IntlDateFormatter::NONE
1240
        );
1241
        $format = $formatter->getPattern();
1242
1243
        $this->extend('updateDateFormat', $format);
1244
1245
        return $format;
1246
    }
1247
1248
    /**
1249
     * Get user locale
1250
     */
1251
    public function getLocale()
1252
    {
1253
        $locale = $this->getField('Locale');
1254
        if ($locale) {
1255
            return $locale;
1256
        }
1257
1258
        return i18n::get_locale();
1259
    }
1260
1261
    /**
1262
     * Return the time format based on the user's chosen locale,
1263
     * falling back to the default format defined by the {@link i18n.get_locale()} setting.
1264
     *
1265
     * @return string ISO date format
1266
     */
1267
    public function getTimeFormat()
1268
    {
1269
        $formatter = new IntlDateFormatter(
1270
            $this->getLocale(),
1271
            IntlDateFormatter::NONE,
1272
            IntlDateFormatter::MEDIUM
1273
        );
1274
        $format = $formatter->getPattern();
1275
1276
        $this->extend('updateTimeFormat', $format);
1277
1278
        return $format;
1279
    }
1280
1281
    //---------------------------------------------------------------------//
1282
1283
1284
    /**
1285
     * Get a "many-to-many" map that holds for all members their group memberships,
1286
     * including any parent groups where membership is implied.
1287
     * Use {@link DirectGroups()} to only retrieve the group relations without inheritance.
1288
     *
1289
     * @todo Push all this logic into Member_GroupSet's getIterator()?
1290
     * @return Member_Groupset
1291
     */
1292
    public function Groups()
1293
    {
1294
        $groups = Member_GroupSet::create(Group::class, 'Group_Members', 'GroupID', 'MemberID');
1295
        $groups = $groups->forForeignID($this->ID);
1296
1297
        $this->extend('updateGroups', $groups);
1298
1299
        return $groups;
1300
    }
1301
1302
    /**
1303
     * @return ManyManyList|UnsavedRelationList
1304
     */
1305
    public function DirectGroups()
1306
    {
1307
        return $this->getManyManyComponents('Groups');
1308
    }
1309
1310
    /**
1311
     * Get a member SQLMap of members in specific groups
1312
     *
1313
     * If no $groups is passed, all members will be returned
1314
     *
1315
     * @param mixed $groups - takes a SS_List, an array or a single Group.ID
1316
     * @return Map Returns an Map that returns all Member data.
1317
     */
1318
    public static function map_in_groups($groups = null)
1319
    {
1320
        $groupIDList = array();
1321
1322
        if ($groups instanceof SS_List) {
1323
            foreach ($groups as $group) {
1324
                $groupIDList[] = $group->ID;
1325
            }
1326
        } elseif (is_array($groups)) {
1327
            $groupIDList = $groups;
1328
        } elseif ($groups) {
1329
            $groupIDList[] = $groups;
1330
        }
1331
1332
        // No groups, return all Members
1333
        if (!$groupIDList) {
1334
            return static::get()->sort(array('Surname' => 'ASC', 'FirstName' => 'ASC'))->map();
1335
        }
1336
1337
        $membersList = new ArrayList();
1338
        // This is a bit ineffective, but follow the ORM style
1339
        /** @var Group $group */
1340
        foreach (Group::get()->byIDs($groupIDList) as $group) {
1341
            $membersList->merge($group->Members());
1342
        }
1343
1344
        $membersList->removeDuplicates('ID');
1345
1346
        return $membersList->map();
1347
    }
1348
1349
1350
    /**
1351
     * Get a map of all members in the groups given that have CMS permissions
1352
     *
1353
     * If no groups are passed, all groups with CMS permissions will be used.
1354
     *
1355
     * @param array $groups Groups to consider or NULL to use all groups with
1356
     *                      CMS permissions.
1357
     * @return Map Returns a map of all members in the groups given that
1358
     *                have CMS permissions.
1359
     */
1360
    public static function mapInCMSGroups($groups = null)
1361
    {
1362
        // Check CMS module exists
1363
        if (!class_exists(LeftAndMain::class)) {
1364
            return ArrayList::create()->map();
1365
        }
1366
1367
        if (count($groups) == 0) {
1368
            $perms = array('ADMIN', 'CMS_ACCESS_AssetAdmin');
1369
1370
            if (class_exists(CMSMain::class)) {
1371
                $cmsPerms = CMSMain::singleton()->providePermissions();
1372
            } else {
1373
                $cmsPerms = LeftAndMain::singleton()->providePermissions();
1374
            }
1375
1376
            if (!empty($cmsPerms)) {
1377
                $perms = array_unique(array_merge($perms, array_keys($cmsPerms)));
1378
            }
1379
1380
            $permsClause = DB::placeholders($perms);
1381
            /** @skipUpgrade */
1382
            $groups = Group::get()
1383
                ->innerJoin("Permission", '"Permission"."GroupID" = "Group"."ID"')
1384
                ->where(array(
1385
                    "\"Permission\".\"Code\" IN ($permsClause)" => $perms
1386
                ));
1387
        }
1388
1389
        $groupIDList = array();
1390
1391
        if ($groups instanceof SS_List) {
1392
            foreach ($groups as $group) {
1393
                $groupIDList[] = $group->ID;
1394
            }
1395
        } elseif (is_array($groups)) {
1396
            $groupIDList = $groups;
1397
        }
1398
1399
        /** @skipUpgrade */
1400
        $members = static::get()
1401
            ->innerJoin("Group_Members", '"Group_Members"."MemberID" = "Member"."ID"')
1402
            ->innerJoin("Group", '"Group"."ID" = "Group_Members"."GroupID"');
1403
        if ($groupIDList) {
1404
            $groupClause = DB::placeholders($groupIDList);
1405
            $members = $members->where(array(
1406
                "\"Group\".\"ID\" IN ($groupClause)" => $groupIDList
1407
            ));
1408
        }
1409
1410
        return $members->sort('"Member"."Surname", "Member"."FirstName"')->map();
1411
    }
1412
1413
1414
    /**
1415
     * Get the groups in which the member is NOT in
1416
     *
1417
     * When passed an array of groups, and a component set of groups, this
1418
     * function will return the array of groups the member is NOT in.
1419
     *
1420
     * @param array $groupList An array of group code names.
1421
     * @param array $memberGroups A component set of groups (if set to NULL,
1422
     *                            $this->groups() will be used)
1423
     * @return array Groups in which the member is NOT in.
1424
     */
1425
    public function memberNotInGroups($groupList, $memberGroups = null)
1426
    {
1427
        if (!$memberGroups) {
1428
            $memberGroups = $this->Groups();
1429
        }
1430
1431
        foreach ($memberGroups as $group) {
1432
            if (in_array($group->Code, $groupList)) {
1433
                $index = array_search($group->Code, $groupList);
1434
                unset($groupList[$index]);
1435
            }
1436
        }
1437
1438
        return $groupList;
1439
    }
1440
1441
1442
    /**
1443
     * Return a {@link FieldList} of fields that would appropriate for editing
1444
     * this member.
1445
     *
1446
     * @skipUpgrade
1447
     * @return FieldList Return a FieldList of fields that would appropriate for
1448
     *                   editing this member.
1449
     */
1450
    public function getCMSFields()
1451
    {
1452
        $this->beforeUpdateCMSFields(function (FieldList $fields) {
1453
            /** @var TabSet $rootTabSet */
1454
            $rootTabSet = $fields->fieldByName("Root");
1455
            /** @var Tab $mainTab */
1456
            $mainTab = $rootTabSet->fieldByName("Main");
1457
            /** @var FieldList $mainFields */
1458
            $mainFields = $mainTab->getChildren();
1459
1460
            // Build change password field
1461
            $mainFields->replaceField('Password', $this->getMemberPasswordField());
1462
1463
            $mainFields->replaceField('Locale', new DropdownField(
1464
                "Locale",
1465
                _t(__CLASS__ . '.INTERFACELANG', "Interface Language", 'Language of the CMS'),
1466
                i18n::getSources()->getKnownLocales()
1467
            ));
1468
            $mainFields->removeByName(static::config()->get('hidden_fields'));
1469
1470
            if (!static::config()->get('lock_out_after_incorrect_logins')) {
1471
                $mainFields->removeByName('FailedLoginCount');
1472
            }
1473
1474
            // Groups relation will get us into logical conflicts because
1475
            // Members are displayed within  group edit form in SecurityAdmin
1476
            $fields->removeByName('Groups');
1477
1478
            // Members shouldn't be able to directly view/edit logged passwords
1479
            $fields->removeByName('LoggedPasswords');
1480
1481
            $fields->removeByName('RememberLoginHashes');
1482
1483
            if (Permission::check('EDIT_PERMISSIONS')) {
1484
                // Filter allowed groups
1485
                $groups = Group::get();
1486
                $disallowedGroupIDs = $this->disallowedGroups();
1487
                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...
1488
                    $groups = $groups->exclude('ID', $disallowedGroupIDs);
1489
                }
1490
                $groupsMap = array();
1491
                foreach ($groups as $group) {
1492
                    // Listboxfield values are escaped, use ASCII char instead of &raquo;
1493
                    $groupsMap[$group->ID] = $group->getBreadcrumbs(' > ');
1494
                }
1495
                asort($groupsMap);
1496
                $fields->addFieldToTab(
1497
                    'Root.Main',
1498
                    ListboxField::create('DirectGroups', Group::singleton()->i18n_plural_name())
1499
                        ->setSource($groupsMap)
1500
                        ->setAttribute(
1501
                            'data-placeholder',
1502
                            _t(__CLASS__ . '.ADDGROUP', 'Add group', 'Placeholder text for a dropdown')
1503
                        )
1504
                );
1505
1506
1507
                // Add permission field (readonly to avoid complicated group assignment logic).
1508
                // This should only be available for existing records, as new records start
1509
                // with no permissions until they have a group assignment anyway.
1510
                if ($this->ID) {
1511
                    $permissionsField = new PermissionCheckboxSetField_Readonly(
1512
                        'Permissions',
1513
                        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

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