Completed
Push — master ( 0b9e95...0208b2 )
by Damian
42s queued 20s
created

Member::getValidator()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

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

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

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

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

Loading history...
103
    );
104
105
    /**
106
     * @config
107
     * @var boolean
108
     */
109
    private static $notify_password_change = false;
110
111
    /**
112
     * All searchable database columns
113
     * in this object, currently queried
114
     * with a "column LIKE '%keywords%'
115
     * statement.
116
     *
117
     * @var array
118
     * @todo Generic implementation of $searchable_fields on DataObject,
119
     * with definition for different searching algorithms
120
     * (LIKE, FULLTEXT) and default FormFields to construct a searchform.
121
     */
122
    private static $searchable_fields = array(
123
        'FirstName',
124
        'Surname',
125
        'Email',
126
    );
127
128
    /**
129
     * @config
130
     * @var array
131
     */
132
    private static $summary_fields = array(
133
        'FirstName',
134
        'Surname',
135
        'Email',
136
    );
137
138
    /**
139
     * @config
140
     * @var array
141
     */
142
    private static $casting = array(
143
        'Name' => 'Varchar',
144
    );
145
146
    /**
147
     * Internal-use only fields
148
     *
149
     * @config
150
     * @var array
151
     */
152
    private static $hidden_fields = array(
153
        'AutoLoginHash',
154
        'AutoLoginExpired',
155
        'PasswordEncryption',
156
        'PasswordExpiry',
157
        'LockedOutUntil',
158
        'TempIDHash',
159
        'TempIDExpired',
160
        'Salt',
161
    );
162
163
    /**
164
     * @config
165
     * @var array See {@link set_title_columns()}
166
     */
167
    private static $title_format = null;
168
169
    /**
170
     * The unique field used to identify this member.
171
     * By default, it's "Email", but another common
172
     * field could be Username.
173
     *
174
     * @config
175
     * @var string
176
     * @skipUpgrade
177
     */
178
    private static $unique_identifier_field = 'Email';
179
180
    /**
181
     * @config
182
     * The number of days that a password should be valid for.
183
     * By default, this is null, which means that passwords never expire
184
     */
185
    private static $password_expiry_days = null;
186
187
    /**
188
     * @config
189
     * @var bool enable or disable logging of previously used passwords. See {@link onAfterWrite}
190
     */
191
    private static $password_logging_enabled = true;
192
193
    /**
194
     * @config
195
     * @var Int Number of incorrect logins after which
196
     * the user is blocked from further attempts for the timespan
197
     * defined in {@link $lock_out_delay_mins}.
198
     */
199
    private static $lock_out_after_incorrect_logins = 10;
200
201
    /**
202
     * @config
203
     * @var integer Minutes of enforced lockout after incorrect password attempts.
204
     * Only applies if {@link $lock_out_after_incorrect_logins} greater than 0.
205
     */
206
    private static $lock_out_delay_mins = 15;
207
208
    /**
209
     * @config
210
     * @var String If this is set, then a session cookie with the given name will be set on log-in,
211
     * and cleared on logout.
212
     */
213
    private static $login_marker_cookie = null;
214
215
    /**
216
     * Indicates that when a {@link Member} logs in, Member:session_regenerate_id()
217
     * should be called as a security precaution.
218
     *
219
     * This doesn't always work, especially if you're trying to set session cookies
220
     * across an entire site using the domain parameter to session_set_cookie_params()
221
     *
222
     * @config
223
     * @var boolean
224
     */
225
    private static $session_regenerate_id = true;
226
227
228
    /**
229
     * Default lifetime of temporary ids.
230
     *
231
     * This is the period within which a user can be re-authenticated within the CMS by entering only their password
232
     * and without losing their workspace.
233
     *
234
     * Any session expiration outside of this time will require them to login from the frontend using their full
235
     * username and password.
236
     *
237
     * Defaults to 72 hours. Set to zero to disable expiration.
238
     *
239
     * @config
240
     * @var int Lifetime in seconds
241
     */
