Completed
Push — master ( df6bd3...df7ec2 )
by Mathieu
11:21
created

AbstractUser   C

Complexity

Total Complexity 63

Size/Duplication

Total Lines 531
Duplicated Lines 18.83 %

Coupling/Cohesion

Components 3
Dependencies 3

Importance

Changes 4
Bugs 0 Features 0
Metric Value
wmc 63
c 4
b 0
f 0
lcom 3
cbo 3
dl 100
loc 531
rs 5.8893

24 Methods

Rating   Name   Duplication   Size   Complexity  
A key() 0 4 1
A setUsername() 10 12 2
A username() 0 4 1
A setEmail() 0 12 2
A email() 0 4 1
A setPassword() 0 14 3
A password() 0 4 1
B setRoles() 0 21 5
A roles() 0 4 1
B setLastLoginDate() 28 28 5
A lastLoginDate() 0 4 1
A setLastLoginIp() 17 21 4
A lastLoginIp() 0 4 1
B setLastPasswordDate() 28 28 5
A lastPasswordDate() 0 4 1
A setLastPasswordIp() 17 21 4
A lastPasswordIp() 0 4 1
A setLoginToken() 0 17 3
A loginToken() 0 4 1
A saveToSession() 0 12 2
A login() 0 18 4
A logout() 0 14 2
B resetPassword() 0 23 5
C getAuthenticated() 0 30 7

How to fix   Duplicated Code    Complexity   

Duplicated Code

Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.

Common duplication problems, and corresponding solutions are:

Complex Class

 Tip:   Before tackling complexity, make sure that you eliminate any duplication first. This often can reduce the size of classes significantly.

Complex classes like AbstractUser often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use AbstractUser, and based on these observations, apply Extract Interface, too.

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-config'
14
use Charcoal\Config\ConfigurableInterface;
15
use Charcoal\Config\ConfigurableTrait;
16
17
// From 'charcoal-object'
18
use Charcoal\Object\Content;
19
20
/**
21
 * Full implementation, as abstract class, of the `UserInterface`.
22
 */
23
abstract class AbstractUser extends Content implements
24
    UserInterface,
25
    ConfigurableInterface
