Completed
Push — master ( 8a3e9b...ad7ada )
by Maximilian
02:34
created

User::isApproved()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 4
rs 10
c 0
b 0
f 0
cc 1
eloc 2
nc 1
nop 0
1
<?php
2
3
/*
4
 * This file is part of the Sententiaregum project.
5
 *
6
 * (c) Maximilian Bosch <[email protected]>
7
 * (c) Ben Bieler <[email protected]>
8
 *
9
 * For the full copyright and license information, please view the LICENSE
10
 * file that was distributed with this source code.
11
 */
12
13
declare(strict_types=1);
14
15
namespace AppBundle\Model\User;
16
17
use AppBundle\Model\User\Util\Date\DateTimeComparison;
18
use DateTime;
19
use Doctrine\Common\Collections\ArrayCollection;
20
use Doctrine\ORM\Mapping as ORM;
21
use Ma27\ApiKeyAuthenticationBundle\Annotation as Auth;
22
use Ma27\ApiKeyAuthenticationBundle\Model\Password\PasswordHasherInterface;
23
use Ramsey\Uuid\Uuid;
24
use Serializable;
25
use Symfony\Component\Security\Core\User\UserInterface;
26
27
/**
28
 * User.
29
 *
30
 * @author Maximilian Bosch <[email protected]>
31
 *
32
 * @ORM\Entity(repositoryClass="AppBundle\Service\Doctrine\Repository\UserRepository")
33
 * @ORM\Table(name="User", indexes={
34
 *     @ORM\Index(name="user_lastAction", columns={"last_action"}),
35
 *     @ORM\Index(name="user_locale", columns={"locale"}),
36
 *     @ORM\Index(name="user_activation", columns={"username", "pendingActivation_activation_date"})
37
 * })
38
 * @ORM\Cache(usage="READ_WRITE", region="strict")
39
 */