242
    private static $temp_id_lifetime = 259200;
243
244
    /**
245
     * 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 '
355
                    . 'logging in. Please try again in {count} minutes.',
356
                    null,
357
                    ['count' => static::config()->get('lock_out_delay_mins')]
358
                )
359
            );
360
        }
361
362
        $this->extend('canLogIn', $result);
363
364
        return $result;
365
    }
366
367
    /**
368
     * Returns true if this user is locked out
369
     *
370
     * @skipUpgrade
371
     * @return bool
372
     */
373
    public function isLockedOut()
374
    {
375
        /** @var DBDatetime $lockedOutUntilObj */
376
        $lockedOutUntilObj = $this->dbObject('LockedOutUntil');
377
        if ($lockedOutUntilObj->InFuture()) {
378
            return true;
379
        }
380
381
        $maxAttempts = $this->config()->get('lock_out_after_incorrect_logins');
382
        if ($maxAttempts <= 0) {
383
            return false;
384
        }
385
386
        $idField = static::config()->get('unique_identifier_field');
387
        $attempts = LoginAttempt::getByEmail($this->{$idField})
388
            ->sort('Created', 'DESC')
389
            ->limit($maxAttempts);
390
391
        if ($attempts->count() < $maxAttempts) {
392
            return false;
393
        }
394
395
        foreach ($attempts as $attempt) {
396
            if ($attempt->Status === 'Success') {
397
                return false;
398
            }
399
        }
400
401
        // Calculate effective LockedOutUntil
402
        /** @var DBDatetime $firstFailureDate */
403
        $firstFailureDate = $attempts->first()->dbObject('Created');
404
        $maxAgeSeconds = $this->config()->get('lock_out_delay_mins') * 60;
405
        $lockedOutUntil = $firstFailureDate->getTimestamp() + $maxAgeSeconds;
406
        $now = DBDatetime::now()->getTimestamp();
407
        if ($now < $lockedOutUntil) {
408
            return true;
409
        }
410
411
        return false;
412
    }
413
414
    /**
415
     * Set a {@link PasswordValidator} object to use to validate member's passwords.
416
     *
417
     * @param PasswordValidator $validator
418
     */
419
    public static function set_password_validator(PasswordValidator $validator = null)
420
    {
421
        // Override existing config
422
        Config::modify()->remove(Injector::class, PasswordValidator::class);
423
        if ($validator) {
424
            Injector::inst()->registerService($validator, PasswordValidator::class);
425
        } else {
426
            Injector::inst()->unregisterNamedObject(PasswordValidator::class);
427
        }
428
    }
429
430
    /**
431
     * Returns the default {@link PasswordValidator}
432
     *
433
     * @return PasswordValidator
434
     */
435
    public static function password_validator()
436
    {
437
        if (Injector::inst()->has(PasswordValidator::class)) {
438
            return Injector::inst()->get(PasswordValidator::class);
439
        }
440
        return null;
441
    }
442
443
    public function isPasswordExpired()
444
    {
445
        if (!$this->PasswordExpiry) {
446
            return false;
447
        }
448
449
        return strtotime(date('Y-m-d')) >= strtotime($this->PasswordExpiry);
450
    }
451
452
    /**
453
     * @deprecated 5.0.0 Use Security::setCurrentUser() or IdentityStore::logIn()
454
     *
455
     */
456
    public function logIn()
457
    {
458
        Deprecation::notice(
459
            '5.0.0',
460
            'This method is deprecated and only logs in for the current request. Please use '
461
            . 'Security::setCurrentUser($user) or an IdentityStore'
462
        );
463
        Security::setCurrentUser($this);
464
    }
465
466
    /**
467
     * Called before a member is logged in via session/cookie/etc
468
     */
469
    public function beforeMemberLoggedIn()
470
    {
471
        // @todo Move to middleware on the AuthenticationMiddleware IdentityStore
472
        $this->extend('beforeMemberLoggedIn');
473
    }
