Passed
Push — trunk ( e136f9...a572a3 )
by Christian
13:15 queued 22s
created

UserRecoveryService   A

Complexity

Total Complexity 19

Size/Duplication

Total Lines 184
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 79
dl 0
loc 184
rs 10
c 0
b 0
f 0
wmc 19

9 Methods

Rating   Name   Duplication   Size   Complexity  
A deleteRecoveryForUser() 0 7 1
A __construct() 0 8 1
A updatePassword() 0 22 2
A generateUserRecovery() 0 59 5
A getSalesChannel() 0 9 2
A getUserByHash() 0 13 2
A getUserByEmail() 0 15 2
A checkHash() 0 12 2
A getUserRecovery() 0 9 2
1
<?php declare(strict_types=1);
2
3
namespace Shopware\Core\System\User\Recovery;
4
5
use Shopware\Core\DevOps\Environment\EnvironmentHelper;
6
use Shopware\Core\Framework\Context;
7
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
8
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
9
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
10
use Shopware\Core\Framework\Log\Package;
11
use Shopware\Core\Framework\Util\Random;
12
use Shopware\Core\Framework\Uuid\Uuid;
13
use Shopware\Core\System\SalesChannel\Context\SalesChannelContextService;
14
use Shopware\Core\System\SalesChannel\Context\SalesChannelContextServiceParameters;
15
use Shopware\Core\System\SalesChannel\SalesChannelCollection;
16
use Shopware\Core\System\SalesChannel\SalesChannelEntity;
17
use Shopware\Core\System\User\Aggregate\UserRecovery\UserRecoveryCollection;
18
use Shopware\Core\System\User\Aggregate\UserRecovery\UserRecoveryEntity;
19
use Shopware\Core\System\User\UserCollection;
20
use Shopware\Core\System\User\UserEntity;
21
use Shopware\Core\System\User\UserException;
22
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
23
use Symfony\Component\Routing\Exception\RouteNotFoundException;
24
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
25
use Symfony\Component\Routing\RouterInterface;
26
27
#[Package('system-settings')]
28
class UserRecoveryService
29
{
30
    /**
31
     * @param EntityRepository<UserRecoveryCollection> $userRecoveryRepo
32
     * @param EntityRepository<UserCollection> $userRepo
33
     * @param EntityRepository<SalesChannelCollection> $salesChannelRepository
34
     *
35
     * @internal
36
     */
37
    public function __construct(
38
        private readonly EntityRepository $userRecoveryRepo,
39
        private readonly EntityRepository $userRepo,
40
        private readonly RouterInterface $router,
41
        private readonly EventDispatcherInterface $dispatcher,
42
        private readonly SalesChannelContextService $salesChannelContextService,
43
        private readonly EntityRepository $salesChannelRepository,
44
    ) {
45
    }
46
47
    public function generateUserRecovery(string $userEmail, Context $context): void
48
    {
49
        $user = $this->getUserByEmail($userEmail, $context);
50
51
        if (!$user) {
52
            return;
53
        }
54
55
        $userId = $user->getId();
56
57
        $userIdCriteria = new Criteria();
58
        $userIdCriteria->addFilter(new EqualsFilter('userId', $userId));
59
        $userIdCriteria->addAssociation('user');
60
61
        if ($existingRecovery = $this->getUserRecovery($userIdCriteria, $context)) {
62
            $this->deleteRecoveryForUser($existingRecovery, $context);
63
        }
64
65
        $recoveryData = [
66
            'userId' => $userId,
67
            'hash' => Random::getAlphanumericString(32),
68
        ];
69
70
        $this->userRecoveryRepo->create([$recoveryData], $context);
71
72
        $recovery = $this->getUserRecovery($userIdCriteria, $context);
73
74
        if (!$recovery) {
75
            return;
76
        }
77
78
        $hash = $recovery->getHash();
79
80
        try {
81
            $url = $this->router->generate('administration.index', [], UrlGeneratorInterface::ABSOLUTE_URL);
82
        } catch (RouteNotFoundException) {
83
            // fallback if admin bundle is not installed, the url should work once the bundle is installed
84
            $url = EnvironmentHelper::getVariable('APP_URL') . '/admin';
85
        }
86
87
        $recoveryUrl = $url . '#/login/user-recovery/' . $hash;
88
89
        $salesChannel = $this->getSalesChannel($context);
90
91
        $salesChannelContext = $this->salesChannelContextService->get(
92
            new SalesChannelContextServiceParameters(
93
                $salesChannel->getId(),
94
                Uuid::randomHex(),
95
                $salesChannel->getLanguageId(),
96
                $salesChannel->getCurrencyId(),
97
                null,
98
                $context,
99
                null,
100
            )
101
        );
102
103
        $this->dispatcher->dispatch(
104
            new UserRecoveryRequestEvent($recovery, $recoveryUrl, $salesChannelContext->getContext()),
105
            UserRecoveryRequestEvent::EVENT_NAME
106
        );
107
    }
108
109
    public function checkHash(string $hash, Context $context): bool
110
    {
111
        $criteria = new Criteria();
112
        $criteria->addFilter(
113
            new EqualsFilter('hash', $hash)
114
        );
115
116
        $recovery = $this->getUserRecovery($criteria, $context);
117
118
        $validDateTime = (new \DateTime())->sub(new \DateInterval('PT2H'));
119
120
        return $recovery && $validDateTime < $recovery->getCreatedAt();
121
    }
122
123
    public function updatePassword(string $hash, string $password, Context $context): bool
124
    {
125
        if (!$this->checkHash($hash, $context)) {
126
            return false;
127
        }
128
129
        $criteria = new Criteria();
130
        $criteria->addFilter(new EqualsFilter('hash', $hash));
131
132
        /** @var UserRecoveryEntity $recovery It can't be null as we checked the hash before */
133
        $recovery = $this->getUserRecovery($criteria, $context);
134
135
        $updateData = [
136
            'id' => $recovery->getUserId(),
137
            'password' => $password,
138
        ];
139
140
        $this->userRepo->update([$updateData], $context);
141
142
        $this->deleteRecoveryForUser($recovery, $context);
143
144
        return true;
145
    }
146
147
    public function getUserByHash(string $hash, Context $context): ?UserEntity
148
    {
149
        $criteria = new Criteria();
150
        $criteria->addFilter(new EqualsFilter('hash', $hash));
151
        $criteria->addAssociation('user');
152
153
        $user = $this->getUserRecovery($criteria, $context);
154
155
        if ($user === null) {
156
            return null;
157
        }
158
159
        return $user->getUser();
160
    }
161
162
    private function getUserByEmail(string $userEmail, Context $context): ?UserEntity
163
    {
164
        $criteria = new Criteria();
165
166
        $criteria->addFilter(
167
            new EqualsFilter('email', $userEmail)
168
        );
169
170
        $user = $this->userRepo->search($criteria, $context)->first();
171
172
        if (!$user instanceof UserEntity) {
173
            return null;
174
        }
175
176
        return $user;
177
    }
178
179
    private function getUserRecovery(Criteria $criteria, Context $context): ?UserRecoveryEntity
180
    {
181
        $recovery = $this->userRecoveryRepo->search($criteria, $context)->first();
182
183
        if (!$recovery instanceof UserRecoveryEntity) {
184
            return null;
185
        }
186
187
        return $recovery;
188
    }
189
190
    private function deleteRecoveryForUser(UserRecoveryEntity $userRecoveryEntity, Context $context): void
191
    {
192
        $recoveryData = [
193
            'id' => $userRecoveryEntity->getId(),
194
        ];
195
196
        $this->userRecoveryRepo->delete([$recoveryData], $context);
197
    }
198
199
    /**
200
     * pick a random sales channel to form sales channel context as flow builder requires it
201
     */
202
    private function getSalesChannel(Context $context): SalesChannelEntity
203
    {
204
        $salesChannel = $this->salesChannelRepository->search((new Criteria())->setLimit(1), $context)->first();
205
206
        if (!$salesChannel instanceof SalesChannelEntity) {
207
            throw UserException::salesChannelNotFound();
208
        }
209
210
        return $salesChannel;
211
    }
212
}
213