Passed
Push — master ( 388e0b...41d6d2 )
by
unknown
50s queued 10s
created

AbstractUser::setDisplayName()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 6
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 1
1
<?php
2
3
namespace Charcoal\User;
4
5
use DateTime;
6
use DateTimeInterface;
7
use Exception;
8
use InvalidArgumentException;
9
10
// From 'charcoal-factory'
11
use Charcoal\Factory\FactoryInterface;
12
13
// From 'charcoal-core'
14
use Charcoal\Validator\ValidatorInterface;
15
16
// From 'charcoal-config'
17
use Charcoal\Config\ConfigurableInterface;
18
use Charcoal\Config\ConfigurableTrait;
19
20
// From 'charcoal-object'
21
use Charcoal\Object\Content;
22
23
/**
24
 * Full implementation, as abstract class, of the `UserInterface`.
25
 */
26
abstract class AbstractUser extends Content implements
27
    UserInterface,
28
    ConfigurableInterface
29
{
30
    use ConfigurableTrait;
31
32
    /**
33
     * @var UserInterface $authenticatedUser
34
     */
35
    protected static $authenticatedUser;
36
37
    /**
38
     * The email address should be unique and mandatory.
39
     *
40
     * It is also used as the login name.
41
     *
42
     * @var string
43
     */
44
    private $email;
45
46
    /**
47
     * The password is stored encrypted in the (database) storage.
48
     *
49
     * @var string|null
50
     */
51
    private $password;
52
53
    /**
54
     * The display name serves as a human-readable identifier for the user.
55
     *
56
     * @var string|null
57
     */
58
    private $displayName;
59
60
    /**
61
     * Roles define a set of tasks a user is allowed or denied from performing.
62
     *
63
     * @var string[]
64
     */
65
    private $roles = [];
66
67
    /**
68
     * The timestamp of the latest (successful) login.
69
     *
70
     * @var DateTimeInterface|null
71
     */
72
    private $lastLoginDate;
73
74
    /**
75
     * The IP address during the latest (successful) login.
76
     *
77
     * @var string|null
78
     */
79
    private $lastLoginIp;
80
81
    /**
82
     * The timestamp of the latest password change.
83
     *
84
     * @var DateTimeInterface|null
85
     */
86
    private $lastPasswordDate;
87
88
    /**
89
     * The IP address during the latest password change.
90
     *
91
     * @var string|null
92
     */
93
    private $lastPasswordIp;
94
95
    /**
96
     * Tracks the password reset token.
97
     *
98
     * If the token is set (not empty), then the user should be prompted
99
     * to reset his password after login / enter the token to continue.
100
     *
101
     * @var string|null
102
     */
103
    private $loginToken = '';
104
105
    /**
106
     * Structure
107
     *
108
     * Get the user preferences
109
     *
110
     * @var array|mixed
111
     */
112
    private $preferences;
113
114
    /**
115
     * @param  string $email The user email.
116
     * @throws InvalidArgumentException If the email is not a string.
117
     * @return UserInterface Chainable
118
     */
119
    public function setEmail($email)
120
    {
121
        if (!is_string($email)) {
122
            throw new InvalidArgumentException(
123
                'Set user email: Email must be a string'
124
            );
125
        }
126
127
        $this->email = $email;
128
129
        return $this;
130
    }
131
132
    /**
133
     * @return string
134
     */
135
    public function email()
136
    {
137
        return $this->email;
138
    }
139
140
    /**
141
     * @param  string|null $password The user password. Encrypted in storage.
142
     * @throws InvalidArgumentException If the password is not a string (or null, to reset).
143
     * @return UserInterface Chainable
144
     */
145
    public function setPassword($password)
146
    {
147
        if ($password === null) {
148
            $this->password = $password;
149
        } elseif (is_string($password)) {
150
            $this->password = $password;
151
        } else {
152
            throw new InvalidArgumentException(
153
                'Set user password: Password must be a string'
154
            );
155
        }
156
157
        return $this;
158
    }
159
160
    /**
161
     * @return string|null
162
     */
163
    public function password()
164
    {
165
        return $this->password;
166
    }
167
168
    /**
169
     * @param  string|null $name The user's display name.
170
     * @return UserInterface Chainable
171
     */
172
    public function setDisplayName($name)
173
    {
174
        $this->displayName = $name;
175
176
        return $this;
177
    }
178
179
    /**
180
     * @return string|null
181
     */
182
    public function displayName()
183
    {
184
        return $this->displayName;
185
    }
186
187
    /**
188
     * @param  string|string[]|null $roles The ACL roles this user belongs to.
189
     * @throws InvalidArgumentException If the roles argument is invalid.
190
     * @return UserInterface Chainable
191
     */
192
    public function setRoles($roles)
193
    {
194
        if (empty($roles) && !is_numeric($roles)) {
195
            $this->roles = [];
196
            return $this;
197
        }
198
199
        if (is_string($roles)) {
200
            $roles = explode(',', $roles);
201
        }
202
203
        if (!is_array($roles)) {
204
            throw new InvalidArgumentException(
205
                'Roles must be a comma-separated string or an array'
206
            );
207
        }
208
209
        $this->roles = array_filter(array_map('trim', $roles), 'strlen');
210
211
        return $this;
212
    }
213
214
    /**
215
     * @return string[]
216
     */
217
    public function roles()
218
    {
219
        return $this->roles;
220
    }
221
222
    /**
223
     * @param  string|DateTimeInterface|null $lastLoginDate The last login date.
224
     * @throws InvalidArgumentException If the ts is not a valid date/time.
225
     * @return UserInterface Chainable
226
     */
227 View Code Duplication
    public function setLastLoginDate($lastLoginDate)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
228
    {
229
        if ($lastLoginDate === null) {
230
            $this->lastLoginDate = null;
231
            return $this;
232
        }
233
234
        if (is_string($lastLoginDate)) {
235
            try {
236
                $lastLoginDate = new DateTime($lastLoginDate);
237
            } catch (Exception $e) {
238
                throw new InvalidArgumentException(sprintf(
239
                    'Invalid login date (%s)',
240
                    $e->getMessage()
241
                ), 0, $e);
242
            }
243
        }
244
245
        if (!($lastLoginDate instanceof DateTimeInterface)) {
246
            throw new InvalidArgumentException(
247
                'Invalid "Last Login Date" value. Must be a date/time string or a DateTime object.'
248
            );
249
        }
250
251
        $this->lastLoginDate = $lastLoginDate;
252
253
        return $this;
254
    }
255
256
    /**
257
     * @return DateTimeInterface|null
258
     */
259
    public function lastLoginDate()
260
    {
261
        return $this->lastLoginDate;
262
    }
263
264
    /**
265
     * @param  string|integer|null $ip The last login IP address.
266
     * @throws InvalidArgumentException If the IP is not an IP string, an integer, or null.
267
     * @return UserInterface Chainable
268
     */
269 View Code Duplication
    public function setLastLoginIp($ip)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
270
    {
271
        if ($ip === null) {
272
            $this->lastLoginIp = null;
273
            return $this;
274
        }
275
276
        if (is_int($ip)) {
277
            $ip = long2ip($ip);
278
        }
279
280
        if (!is_string($ip)) {
281
            throw new InvalidArgumentException(
282
                'Invalid IP address'
283
            );
284
        }
285
286
        $this->lastLoginIp = $ip;
287
288
        return $this;
289
    }
290
291
    /**
292
     * Get the last login IP in x.x.x.x format
293
     *
294
     * @return string|null
295
     */
296
    public function lastLoginIp()
297
    {
298
        return $this->lastLoginIp;
299
    }
300
301
    /**
302
     * @param  string|DateTimeInterface|null $lastPasswordDate The last password date.
303
     * @throws InvalidArgumentException If the passsword date is not a valid DateTime.
304
     * @return UserInterface Chainable
305
     */
306 View Code Duplication
    public function setLastPasswordDate($lastPasswordDate)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
307
    {
308
        if ($lastPasswordDate === null) {
309
            $this->lastPasswordDate = null;
310
            return $this;
311
        }
312
313
        if (is_string($lastPasswordDate)) {
314
            try {
315
                $lastPasswordDate = new DateTime($lastPasswordDate);
316
            } catch (Exception $e) {
317
                throw new InvalidArgumentException(sprintf(
318
                    'Invalid last password date (%s)',
319
                    $e->getMessage()
320
                ), 0, $e);
321
            }
322
        }
323
324
        if (!($lastPasswordDate instanceof DateTimeInterface)) {
325
            throw new InvalidArgumentException(
326
                'Invalid "Last Password Date" value. Must be a date/time string or a DateTime object.'
327
            );
328
        }
329
330
        $this->lastPasswordDate = $lastPasswordDate;
331
332
        return $this;
333
    }
334
335
    /**
336
     * @return DateTimeInterface|null
337
     */
338
    public function lastPasswordDate()
339
    {
340
        return $this->lastPasswordDate;
341
    }
342
343
    /**
344
     * @param  integer|string|null $ip The last password IP.
345
     * @throws InvalidArgumentException If the IP is not null, an integer or an IP string.
346
     * @return UserInterface Chainable
347
     */
348 View Code Duplication
    public function setLastPasswordIp($ip)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
349
    {
350
        if ($ip === null) {
351
            $this->lastPasswordIp = null;
352
            return $this;
353
        }
354
355
        if (is_int($ip)) {
356
            $ip = long2ip($ip);
357
        }
358
359
        if (!is_string($ip)) {
360
            throw new InvalidArgumentException(
361
                'Invalid IP address'
362
            );
363
        }
364
365
        $this->lastPasswordIp = $ip;
366
367
        return $this;
368
    }
369
370
    /**
371
     * Get the last password change IP in x.x.x.x format
372
     *
373
     * @return string|null
374
     */
375
    public function lastPasswordIp()
376
    {
377
        return $this->lastPasswordIp;
378
    }
379
380
    /**
381
     * @param  string|null $token The login token.
382
     * @throws InvalidArgumentException If the token is not a string.
383
     * @return UserInterface Chainable
384
     */
385
    public function setLoginToken($token)
386
    {
387
        if ($token === null) {
388
            $this->loginToken = null;
389
            return $this;
390
        }
391
392
        if (!is_string($token)) {
393
            throw new InvalidArgumentException(
394
                'Login Token must be a string'
395
            );
396
        }
397
398
        $this->loginToken = $token;
399
400
        return $this;
401
    }
402
403
    /**
404
     * @return string|null
405
     */
406
    public function loginToken()
407
    {
408
        return $this->loginToken;
409
    }
410
411
    /**
412
     * @param array|mixed $preferences Preferences for AbstractUser.
413
     * @return self
414
     */
415
    public function setPreferences($preferences)
416
    {
417
        $this->preferences = $preferences;
418
419
        return $this;
420
    }
421
422
    /**
423
     * @return array|mixed
424
     */
425
    public function preferences()
426
    {
427
        return $this->preferences;
428
    }
429
430
    /**
431
     * @throws Exception If trying to save a user to session without a ID.
432
     * @return UserInterface Chainable
433
     */
434
    public function saveToSession()
435
    {
436
        if (!$this->id()) {
437
            throw new Exception(
438
                'Can not set auth user; no user ID'
439
            );
440
        }
441
442
        $_SESSION[static::sessionKey()] = $this->id();
443
444
        return $this;
445
    }
446
447
    /**
448
     * Log in the user (in session)
449
     *
450
     * Called when the authentication is successful.
451
     *
452
     * @return boolean Success / Failure
453
     */
454
    public function login()
455
    {
456
        if (!$this->id()) {
457
            return false;
458
        }
459
460
        $this->setLastLoginDate('now');
461
        $ip = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '';
462
        if ($ip) {
463
            $this->setLastLoginIp($ip);
464
        }
465
466
        $this->update([ 'last_login_ip', 'last_login_date' ]);
467
468
        $this->saveToSession();
469
470
        return true;
471
    }
472
473
    /**
474
     * Empties the session var associated to the session key.
475
     *
476
     * @return boolean Logged out or not.
477
     */
478
    public function logout()
479
    {
480
        // Irrelevant call...
481
        if (!$this->id()) {
482
            return false;
483
        }
484
485
        $key = static::sessionKey();
486
487
        $_SESSION[$key] = null;
488
        unset($_SESSION[$key], static::$authenticatedUser[$key]);
489
490
        return true;
491
    }
492
493
    /**
494
     * Reset the password.
495
     *
496
     * Encrypt the password and re-save the object in the database.
497
     * Also updates the last password date & ip.
498
     *
499
     * @param string $plainPassword The plain (non-encrypted) password to reset to.
500
     * @throws InvalidArgumentException If the plain password is not a string.
501
     * @return UserInterface Chainable
502
     */
503
    public function resetPassword($plainPassword)
504
    {
505
        if (!is_string($plainPassword)) {
506
            throw new InvalidArgumentException(
507
                'Can not change password: password is not a string.'
508
            );
509
        }
510
511
        $hash = password_hash($plainPassword, PASSWORD_DEFAULT);
512
        $this->setPassword($hash);
513
514
        $this->setLastPasswordDate('now');
515
        $ip = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '';
516
        if ($ip) {
517
            $this->setLastPasswordIp($ip);
518
        }
519
520
        if ($this->id()) {
521
            $this->update([ 'password', 'last_password_date', 'last_password_ip' ]);
522
        }
523
524
        return $this;
525
    }
526
527
    /**
528
     * Get the currently authenticated user (from session)
529
     *
530
     * Return null if there is no current user in logged into
531
     *
532
     * @param  FactoryInterface $factory The factory to create the user object with.
533
     * @throws Exception If the user from session is invalid.
534
     * @return UserInterface|null
535
     */
536
    public static function getAuthenticated(FactoryInterface $factory)
537
    {
538
        $key = static::sessionKey();
539
540
        if (isset(static::$authenticatedUser[$key])) {
541
            return static::$authenticatedUser[$key];
542
        }
543
544
        if (!isset($_SESSION[$key])) {
545
            return null;
546
        }
547
548
        $userId = $_SESSION[$key];
549
        if (!$userId) {
550
            return null;
551
        }
552
553
        $userClass = get_called_class();
554
        $user = $factory->create($userClass);
555
        $user->load($userId);
556
557
        // Inactive users can not authenticate
558
        if (!$user->id() || !$user->email() || !$user->active()) {
559
            return null;
560
        }
561
562
        static::$authenticatedUser[$key] = $user;
563
564
        return $user;
565
    }
566
567
568
569
    // Extends Charcoal\Validator\ValidatableTrait
570
    // =========================================================================
571
572
    /**
573
     * Validate the model.
574
     *
575
     * @see   \Charcoal\Validator\ValidatorInterface
576
     * @param ValidatorInterface $v Optional. A custom validator object to use for validation. If null, use object's.
577
     * @return boolean
578
     */
579
    public function validate(ValidatorInterface &$v = null)
580
    {
581
        $result = parent::validate($v);
582
        $previousModel = $this->modelFactory()->create(self::class)->load($this->id());
583
584
        $email = $this->email();
585
        if (empty($email)) {
586
            $this->validator()->error(
587
                'Email is required.',
588
                'email'
0 ignored issues
show
Unused Code introduced by
The call to ValidatorInterface::error() has too many arguments starting with 'email'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
589
            );
590
        } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
591
            $this->validator()->error(
592
                'Email format is incorrect.',
593
                'email'
0 ignored issues
show
Unused Code introduced by
The call to ValidatorInterface::error() has too many arguments starting with 'email'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
594
            );
595
        /** Check if updating/changing email. */
596
        } elseif ($previousModel->email() !== $email) {
597
            $existingModel = $this->modelFactory()->create(self::class)->loadFrom('email', $email);
598
            /** Check for existing user with given email. */
599
            if (!empty($existingModel->id())) {
600
                $this->validator()->error(
601
                    'This email is not available.',
602
                    'email'
0 ignored issues
show
Unused Code introduced by
The call to ValidatorInterface::error() has too many arguments starting with 'email'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
603
                );
604
            }
605
        }
606
607
        return count($this->validator()->errorResults()) === 0 && $result;
0 ignored issues
show
Bug introduced by
The method errorResults() does not exist on Charcoal\Validator\ValidatorInterface. Did you maybe mean results()?

This check marks calls to methods that do not seem to exist on an object.

This is most likely the result of a method being renamed without all references to it being renamed likewise.

Loading history...
608
    }
609
}
610