474
475
    /**
476
     * Called after a member is logged in via session/cookie/etc
477
     */
478
    public function afterMemberLoggedIn()
479
    {
480
        // Clear the incorrect log-in count
481
        $this->registerSuccessfulLogin();
482
483
        $this->LockedOutUntil = null;
484
485
        $this->regenerateTempID();
486
487
        $this->write();
488
489
        // Audit logging hook
490
        $this->extend('afterMemberLoggedIn');
491
    }
492
493
    /**
494
     * Trigger regeneration of TempID.
495
     *
496
     * This should be performed any time the user presents their normal identification (normally Email)
497
     * and is successfully authenticated.
498
     */
499
    public function regenerateTempID()
500
    {
501
        $generator = new RandomGenerator();
502
        $lifetime = self::config()->get('temp_id_lifetime');
503
        $this->TempIDHash = $generator->randomToken('sha1');
504
        $this->TempIDExpired = $lifetime
505
            ? date('Y-m-d H:i:s', strtotime(DBDatetime::now()->getValue()) + $lifetime)
506
            : null;
507
        $this->write();
508
    }
509
510
    /**
511
     * Check if the member ID logged in session actually
512
     * has a database record of the same ID. If there is
513
     * no logged in user, FALSE is returned anyway.
514
     *
515
     * @deprecated Not needed anymore, as it returns Security::getCurrentUser();
516
     *
517
     * @return boolean TRUE record found FALSE no record found
518
     */
519
    public static function logged_in_session_exists()
520
    {
521
        Deprecation::notice(
522
            '5.0.0',
523
            'This method is deprecated and now does not add value. Please use Security::getCurrentUser()'
524
        );
525
526
        if ($member = Security::getCurrentUser()) {
527
            if ($member && $member->exists()) {
528
                return true;
529
            }
530
        }
531
532
        return false;
533
    }
534
535
    /**
536
     * @deprecated Use Security::setCurrentUser(null) or an IdentityStore
537
     * Logs this member out.
538
     */
539
    public function logOut()
540
    {
541
        Deprecation::notice(
542
            '5.0.0',
543
            'This method is deprecated and now does not persist. Please use '
544
            . 'Security::setCurrentUser(null) or an IdentityStore'
545
        );
546
547
        Injector::inst()->get(IdentityStore::class)->logOut(Controller::curr()->getRequest());
548
    }
549
550
    /**
551
     * Audit logging hook, called before a member is logged out
552
     *
553
     * @param HTTPRequest|null $request
554
     */
555
    public function beforeMemberLoggedOut(HTTPRequest $request = null)
556
    {
557
        $this->extend('beforeMemberLoggedOut', $request);
558
    }
559
560
    /**
561
     * Audit logging hook, called after a member is logged out
562
     *
563
     * @param HTTPRequest|null $request
564
     */
565
    public function afterMemberLoggedOut(HTTPRequest $request = null)
566
    {
567
        $this->extend('afterMemberLoggedOut', $request);
568
    }
569
570
    /**
571
     * Utility for generating secure password hashes for this member.
572
     *
573
     * @param string $string
574
     * @return string
575
     * @throws PasswordEncryptor_NotFoundException
576
     */
577
    public function encryptWithUserSettings($string)
578
    {
579
        if (!$string) {
580
            return null;
581
        }
582
583
        // If the algorithm or salt is not available, it means we are operating
584
        // on legacy account with unhashed password. Do not hash the string.
585
        if (!$this->PasswordEncryption) {
586
            return $string;
587
        }
588
589
        // We assume we have PasswordEncryption and Salt available here.
590
        $e = PasswordEncryptor::create_for_algorithm($this->PasswordEncryption);
591
592
        return $e->encrypt($string, $this->Salt);
593
    }
594
595
    /**
596
     * Generate an auto login token which can be used to reset the password,
597
     * at the same time hashing it and storing in the database.
598
     *
599
     * @param int|null $lifetime DEPRECATED: The lifetime of the auto login hash in days. Overrides
600
     *                           the Member.auto_login_token_lifetime config value
601
     * @return string Token that should be passed to the client (but NOT persisted).
602
     */