40
class User implements UserInterface, Serializable
41
{
42
    const STATE_NEW                   = 'new';
43
    const STATE_APPROVED              = 'approved';
44
    const STATE_LOCKED                = 'locked';
45
    const MAX_FAILED_ATTEMPTS_FROM_IP = 3;
46
    const FAILED_AUTH_POOL            = 'failed_auths';
47
    const AUTH_ATTEMPT_POOL           = 'auth_attempts';
48
49
    /**
50
     * @var string
51
     *
52
     * @ORM\Id
53
     * @ORM\GeneratedValue(strategy="NONE")
54
     * @ORM\Column(name="id", type="guid")
55
     */
56
    private $id;
57
58
    /**
59
     * @var string
60
     *
61
     * @ORM\Column(name="username", type="string", length=50, unique=true)
62
     * @Auth\Login
63
     */
64
    private $username;
65
66
    /**
67
     * @var string
68
     *
69
     * @ORM\Column(name="password", type="string", length=500)
70
     * @Auth\Password
71
     */
72
    private $password;
73
74
    /**
75
     * @var string
76
     *
77
     * @ORM\Column(name="email", type="string", unique=true)
78
     */
79
    private $email;
80
81
    /**
82
     * @var string
83
     *
84
     * @ORM\Column(name="api_key", type="string", length=200, unique=true, nullable=true)
85
     * @Auth\ApiKey
86
     */
87
    private $apiKey;
88
89
    /**
90
     * @var DateTime
91
     *
92
     * @ORM\Column(name="last_action", type="datetime")
93
     * @Auth\LastAction
94
     */
95
    private $lastAction;
96
97
    /**
98
     * @var DateTime
99
     *
100
     * @ORM\Column(name="registration_date", type="datetime")
101
     */
102
    private $registrationDate;
103
104
    /**
105
     * @var string
106
     *
107
     * @ORM\Column(name="state", type="string")
108
     */
109
    private $state;
110
111
    /**
112
     * @var string
113
     *
114
     * @ORM\Column(name="about_text", type="string", nullable=true)
115
     */
116
    private $aboutText;
117
118
    /**
119
     * @var ArrayCollection
120
     *
121
     * @ORM\ManyToMany(targetEntity="AppBundle\Model\User\Role", indexBy="role")
122
     * @ORM\JoinTable(
123
     *     name="User2Role",
124
     *     joinColumns={@ORM\JoinColumn(name="userId")},
125
     *     inverseJoinColumns={@ORM\JoinColumn(name="roleId")}
126
     * )
127
     */
128
    private $roles;
129
130
    /**
131
     * @var ArrayCollection
132
     *
133
     * @ORM\ManyToMany(targetEntity="AppBundle\Model\User\User", indexBy="username", fetch="EXTRA_LAZY")
134
     * @ORM\JoinTable(
135
     *     name="Follower",
136
     *     joinColumns={@ORM\JoinColumn(name="userId")},
137
     *     inverseJoinColumns={@ORM\JoinColumn(name="followerId")}
138
     * )
139
     */
140
    private $following;
141
142
    /**
143
     * @var string
144
     *
145
     * @ORM\Column(name="locale", length=2)
146
     */
147
    private $locale = 'en';
148
149
    /**
150
     * @var PendingActivation
151
     *
152
     * @ORM\Embedded(class="AppBundle\Model\User\PendingActivation")
153
     */
154
    private $pendingActivation;
155
156
    /**
157
     * @var ArrayCollection
158
     *
159
     * @ORM\ManyToMany(
160
     *     targetEntity="AppBundle\Model\User\AuthenticationAttempt",
161
     *     indexBy="username",
162
     *     orphanRemoval=true,
163
     *     cascade={"persist"}
164
     * )
165
     * @ORM\JoinTable(
166
     *     name="FailedAuthAttempt2User",
167
     *     joinColumns={@ORM\JoinColumn(name="userId")},
168
     *     inverseJoinColumns={@ORM\JoinColumn(name="attemptId")}
169
     * )
170
     */
171
    private $failedAuthentications;
172
173
    /**
174
     * @var ArrayCollection
175
     *
176
     * @ORM\ManyToMany(
177
     *     targetEntity="AppBundle\Model\User\AuthenticationAttempt",
178
     *     indexBy="username",
179
     *     orphanRemoval=true,
180
     *     cascade={"persist"}
181
     * )
182
     * @ORM\JoinTable(
183
     *     name="Auth2User",
184
     *     joinColumns={@ORM\JoinColumn(name="userId")},
185
     *     inverseJoinColumns={@ORM\JoinColumn(name="attemptId")}
186
     * )
187
     */
188
    private $authentications;
189
190
    /**
191
     * Factory that fills the required fields of the user.
192
     *
193
     * @param string                  $username
194
     * @param string                  $password
195
     * @param string                  $email
196
     * @param PasswordHasherInterface $passwordHasher
197
     *
198
     * @return User
199
     */
200
    public static function create(string $username, string $password, string $email, PasswordHasherInterface $passwordHasher): self
201
    {
202
        $user = new self();
203
        $user->setOrUpdatePassword($password, $passwordHasher);
204
205
        $user->username = $username;
206
        $user->email    = $email;
207
208
        return $user;
209
    }
210
211
    /**
212
     * Constructor.
213
     */
214
    private function __construct()
215
    {
216
        $this->id = Uuid::uuid4()->toString();
217
218
        $this->roles                 = new ArrayCollection();
219
        $this->following             = new ArrayCollection();
220
        $this->failedAuthentications = new ArrayCollection();
221
        $this->authentications       = new ArrayCollection();
222
223
        $this->registrationDate      = new DateTime();
224
        $this->lastAction            = new DateTime();
225
226
        $this->performStateTransition(self::STATE_NEW);
227
    }
228
229
    /**
230
     * Get id.
231
     *
232
     * @return string
233
     */
234
    public function getId(): string
235
    {
236
        return $this->id;
237
    }
238
239
    /**
240
     * Get username.
241
     *
242
     * @return string
243
     */
244
    public function getUsername(): string
245
    {
246
        return $this->username;
247
    }
248
249
    /**
250
     * Set password.
251
     *
252
     * @param string                  $password
253
     * @param PasswordHasherInterface $passwordHasher
254
     * @param string|null             $old
255
     *
256
     * @throws \InvalidArgumentException If the update fails.
257
     *
258
     * @return User
259
     */
260
    public function setOrUpdatePassword(string $password, PasswordHasherInterface $passwordHasher, string $old = null): self
261
    {
262
        if ($this->password && !$passwordHasher->compareWith($this->password, $old)) {
263
            throw new \InvalidArgumentException('Old password is invalid, but must be given to change it!');
264
        }
265
266
        $this->password = $passwordHasher->generateHash($password);
267
268
        return $this;
269
    }
270
271
    /**
272
     * Get password.
273
     *
274
     * @return string
275
     */
276
    public function getPassword(): string
277
    {
278
        return $this->password;
279
    }
280
281
    /**
282
     * Get email.
283
     *
284
     * @return string
285
     */
286
    public function getEmail(): string
287
    {
288
        return $this->email;
289
    }
290
291
    /**
292
     * Get apiKey.
293
     *
294
     * @return string
295
     */
296
    public function getApiKey()
297
    {
298
        return $this->apiKey;
299
    }
300
301
    /**
302
     * Set lastAction.
303
     *
304
     * @return User
305
     */
306
    public function updateLastAction(): self
307
    {
308
        $this->lastAction = new DateTime();
309
310
        return $this;
311
    }
312
313
    /**
314
     * Get lastAction.
315
     *
316
     * @return DateTime
317
     */
318
    public function getLastAction(): \DateTime
319
    {
320
        return $this->lastAction;
321
    }
322
323
    /**
324
     * Get registrationDate.
325
     *
326
     * @return DateTime
327
     */
328
    public function getRegistrationDate(): \DateTime
329
    {
330
        return $this->registrationDate;
331
    }
332
333
    /**
334
     * Set state.
335
     *
336
     * @param string $state
337
     * @param string $key
338
     *
339
     * @throws \InvalidArgumentException If no activation key is given.
340
     * @throws \LogicException           If a locked user receives an invalid state transition.
341
     *
342
     * @return User
343
     */
344
    public function performStateTransition(string $state, string $key = null): self
345
    {
346
        if (!in_array($state, [self::STATE_NEW, self::STATE_APPROVED, self::STATE_LOCKED], true)) {
347
            throw new \InvalidArgumentException('Invalid state!');
348
        }
349
350
        switch ($state) {
351
            case self::STATE_APPROVED:
352
                if (self::STATE_NEW === $this->state) {
353
                    if (($activationInfo = $this->pendingActivation) && $key !== $activationInfo->getKey()) {
354
                        throw new \InvalidArgumentException('Invalid activation key given!');
355
                    }
356
357
                    $this->removeActivationKey();
358
                }
359
                break;
360
            case self::STATE_LOCKED:
361
                if (self::STATE_NEW === $this->state) {
362
                    throw new \LogicException('Only approved users can be locked!');
363
                }
364
                break;
365
            default:
366
                $this->pendingActivation = new PendingActivation($this->getRegistrationDate());
367
        }
368
369
        $this->state = $state;
370
371
        return $this;
372
    }
373
374
    /**
375
     * Get state.
376
     *
377
     * @return string
378
     */
379
    public function getState(): string
380
    {
381
        return $this->state;
382
    }
383
384
    /**
385
     * Get locked.
386
     *
387
     * @return bool
388
     */
389
    public function isLocked(): bool
390
    {
391
        return $this->state === self::STATE_LOCKED;
392
    }
393
394
    /**
395
     * Checks if the current user is approved.
396
     *
397
     * @return bool
398
     */
399
    public function isApproved(): bool
400
    {
401
        return $this->state === self::STATE_APPROVED;
402
    }
403
404
    /**
405
     * Set aboutText.
406
     *
407
     * @param string $aboutText
408
     *
409
     * @return User
410
     */
411
    public function setAboutText($aboutText): self
412
    {
413
        $this->aboutText = (string) $aboutText;
414
415
        return $this;
416
    }
417
418
    /**
419
     * Get aboutText.
420
     *
421
     * @return string
422
     */
423
    public function getAboutText(): string
424
    {
425
        return $this->aboutText;
426
    }
427
428
    /**
429
     * Set activationKey.
430
     *
431
     * @param string $activationKey
432
     *
433
     * @return User
434
     */
435
    public function storeUniqueActivationKeyForNonApprovedUser(string $activationKey): self
436
    {
437
        if (self::STATE_APPROVED === $this->state || self::STATE_LOCKED === $this->state) {
438
            throw new \LogicException('Approved users cannot have an activation key!');
439
        }
440
441
        $this->pendingActivation->setKey($activationKey);
442
443
        return $this;
444
    }
445
446
    /**
447
     * Removes the activation key.
448
     *
449
     * @return User
450
     */
451
    public function removeActivationKey(): self
452
    {
453
        if (self::STATE_NEW !== $this->state) {
454
            throw new \LogicException('Only approved users can remove activation keys!');
455
        }
456
457
        $this->pendingActivation = null;
458
459
        return $this;
460
    }
461
462
    /**
463
     * Adds a role.
464
     *
465
     * @param Role $role
466
     *
467
     * @throws \InvalidArgumentException If the user is not approved.
468
     * @throws \LogicException           If the role is already attached.
469
     *
470
     * @return User
471
     */
472
    public function addRole(Role $role): self
473
    {
474
        if (!$this->isApproved()) {
475
            throw new \InvalidArgumentException('Cannot attach role on non-approved or locked user!');
476
        }
477
478
        if ($this->hasRole($role)) {
479
            throw new \LogicException(sprintf(
480
                'Role "%s" already attached at user "%s"!',
481
                $role->getRole(),
482
                $this->getUsername()
483
            ));
484
        }
485
486
        $this->roles->add($role);
487
488
        return $this;
489
    }
490
491
    /**
492
     * Removes a  role.
493
     *
494
     * @param Role $role
495
     *
496
     * @return User
497
     */
498
    public function removeRole(Role $role): self
499
    {
500
        if (!$this->hasRole($role)) {
501
            throw new \LogicException(sprintf('Cannot remove not existing role "%s"!', $role->getRole()));
502
        }
503
504
        $this->roles->removeElement($role);
505
506
        return $this;
507
    }
508
509
    /**
510
     * Checks whether the user has a role.
511
     *
512
     * @param Role $role
513
     *
514
     * @return bool
515
     */
516
    public function hasRole(Role $role): bool
517
    {
518
        return $this->roles->exists(function (int $index, Role $userRole) use ($role) {
519
            return $role->getRole() === $userRole->getRole();
520
        });
521
    }
522
523
    /**
524
     * Gets the roles.
525
     *
526
     * @return Role[]
527
     */
528
    public function getRoles(): array
529
    {
530
        return $this->roles->toArray();
531
    }
532
533
    /**
534
     * Add follower.
535
     *
536
     * @param User $user
537
     *
538
     * @return User
539
     */
540
    public function addFollowing(User $user): self
541
    {
542
        $this->following->add($user);
543
544
        return $this;
545
    }
546
547
    /**
548
     * Removes a follower.
549
     *
550
     * @param User $user
551
     *
552
     * @throws \LogicException If the following user doesn't exist.
553
     *
554
     * @return User
555
     */
556
    public function removeFollowing(User $user): self
557
    {
558
        if (!$this->follows($user)) {
559
            throw new \LogicException(sprintf(
560
                'Cannot remove relation with invalid user "%s"!', $user->getUsername()
561
            ));
562
        }
563
564
        $this->following->removeElement($user);
565
566
        return $this;
567
    }
568
569
    /**
570
     * Checks whether the current user follows a specific user.
571
     *
572
     * @param User $user
573
     *
574
     * @return bool
575
     */
576
    public function follows(User $user): bool
577
    {
578
        return $this->following->exists(function ($index, User $following) use ($user) {
579
            return $following->getId() === $user->getId();
580
        });
581
    }
582
583
    /**
584
     * Get followers.
585
     *
586
     * @return Role[]
587
     */
588
    public function getFollowing(): array
589
    {
590
        return $this->following->toArray();
591
    }
592
593
    /**
594
     * Get locale.
595
     *
596
     * @return string
597
     */
598
    public function getLocale(): string
599
    {
600
        return $this->locale;
601
    }
602
603
    /**
604
     * Set locale.
605
     *
606
     * @param string $locale
607
     *
608
     * @throws \InvalidArgumentException If the locale is invalid.
609
     *
610
     * @return User
611
     */
612
    public function modifyUserLocale(string $locale): self
613
    {
614
        if (!(bool) preg_match('/^([a-z]{2})$/', $locale)) {
615
            throw new \InvalidArgumentException('Invalid locale!');
616
        }
617
618
        $this->locale = (string) $locale;
619
620
        return $this;
621
    }
622
623
    /**
624
     * Get pendingActivation.
625
     *
626
     * @return PendingActivation
627
     */
628
    public function getPendingActivation()
629
    {
630
        return $this->pendingActivation;
631
    }
632
633
    /**
634
     * Checks whether the user ip is new and if true it will be persisted.
635
     *
636
     * @param string                                             $ip
637
     * @param \AppBundle\Model\User\Util\Date\DateTimeComparison $comparison
638
     *
639
     * @return bool
640
     */
641
    public function addAndValidateNewUserIp(string $ip, DateTimeComparison $comparison): bool
642
    {
643
        if (!($isKnown = $this->isKnownIp($ip))) {
644
            $attempt = new AuthenticationAttempt();
645
            $attempt
646
                ->setIp($ip)
647
                ->increaseAttemptCount();
648
649
            $this->authentications->add($attempt);
650
            $this->eraseKnownIpFromBadIPList($ip, $comparison);
651
        }
652
653
        return !$isKnown;
654
    }
655
656
    /**
657
     * Adds one ip of a failed authentication unless its authentication succeeded previously (users may mistype some
658
     * times).
659
     *
660
     * @param string $ip
661
     *
662
     * @return User
663
     */
664
    public function addFailedAuthenticationWithIp(string $ip): self
665
    {
666
        if (!$this->isKnownIp($ip)) {
667
            if (!($attempt = $this->getAuthAttemptModelByIp($ip, self::FAILED_AUTH_POOL))) {
668
                $attempt = new AuthenticationAttempt();
669
                $attempt->setIp($ip);
670
671
                $this->failedAuthentications->add($attempt);
672
            }
673
674
            $attempt->increaseAttemptCount();
675
        }
676
677
        return $this;
678
    }
679
680
    /**
681
     * Checks if one ip exceeds the attempt count.
682
     *
683
     * @param string             $ip
684
     * @param DateTimeComparison $comparison
685
     *
686
     * @return bool
687
     */
688
    public function exceedsIpFailedAuthAttemptMaximum(string $ip, DateTimeComparison $comparison): bool
689
    {
690
        if (!$this->isKnownIp($ip, self::FAILED_AUTH_POOL)) {
691
            return false;
692
        }
693
694
        return $this->needsAuthWarning($this->getAuthAttemptModelByIp($ip, self::FAILED_AUTH_POOL), $comparison);
695
    }
696
697
    /**
698
     * Serializes the internal dataset.
699
     *
700
     * @return string
701
     */
702
    public function serialize(): string
703
    {
704
        return serialize([
705
            $this->id,
706
            $this->username,
707
            $this->password,
708
            $this->email,
709
            $this->lastAction->getTimestamp(),
710
            $this->registrationDate->getTimestamp(),
711
            $this->apiKey,
712
            $this->state,
713
            $this->aboutText,
714
            $this->getRoles(),
715
            $this->getFollowing(),
716
            $this->authentications->toArray(),
717
            $this->failedAuthentications->toArray(),
718
        ]);
719
    }
720
721
    /**
722
     * Deserializes the data and re-creates the model.
723
     *
724
     * @param string $serialized
725
     */
726
    public function unserialize($serialized)
727
    {
728
        $data = unserialize($serialized);
729
730
        $this->id                    = $data[0];
731
        $this->username              = $data[1];
732
        $this->password              = $data[2];
733
        $this->email                 = $data[3];
734
        $this->lastAction            = new DateTime(sprintf('@%s', $data[4]));
735
        $this->registrationDate      = new DateTime(sprintf('@%s', $data[5]));
736
        $this->apiKey                = $data[6];
737
        $this->state                 = $data[7];
738
        $this->aboutText             = $data[8];
739
        $this->roles                 = new ArrayCollection($data[9]);
740
        $this->following             = new ArrayCollection($data[10]);
741
        $this->authentications       = new ArrayCollection($data[11]);
742
        $this->failedAuthentications = new ArrayCollection($data[12]);
743
    }
744
745
    /**
746
     * {@inheritdoc}
747
     */
748
    public function getSalt()
749
    {
750
    }
751
752
    /**
753
     * {@inheritdoc}
754
     */
755
    public function eraseCredentials()
756
    {
757
    }
758
759
    /**
760
     * Checks whether the following ip is known.
761
     *
762
     * @param string $ip
763
     * @param string $dataSource
764
     *
765
     * @return bool
766
     */
767
    private function isKnownIp(string $ip, string $dataSource = self::AUTH_ATTEMPT_POOL): bool
768
    {
769
        return !empty($this->getAuthAttemptModelByIp($ip, $dataSource));
770
    }
771
772
    /**
773
     * Gets the auth model by the given ip.
774
     *
775
     * @param string $ip
776
     * @param string $dataSource
777
     *
778
     * @return AuthenticationAttempt|null
779
     */
780
    private function getAuthAttemptModelByIp($ip, string $dataSource = self::AUTH_ATTEMPT_POOL)
781
    {
782
        $authAttempt = null;
783
        $pool        = $dataSource === self::AUTH_ATTEMPT_POOL
784
            ? $this->authentications->toArray()
785
            : $this->failedAuthentications->toArray();
786
787
        /** @var AuthenticationAttempt $authenticationAttempt */
788
        foreach ($pool as $authenticationAttempt) {
789
            if ((string) $ip === $authenticationAttempt->getIp()) {
790
                $authAttempt = $authenticationAttempt;
791
                break;
792
            }
793
        }
794
795
        return $authAttempt;
796
    }
797
798
    /**
799
     * Checks if enough failed authentications are done in the past to rise an auth warning.
800
     *
801
     * @param AuthenticationAttempt                              $attempt
802
     * @param \AppBundle\Model\User\Util\Date\DateTimeComparison $comparison
803
     *
804
     * @return bool
805
     */
806
    private function needsAuthWarning(AuthenticationAttempt $attempt, DateTimeComparison $comparison): bool
807
    {
808
        $count = $attempt->getAttemptCount();
809
        if (self::MAX_FAILED_ATTEMPTS_FROM_IP <= $count) {
810
            if (($count - 3) === 0) {
811
                return true;
812
            }
813
814
            return !$this->isPreviouslyLoginFailed('-6 hours', $comparison, true);
815
        }
816
817
        return false;
818
    }
819
820
    /**
821
     * Erases user IPs from the blacklist.
822
     *
823
     * @param string             $ip
824
     * @param DateTimeComparison $comparison
825
     */
826
    private function eraseKnownIpFromBadIPList(string $ip, DateTimeComparison $comparison)
827
    {
828
        if ($this->isPreviouslyLoginFailed('-10 minutes', $comparison)) {
829
            // if it failed before the login, the login may be corrupted,
830
            // there the information should be kept. The next login won't erase it although there may
831
            // be a corruption since this will be called at the first login with a new IP only
832
            return;
833
        }
834
835
        /** @var AuthenticationAttempt $model */
836
        foreach ($this->failedAuthentications->toArray() as $model) {
837
            if ($ip === $model->getIp()) {
838
                $this->failedAuthentications->removeElement($model);
839
                break;
840
            }
841
        }
842
    }
843
844
    /**
845
     * Checks if the auth failed previously.
846
     *
847
     * @param string             $diff
848
     * @param bool               $ignoreLastAttempts
849
     * @param DateTimeComparison $comparison
850
     *
851
     * @return bool
852
     */
853
    private function isPreviouslyLoginFailed(string $diff, DateTimeComparison $comparison, bool $ignoreLastAttempts = false): bool
854
    {
855
        return $this->failedAuthentications->exists(
856
            function (int $index, AuthenticationAttempt $failedAttempt) use ($diff, $ignoreLastAttempts, $comparison) {
857
                $ipRange = $failedAttempt->getLastFailedAttemptTimesInRange();
858
859
                return $comparison(
860
                    $diff,
861
                    $failedAttempt->getIp() && $ignoreLastAttempts ? end($ipRange) : $failedAttempt->getLatestFailedAttemptTime()
862
                );
863
            }
864
        );
865
    }
866
}
867