26
{
27
    use ConfigurableTrait;
28
29
    /**
30
     * @var UserInterface $authenticatedUser
31
     */
32
    protected static $authenticatedUser;
33
34
    /**
35
     * The username should be unique and mandatory.
36
     *
37
     * It is also used as the login name and main identifier (key).
38
     *
39
     * @var string
40
     */
41
    private $username = '';
42
43
    /**
44
     * The password is stored encrypted in the (database) storage.
45
     *
46
     * @var string|null
47
     */
48
    private $password;
49
50
    /**
51
     * The email address associated with the account.
52
     *
53
     * @var string
54
     */
55
    private $email;
56
57
    /**
58
     * Roles define a set of tasks a user is allowed or denied from performing.
59
     *
60
     * @var string[]
61
     */
62
    private $roles = [];
63
64
    /**
65
     * The timestamp of the latest (successful) login.
66
     *
67
     * @var DateTimeInterface|null
68
     */
69
    private $lastLoginDate;
70
71
    /**
72
     * The IP address during the latest (successful) login.
73
     *
74
     * @var string|null
75
     */
76
    private $lastLoginIp;
77
78
    /**
79
     * The timestamp of the latest password change.
80
     *
81
     * @var DateTimeInterface|null
82
     */
83
    private $lastPasswordDate;
84
85
    /**
86
     * The IP address during the latest password change.
87
     *
88
     * @var string|null
89
     */
90
    private $lastPasswordIp;
91
92
    /**
93
     * Tracks the password reset token.
94
     *
95
     * If the token is set (not empty), then the user should be prompted
96
     * to reset his password after login / enter the token to continue.
97
     *
98
     * @var string|null
99
     */
100
    private $loginToken = '';
101
102
    /**
103
     * @see    \Charcoal\Source\StorableTrait::key()
104
     * @return string
105
     */
106
    public function key()
107
    {
108
        return 'username';
109
    }
110
111
    /**
112
     * Force a lowercase username
113
     *
114
     * @param  string $username The username (also the login name).
115
     * @throws InvalidArgumentException If the username is not a string.
116
     * @return UserInterface Chainable
117
     */
118 View Code Duplication
    public function setUsername($username)
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...
119
    {
120
        if (!is_string($username)) {
121
            throw new InvalidArgumentException(
122
                'Set user username: Username must be a string'
123
            );
124
        }
125
126
        $this->username = mb_strtolower($username);
127
128
        return $this;
129
    }
130
131
    /**
132
     * @return string
133
     */
134
    public function username()
135
    {
136
        return $this->username;
137
    }
138
139
    /**
140
     * @param  string $email The user email.
141
     * @throws InvalidArgumentException If the email is not a string.
142
     * @return UserInterface Chainable
143
     */
144
    public function setEmail($email)
145
    {
146
        if (!is_string($email)) {
147
            throw new InvalidArgumentException(
148
                'Set user email: Email must be a string'
149
            );
150
        }
151
152
        $this->email = $email;
153
154
        return $this;
155
    }
156
157
    /**
158
     * @return string
159
     */
160
    public function email()
161
    {
162
        return $this->email;
163
    }
164
165
    /**
166
     * @param  string|null $password The user password. Encrypted in storage.
167
     * @throws InvalidArgumentException If the password is not a string (or null, to reset).
168
     * @return UserInterface Chainable
169
     */
170
    public function setPassword($password)
171
    {
172
        if ($password === null) {
173
            $this->password = $password;
174
        } elseif (is_string($password)) {
175
            $this->password = $password;
176
        } else {
177
            throw new InvalidArgumentException(
178
                'Set user password: Password must be a string'
179
            );
180
        }
181
182
        return $this;
183
    }
184
185
    /**
186
     * @return string|null
187
     */
188
    public function password()
189
    {
190
        return $this->password;
191
    }
192
193
    /**
194
     * @param  string|string[]|null $roles The ACL roles this user belongs to.
195
     * @throws InvalidArgumentException If the roles argument is invalid.
196
     * @return UserInterface Chainable
197
     */
198
    public function setRoles($roles)
199
    {
200
        if (empty($roles) && !is_numeric($roles)) {
201
            $this->roles = [];
202
            return $this;
203
        }
204
205
        if (is_string($roles)) {
206
            $roles = explode(',', $roles);
207
        }
208
209
        if (!is_array($roles)) {
210
            throw new InvalidArgumentException(
211
                'Roles must be a comma-separated string or an array'
212
            );
213
        }
214
215
        $this->roles = array_filter(array_map('trim', $roles), 'strlen');
216
217
        return $this;
218
    }
219
220
    /**
221
     * @return string[]
222
     */
223
    public function roles()
224
    {
225
        return $this->roles;
226
    }
227
228
    /**
229
     * @param  string|DateTimeInterface|null $lastLoginDate The last login date.
230
     * @throws InvalidArgumentException If the ts is not a valid date/time.
231
     * @return UserInterface Chainable
232
     */
233 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...
234
    {
235
        if ($lastLoginDate === null) {
236
            $this->lastLoginDate = null;
237
            return $this;
238
        }
239
240
        if (is_string($lastLoginDate)) {
241
            try {
242
                $lastLoginDate = new DateTime($lastLoginDate);
243
            } catch (Exception $e) {
244
                throw new InvalidArgumentException(sprintf(
245
                    'Invalid login date (%s)',
246
                    $e->getMessage()
247
                ), 0, $e);
248
            }
249
        }
250
251
        if (!($lastLoginDate instanceof DateTimeInterface)) {
252
            throw new InvalidArgumentException(
253
                'Invalid "Last Login Date" value. Must be a date/time string or a DateTime object.'
254
            );
255
        }
256
257
        $this->lastLoginDate = $lastLoginDate;
258
259
        return $this;
260
    }
261
262
    /**
263
     * @return DateTimeInterface|null
264
     */
265
    public function lastLoginDate()
266
    {
267
        return $this->lastLoginDate;
268
    }
269
270
    /**
271
     * @param  string|integer|null $ip The last login IP address.
272
     * @throws InvalidArgumentException If the IP is not an IP string, an integer, or null.
273
     * @return UserInterface Chainable
274
     */
275 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...
276
    {
277
        if ($ip === null) {
278
            $this->lastLoginIp = null;
279
            return $this;
280
        }
281
282
        if (is_int($ip)) {
283
            $ip = long2ip($ip);
284
        }
285
286
        if (!is_string($ip)) {
287
            throw new InvalidArgumentException(
288
                'Invalid IP address'
289
            );
290
        }
291
292
        $this->lastLoginIp = $ip;
293
294
        return $this;
295
    }
296
297
    /**
298
     * Get the last login IP in x.x.x.x format
299
     *
300
     * @return string|null
301
     */
302
    public function lastLoginIp()
303
    {
304
        return $this->lastLoginIp;
305
    }
306
307
    /**
308
     * @param  string|DateTimeInterface|null $lastPasswordDate The last password date.
309
     * @throws InvalidArgumentException If the passsword date is not a valid DateTime.
310
     * @return UserInterface Chainable
311
     */
312 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...
313
    {
314
        if ($lastPasswordDate === null) {
315
            $this->lastPasswordDate = null;
316
            return $this;
317
        }
318
319
        if (is_string($lastPasswordDate)) {
320
            try {
321
                $lastPasswordDate = new DateTime($lastPasswordDate);
322
            } catch (Exception $e) {
323
                throw new InvalidArgumentException(sprintf(
324
                    'Invalid last password date (%s)',
325
                    $e->getMessage()
326
                ), 0, $e);
327
            }
328
        }
329
330
        if (!($lastPasswordDate instanceof DateTimeInterface)) {
331
            throw new InvalidArgumentException(
332
                'Invalid "Last Password Date" value. Must be a date/time string or a DateTime object.'
333
            );
334
        }
335
336
        $this->lastPasswordDate = $lastPasswordDate;
337
338
        return $this;
339
    }
340
341
    /**
342
     * @return DateTimeInterface|null
343
     */
344
    public function lastPasswordDate()
345
    {
346
        return $this->lastPasswordDate;
347
    }
348
349
    /**
350
     * @param  integer|string|null $ip The last password IP.
351
     * @throws InvalidArgumentException If the IP is not null, an integer or an IP string.
352
     * @return UserInterface Chainable
353
     */
354 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...
355
    {
356
        if ($ip === null) {
357
            $this->lastPasswordIp = null;
358
            return $this;
359
        }
360
361
        if (is_int($ip)) {
362
            $ip = long2ip($ip);
363
        }
364
365
        if (!is_string($ip)) {
366
            throw new InvalidArgumentException(
367
                'Invalid IP address'
368
            );
369
        }
370
371
        $this->lastPasswordIp = $ip;
372
373
        return $this;
374
    }
375
376
    /**
377
     * Get the last password change IP in x.x.x.x format
378
     *
379
     * @return string|null
380
     */
381
    public function lastPasswordIp()
382
    {
383
        return $this->lastPasswordIp;
384
    }
385
386
    /**
387
     * @param  string|null $token The login token.
388
     * @throws InvalidArgumentException If the token is not a string.
389
     * @return UserInterface Chainable
390
     */
391
    public function setLoginToken($token)
392
    {
393
        if ($token === null) {
394
            $this->loginToken = null;
395
            return $this;
396
        }
397
398
        if (!is_string($token)) {
399
            throw new InvalidArgumentException(
400
                'Login Token must be a string'
401
            );
402
        }
403
404
        $this->loginToken = $token;
405
406
        return $this;
407
    }
408
409
    /**
410
     * @return string|null
411
     */
412
    public function loginToken()
413
    {
414
        return $this->loginToken;
415
    }
416
417
    /**
418
     * @throws Exception If trying to save a user to session without a ID.
419
     * @return UserInterface Chainable
420
     */
421
    public function saveToSession()
0 ignored issues
show
Coding Style introduced by
saveToSession uses the super-global variable $_SESSION which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
422
    {
423
        if (!$this->id()) {
424
            throw new Exception(
425
                'Can not set auth user; no user ID'
426
            );
427
        }
428
429
        $_SESSION[static::sessionKey()] = $this->id();
430
431
        return $this;
432
    }
433
434
    /**
435
     * Log in the user (in session)
436
     *
437
     * Called when the authentication is successful.
438
     *
439
     * @return boolean Success / Failure
440
     */
441
    public function login()
0 ignored issues
show
Coding Style introduced by
login uses the super-global variable $_SERVER which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
442
    {
443
        if (!$this->id()) {
444
            return false;
445
        }
446
447
        $this->setLastLoginDate('now');
448
        $ip = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '';
449
        if ($ip) {
450
            $this->setLastLoginIp($ip);
451
        }
452
453
        $this->update([ 'last_login_ip', 'last_login_date' ]);
454
455
        $this->saveToSession();
456
457
        return true;
458
    }
459
460
    /**
461
     * Empties the session var associated to the session key.
462
     *
463
     * @return boolean Logged out or not.
464
     */
465
    public function logout()
0 ignored issues
show
Coding Style introduced by
logout uses the super-global variable $_SESSION which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
466
    {
467
        // Irrelevant call...
468
        if (!$this->id()) {
469
            return false;
470
        }
471
472
        $key = static::sessionKey();
473
474
        $_SESSION[$key] = null;
475
        unset($_SESSION[$key], static::$authenticatedUser[$key]);
476
477
        return true;
478
    }
479
480
    /**
481
     * Reset the password.
482
     *
483
     * Encrypt the password and re-save the object in the database.
484
     * Also updates the last password date & ip.
485
     *
486
     * @param string $plainPassword The plain (non-encrypted) password to reset to.
487
     * @throws InvalidArgumentException If the plain password is not a string.
488
     * @return UserInterface Chainable
489
     */
490
    public function resetPassword($plainPassword)
0 ignored issues
show
Coding Style introduced by
resetPassword uses the super-global variable $_SERVER which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
491
    {
492
        if (!is_string($plainPassword)) {
493
            throw new InvalidArgumentException(
494
                'Can not change password: password is not a string.'
495
            );
496
        }
497
498
        $hash = password_hash($plainPassword, PASSWORD_DEFAULT);
499
        $this->setPassword($hash);
500
501
        $this->setLastPasswordDate('now');
502
        $ip = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '';
503
        if ($ip) {
504
            $this->setLastPasswordIp($ip);
505
        }
506
507
        if ($this->id()) {
508
            $this->update([ 'password', 'last_password_date', 'last_password_ip' ]);
509
        }
510
511
        return $this;
512
    }
513
514
    /**
515
     * Get the currently authenticated user (from session)
516
     *
517
     * Return null if there is no current user in logged into
518
     *
519
     * @param  FactoryInterface $factory The factory to create the user object with.
520
     * @throws Exception If the user from session is invalid.
521
     * @return UserInterface|null
522
     */
523
    public static function getAuthenticated(FactoryInterface $factory)
0 ignored issues
show
Coding Style introduced by
getAuthenticated uses the super-global variable $_SESSION which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
524
    {
525
        $key = static::sessionKey();
526
527
        if (isset(static::$authenticatedUser[$key])) {
528
            return static::$authenticatedUser[$key];
529
        }
530
531
        if (!isset($_SESSION[$key])) {
532
            return null;
533
        }
534
535
        $userId = $_SESSION[$key];
536
        if (!$userId) {
537
            return null;
538
        }
539
540
        $userClass = get_called_class();
541
        $user = $factory->create($userClass);
542
        $user->load($userId);
543
544
        // Inactive users can not authenticate
545
        if (!$user->id() || !$user->username() || !$user->active()) {
546
            return null;
547
        }
548
549
        static::$authenticatedUser[$key] = $user;
550
551
        return $user;
552
    }
553
}
554