603
    public function generateAutologinTokenAndStoreHash($lifetime = null)
604
    {
605
        if ($lifetime !== null) {
606
            Deprecation::notice(
607
                '5.0',
608
                'Passing a $lifetime to Member::generateAutologinTokenAndStoreHash() is deprecated,
609
                    use the Member.auto_login_token_lifetime config setting instead',
610
                Deprecation::SCOPE_GLOBAL
611
            );
612
            $lifetime = (86400 * $lifetime); // Method argument is days, convert to seconds
613
        } else {
614
            $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...
615
        }
616
617
        do {
618
            $generator = new RandomGenerator();
619
            $token = $generator->randomToken();
620
            $hash = $this->encryptWithUserSettings($token);
621
        } 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...
622
            '"Member"."AutoLoginHash"' => $hash
623
        )));
624
625
        $this->AutoLoginHash = $hash;
626
        $this->AutoLoginExpired = date('Y-m-d H:i:s', time() + $lifetime);
627
628
        $this->write();
629
630
        return $token;
631
    }
632
633
    /**
634
     * Check the token against the member.
635
     *
636
     * @param string $autologinToken
637
     *
638
     * @returns bool Is token valid?
639
     */
640
    public function validateAutoLoginToken($autologinToken)
641
    {
642
        $hash = $this->encryptWithUserSettings($autologinToken);
643
        $member = self::member_from_autologinhash($hash, false);
644
645
        return (bool)$member;
646
    }
647
648
    /**
649
     * Return the member for the auto login hash
650
     *
651
     * @param string $hash The hash key
652
     * @param bool $login Should the member be logged in?
653
     *
654
     * @return Member the matching member, if valid
655
     * @return Member
656
     */
657
    public static function member_from_autologinhash($hash, $login = false)
658
    {
659
        /** @var Member $member */
660
        $member = static::get()->filter([
661
            'AutoLoginHash'                => $hash,
662
            'AutoLoginExpired:GreaterThan' => DBDatetime::now()->getValue(),
663
        ])->first();
664
665
        if ($login && $member) {
666
            Injector::inst()->get(IdentityStore::class)->logIn($member);
667
        }
668
669
        return $member;
670
    }
671
672
    /**
673
     * Find a member record with the given TempIDHash value
674
     *
675
     * @param string $tempid
676
     * @return Member
677
     */
678
    public static function member_from_tempid($tempid)
679
    {
680
        $members = static::get()
681
            ->filter('TempIDHash', $tempid);
682
683
        // Exclude expired
684
        if (static::config()->get('temp_id_lifetime')) {
685
            /** @var DataList|Member[] $members */
686
            $members = $members->filter('TempIDExpired:GreaterThan', DBDatetime::now()->getValue());
687
        }
688
689
        return $members->first();
690
    }
691
692
    /**
693
     * Returns the fields for the member form - used in the registration/profile module.
694
     * It should return fields that are editable by the admin and the logged-in user.
695
     *
696
     * @todo possibly move this to an extension
697
     *
698
     * @return FieldList Returns a {@link FieldList} containing the fields for
699
     *                   the member form.
700
     */
701
    public function getMemberFormFields()
702
    {
703
        $fields = parent::getFrontEndFields();
704
705
        $fields->replaceField('Password', $this->getMemberPasswordField());
706
707
        $fields->replaceField('Locale', new DropdownField(
708
            'Locale',
709
            $this->fieldLabel('Locale'),
710
            i18n::getSources()->getKnownLocales()
711
        ));
712
713
        $fields->removeByName(static::config()->get('hidden_fields'));
714
        $fields->removeByName('FailedLoginCount');
715
716
717
        $this->extend('updateMemberFormFields', $fields);
718
719
        return $fields;
720
    }
721
722
    /**
723
     * Builds "Change / Create Password" field for this member
724
     *
725
     * @return ConfirmedPasswordField
726
     */
727
    public function getMemberPasswordField()
728
    {
729
        $editingPassword = $this->isInDB();
730
        $label = $editingPassword
731
            ? _t(__CLASS__ . '.EDIT_PASSWORD', 'New Password')
732
            : $this->fieldLabel('Password');
733
        /** @var ConfirmedPasswordField $password */
734
        $password = ConfirmedPasswordField::create(
735
            'Password',
736
            $label,
737
            null,
738
            null,
739
            $editingPassword
740
        );
741
742
        // If editing own password, require confirmation of existing
743
        if ($editingPassword && $this->ID == Security::getCurrentUser()->ID) {
744
            $password->setRequireExistingPassword(true);
745
        }
746
747
        $password->setCanBeEmpty(true);
748
        $this->extend('updateMemberPasswordField', $password);
749
750
        return $password;
751
    }
752
753
754
    /**
755
     * Returns the {@link RequiredFields} instance for the Member object. This
756
     * Validator is used when saving a {@link CMSProfileController} or added to
757
     * any form responsible for saving a users data.
758
     *
759
     * To customize the required fields, add a {@link DataExtension} to member
760
     * calling the `updateValidator()` method.
761
     *
762
     * @return Member_Validator
763
     */
764
    public function getValidator()
765
    {
766
        $validator = Member_Validator::create();
767
        $validator->setForMember($this);
768
        $this->extend('updateValidator', $validator);
769
770
        return $validator;
771
    }
772
773
774
    /**
775
     * Returns the current logged in user
776
     *
777
     * @deprecated 5.0.0 use Security::getCurrentUser()
778
     *
779
     * @return Member
780
     */
781
    public static function currentUser()
782
    {
783
        Deprecation::notice(
784
            '5.0.0',
785
            'This method is deprecated. Please use Security::getCurrentUser() or an IdentityStore'
786
        );
787
788
        return Security::getCurrentUser();
789
    }
790
791
    /**
792
     * Temporarily act as the specified user, limited to a $callback, but
793
     * without logging in as that user.
794
     *
795
     * E.g.
796
     * <code>
797
     * Member::logInAs(Security::findAnAdministrator(), function() {
798
     *     $record->write();
799
     * });
800
     * </code>
801
     *
802
     * @param Member|null|int $member Member or member ID to log in as.
803
     * Set to null or 0 to act as a logged out user.
804
     * @param callable $callback
805
     */
806
    public static function actAs($member, $callback)
807
    {
808
        $previousUser = Security::getCurrentUser();
809
810
        // Transform ID to member
811
        if (is_numeric($member)) {
812
            $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...
813
        }
814
        Security::setCurrentUser($member);
815
816
        try {
817
            return $callback();
818
        } finally {
819
            Security::setCurrentUser($previousUser);
820
        }
821
    }
822
823
    /**
824
     * Get the ID of the current logged in user
825
     *
826
     * @deprecated 5.0.0 use Security::getCurrentUser()
827
     *
828
     * @return int Returns the ID of the current logged in user or 0.
829
     */
830
    public static function currentUserID()
831
    {
832
        Deprecation::notice(
833
            '5.0.0',
834
            'This method is deprecated. Please use Security::getCurrentUser() or an IdentityStore'
835
        );
836
837
        if ($member = Security::getCurrentUser()) {
838
            return $member->ID;
839
        } else {
840
            return 0;
841
        }
842
    }
843
844
    /**
845
     * Generate a random password, with randomiser to kick in if there's no words file on the
846
     * filesystem.
847
     *
848
     * @return string Returns a random password.
849
     */
850
    public static function create_new_password()
851
    {
852
        $words = Security::config()->uninherited('word_list');
853
854
        if ($words && file_exists($words)) {
855
            $words = file($words);
856
857
            list($usec, $sec) = explode(' ', microtime());
858
            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

858
            mt_srand(/** @scrutinizer ignore-type */ $sec + ((float)$usec * 100000));
Loading history...
859
860
            $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

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

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

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