Completed
Push — 4-cactus ( 23b6ed...14c59d )
by Alberto
20s queued 12s
created

SignupUserAction::execute()   B

Complexity

Conditions 6
Paths 18

Size

Total Lines 37
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
eloc 21
nc 18
nop 1
dl 0
loc 37
rs 8.9617
c 0
b 0
f 0
1
<?php
2
/**
3
 * BEdita, API-first content management framework
4
 * Copyright 2019 ChannelWeb Srl, Chialab Srl
5
 *
6
 * This file is part of BEdita: you can redistribute it and/or modify
7
 * it under the terms of the GNU Lesser General Public License as published
8
 * by the Free Software Foundation, either version 3 of the License, or
9
 * (at your option) any later version.
10
 *
11
 * See LICENSE.LGPL or <http://gnu.org/licenses/lgpl-3.0.html> for more details.
12
 */
13
14
namespace BEdita\Core\Model\Action;
15
16
use BEdita\Core\Model\Entity\AsyncJob;
17
use BEdita\Core\Model\Entity\User;
18
use BEdita\Core\Model\Table\RolesTable;
19
use BEdita\Core\Model\Validation\Validation;
20
use BEdita\Core\Utility\LoggedUser;
21
use Cake\Core\Configure;
22
use Cake\Event\Event;
23
use Cake\Event\EventDispatcherTrait;
24
use Cake\Event\EventListenerInterface;
25
use Cake\Http\Client;
26
use Cake\I18n\Time;
27
use Cake\Mailer\MailerAwareTrait;
28
use Cake\Network\Exception\BadRequestException;
29
use Cake\Network\Exception\UnauthorizedException;
30
use Cake\ORM\TableRegistry;
31
use Cake\Routing\Router;
32
use Cake\Utility\Hash;
33
use Cake\Validation\Validator;
34
35
/**
36
 * Command to signup a user.
37
 *
38
 * @since 4.0.0
39
 */
40
class SignupUserAction extends BaseAction implements EventListenerInterface
41
{
42
43
    use EventDispatcherTrait;
44
    use MailerAwareTrait;
45
46
    /**
47
     * 400 Username already registered
48
     *
49
     * @var string
50
     */
51
    const BE_USER_EXISTS = 'be_user_exists';
52
53
    /**
54
     * The UsersTable table
55
     *
56
     * @var \BEdita\Core\Model\Table\UsersTable
57
     */
58
    protected $Users;
59
60
    /**
61
     * The AsyncJobs table
62
     *
63
     * @var \BEdita\Core\Model\Table\AsyncJobsTable
64
     */
65
    protected $AsyncJobs;
66
67
    /**
68
     * The RolesTable table
69
     *
70
     * @var \BEdita\Core\Model\Table\RolesTable
71
     */
72
    protected $Roles;
73
74
    /**
75
     * {@inheritdoc}
76
     */
77
    protected function initialize(array $config)
78
    {
79
        $this->Users = TableRegistry::get('Users');
80
        $this->AsyncJobs = TableRegistry::get('AsyncJobs');
81
        $this->Roles = TableRegistry::get('Roles');
82
83
        $this->getEventManager()->on($this);
84
    }
85
86
    /**
87
     * {@inheritDoc}
88
     *
89
     * @return \BEdita\Core\Model\Entity\User
90
     * @throws \Cake\Network\Exception\BadRequestException When validation of URL options fails
91
     * @throws \Cake\Network\Exception\UnauthorizedException Upon external authorization check failure.
92
     */
93
    public function execute(array $data = [])
94
    {
95
        $data = $this->normalizeInput($data);
96
        // add activation url from config if not set
97
        if (Configure::check('Signup.activationUrl') && empty($data['data']['activation_url'])) {
98
            $data['data']['activation_url'] = Router::url(Configure::read('Signup.activationUrl'));
99
        }
100
        $errors = $this->validate($data['data']);
101
        if (!empty($errors)) {
102
            throw new BadRequestException([
0 ignored issues
show
Bug introduced by
array('title' => __d('be...), 'detail' => $errors) of type array<string,array|null|string> is incompatible with the type null|string expected by parameter $message of Cake\Network\Exception\B...xception::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

102
            throw new BadRequestException(/** @scrutinizer ignore-type */ [
Loading history...
103
                'title' => __d('bedita', 'Invalid data'),
104
                'detail' => $errors,
105
            ]);
106
        }
107
108
        // operations are not in transaction because AsyncJobs could use a different connection
109
        $user = $this->createUser($data['data']);
110
        try {
111
            // add roles to user, with validity check
112
            $this->addRoles($user, $data['data']);
0 ignored issues
show
Bug introduced by
It seems like $user can also be of type boolean; however, parameter $entity of BEdita\Core\Model\Action...pUserAction::addRoles() does only seem to accept BEdita\Core\Model\Entity\User, 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

112
            $this->addRoles(/** @scrutinizer ignore-type */ $user, $data['data']);
Loading history...
113
114
            if (empty($data['data']['auth_provider'])) {
115
                $job = $this->createSignupJob($user);
0 ignored issues
show
Bug introduced by
It seems like $user can also be of type boolean; however, parameter $user of BEdita\Core\Model\Action...tion::createSignupJob() does only seem to accept BEdita\Core\Model\Entity\User, 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

115
                $job = $this->createSignupJob(/** @scrutinizer ignore-type */ $user);
Loading history...
116
                $activationUrl = $this->getActivationUrl($job, $data['data']);
117
118
                $this->dispatchEvent('Auth.signup', [$user, $job, $activationUrl], $this->Users);
119
            } else {
120
                $this->dispatchEvent('Auth.signupActivation', [$user], $this->Users);
121
            }
122
        } catch (\Exception $e) {
123
            // if async job or send mail fail remove user created and re-throw the exception
124
            $this->Users->delete($user);
0 ignored issues
show
Bug introduced by
It seems like $user can also be of type boolean; however, parameter $entity of BEdita\Core\Model\Table\UsersTable::delete() does only seem to accept Cake\Datasource\EntityInterface, 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

124
            $this->Users->delete(/** @scrutinizer ignore-type */ $user);
Loading history...
125
126
            throw $e;
127
        }
128
129
        return (new GetObjectAction(['table' => $this->Users]))->execute(['primaryKey' => $user->id, 'contain' => 'Roles']);
0 ignored issues
show
Bug Best Practice introduced by
The expression return new BEdita\Core\M... 'contain' => 'Roles')) returns the type array which is incompatible with the documented return type BEdita\Core\Model\Entity\User.
Loading history...
130
    }
131
132
    /**
133
     * Normalize input data to plain JSON if in JSON API format
134
     *
135
     * @param array $data Input data
136
     * @return array Normalized array
137
     */
138
    protected function normalizeInput(array $data)
139
    {
140
        if (!empty($data['data']['data']['attributes'])) {
141
            $meta = !empty($data['data']['data']['meta']) ? $data['data']['data']['meta'] : [];
142
            $data['data'] = array_merge($data['data']['data']['attributes'], $meta);
143
        }
144
145
        return $data;
146
    }
147
148
    /**
149
     * Validate data.
150
     *
151
     * It needs to validate some data that don't concern the User entity
152
     * as `activation_url` and `redirect_url`
153
     *
154
     * @param array $data The data to validate
155
     * @return array
156
     */
157
    protected function validate(array $data)
158
    {
159
        $validator = new Validator();
160
        $validator->setProvider('bedita', Validation::class);
161
162
        if (empty($data['auth_provider'])) {
163
            $validator->requirePresence('activation_url');
164
165
            $validator
166
                ->requirePresence('username')
167
                ->notEmpty('username');
168
        } else {
169
            $validator
170
                ->requirePresence('provider_username')
171
                ->requirePresence('access_token');
172
        }
173
174
        $validator
175
            ->add('activation_url', 'customUrl', [
176
                'rule' => 'url',
177
                'provider' => 'bedita',
178
            ])
179
180
            ->add('redirect_url', 'customUrl', [
181
                'rule' => 'url',
182
                'provider' => 'bedita',
183
            ]);
184
185
        return $validator->errors($data);
186
    }
187
188
    /**
189
     * Create a new user with status:
190
     *  - `on` if an external auth provider is used or no activation
191
     *    is required via `Signup.requireActivation` config
192
     *  - `draft` in other cases.
193
     *
194
     * The user is validated using 'signup' validation.
195
     *
196
     * @param array $data The data to save
197
     * @return \BEdita\Core\Model\Entity\User|bool User created or `false` on error
198
     * @throws \Cake\Network\Exception\UnauthorizedException Upon external authorization check failure.
199
     */
200
    protected function createUser(array $data)
201
    {
202
        if (!LoggedUser::getUser()) {
203
            LoggedUser::setUser(['id' => 1]);
204
        }
205
206
        $status = 'draft';
207
        if (Configure::read('Signup.requireActivation') === false) {
208
            $status = 'on';
209
        }
210
        unset($data['status']);
211
212
        if (empty($data['auth_provider'])) {
213
            return $this->createUserEntity($data, $status, 'signup');
214
        }
215
216
        $authProvider = $this->checkExternalAuth($data);
217
218
        $user = $this->createUserEntity($data, 'on', 'signupExternal');
219
220
        // create `ExternalAuth` entry
221
        $this->Users->dispatchEvent('Auth.externalAuth', [
222
            'authProvider' => $authProvider,
223
            'providerUsername' => $data['provider_username'],
224
            'userId' => $user->get('id'),
225
            'params' => empty($data['provider_userdata']) ? null : $data['provider_userdata'],
226
        ]);
227
228
        return $user;
229
    }
230
231
    /**
232
     * Create User model entity.
233
     *
234
     * @param array $data The signup data
235
     * @param string $status User `status`, `on` or `draft`
236
     * @param string $validate Validation options to use
237
     * @return @return \BEdita\Core\Model\Entity\User The User entity created
0 ignored issues
show
Documentation Bug introduced by
The doc comment @return at position 0 could not be parsed: Unknown type name '@return' at position 0 in @return.
Loading history...
238
     */
239
    protected function createUserEntity(array $data, $status, $validate)
240
    {
241
        if ($this->Users->exists(['username' => $data['username']])) {
242
            $this->dispatchEvent('Auth.signupUserExists', [$data], $this->Users);
243
            throw new BadRequestException([
0 ignored issues
show
Bug introduced by
array('title' => __d('be...> self::BE_USER_EXISTS) of type array<string,null|string> is incompatible with the type null|string expected by parameter $message of Cake\Network\Exception\B...xception::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

243
            throw new BadRequestException(/** @scrutinizer ignore-type */ [
Loading history...
244
                'title' => __d('bedita', 'User "{0}" already registered', $data['username']),
245
                'code' => self::BE_USER_EXISTS,
246
            ]);
247
        }
248
        $action = new SaveEntityAction(['table' => $this->Users]);
249
250
        return $action([
251
            'entity' => $this->Users->newEntity()->set('status', $status),
252
            'data' => $data,
253
            'entityOptions' => [
254
                'validate' => $validate,
255
            ],
256
        ]);
257
    }
258
259
    /**
260
     * Check external auth data validity
261
     *
262
     * To perform external auth check these fields are mandatory:
263
     *  - "auth_provider": provider name like "google", "facebook"... must be in `auth_providers`
264
     *  - "provider_username": id or username of the provider
265
     *  - "access_token": token returned by provider to use in check
266
     *
267
     * @param array $data The signup data
268
     * @return \BEdita\Core\Model\Entity\AuthProvider AuthProvider entity
269
     * @throws \Cake\Network\Exception\UnauthorizedException Upon external authorization check failure.
270
     */
271
    protected function checkExternalAuth(array $data)
272
    {
273
        /** @var \BEdita\Core\Model\Entity\AuthProvider $authProvider */
274
        $authProvider = TableRegistry::get('AuthProviders')->find('enabled')
275
            ->where(['name' => $data['auth_provider']])
276
            ->first();
277
        if (empty($authProvider)) {
278
            throw new UnauthorizedException(__d('bedita', 'External auth provider not found'));
279
        }
280
        $providerResponse = $this->getOAuth2Response($authProvider->get('url'), $data['access_token']);
281
        if (!$authProvider->checkAuthorization($providerResponse, $data['provider_username'])) {
282
            throw new UnauthorizedException(__d('bedita', 'External auth failed'));
283
        }
284
285
        return $authProvider;
286
    }
287
288
    /**
289
     * Get response from an OAuth2 provider
290
     *
291
     * @param string $url OAuth2 provider URL
292
     * @param string $accessToken Access token to use in request
293
     * @return array Response from an OAuth2 provider
294
     * @codeCoverageIgnore
295
     */
296
    protected function getOAuth2Response($url, $accessToken)
297
    {
298
        $response = (new Client())->get($url, [], ['headers' => ['Authorization' => 'Bearer ' . $accessToken]]);
299
300
        return !empty($response->json) ? $response->json : [];
301
    }
302
303
    /**
304
     * Add roles to user if requested, with validity check
305
     *
306
     * @param \BEdita\Core\Model\Entity\User $entity The user created
307
     * @param array $data The signup data
308
     * @return void
309
     */
310
    protected function addRoles(User $entity, array $data)
311
    {
312
        $signupRoles = Hash::get($data, 'roles', Configure::read('Signup.defaultRoles'));
313
        if (empty($signupRoles)) {
314
            return;
315
        }
316
        $roles = $this->loadRoles($signupRoles);
317
        $association = $this->Users->associations()->getByProperty('roles');
318
        $association->link($entity, $roles);
319
    }
320
321
    /**
322
     * Load requested roles entities with validation
323
     *
324
     * @param array $roles Requested role names
325
     * @return \BEdita\Core\Model\Entity\Role[] requested role entities
326
     * @throws \Cake\Network\Exception\BadRequestException When role validation fails
327
     */
328
    protected function loadRoles(array $roles)
329
    {
330
        $entities = [];
331
        $allowed = (array)Configure::read('Signup.roles');
332
        foreach ($roles as $name) {
333
            $role = $this->Roles->find()->where(compact('name'))->first();
334
            if (RolesTable::ADMIN_ROLE === $role->get('id') || !in_array($name, $allowed)) {
335
                throw new BadRequestException(__d('bedita', 'Role "{0}" not allowed on signup', [$name]));
336
            }
337
            $entities[] = $role;
338
        }
339
340
        return $entities;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $entities returns an array which contains values of type array which are incompatible with the documented value type BEdita\Core\Model\Entity\Role.
Loading history...
341
    }
342
343
    /**
344
     * Create the signup async job
345
     *
346
     * @param \BEdita\Core\Model\Entity\User $user The user created
347
     * @return \BEdita\Core\Model\Entity\AsyncJob
348
     */
349
    protected function createSignupJob(User $user)
350
    {
351
        $action = new SaveEntityAction(['table' => $this->AsyncJobs]);
352
353
        return $action([
354
            'entity' => $this->AsyncJobs->newEntity(),
355
            'data' => [
356
                'service' => 'signup',
357
                'payload' => [
358
                    'user_id' => $user->id,
359
                ],
360
                'scheduled_from' => new Time('1 day'),
361
                'priority' => 1,
362
            ],
363
        ]);
364
    }
365
366
    /**
367
     * Send confirmation email to user
368
     *
369
     * @param \Cake\Event\Event $event Dispatched event.
370
     * @param \BEdita\Core\Model\Entity\User $user The user
371
     * @param \BEdita\Core\Model\Entity\AsyncJob $job The referred async job
372
     * @param string $activationUrl URL to be used for activation.
373
     * @return void
374
     */
375
    public function sendMail(Event $event, User $user, AsyncJob $job, $activationUrl)
376
    {
377
        if (empty($user->get('email'))) {
378
            return;
379
        }
380
        $options = [
381
            'params' => compact('activationUrl', 'user'),
382
        ];
383
        $this->getMailer('BEdita/Core.User')->send('signup', [$options]);
384
    }
385
386
    /**
387
     * Send welcome email to user to inform of successfully activation
388
     * External auth users are already activated
389
     *
390
     * @param \Cake\Event\Event $event Dispatched event.
391
     * @param \BEdita\Core\Model\Entity\User $user The user
392
     * @return void
393
     */
394
    public function sendActivationMail(Event $event, User $user)
395
    {
396
        $options = [
397
            'params' => compact('user'),
398
        ];
399
        $this->getMailer('BEdita/Core.User')->send('welcome', [$options]);
400
    }
401
402
    /**
403
     * Return the signup activation url
404
     *
405
     * @param \BEdita\Core\Model\Entity\AsyncJob $job The async job entity
406
     * @param array $urlOptions The options used to build activation url
407
     * @return string
408
     */
409
    protected function getActivationUrl(AsyncJob $job, array $urlOptions)
410
    {
411
        $baseUrl = $urlOptions['activation_url'];
412
        $redirectUrl = empty($urlOptions['redirect_url']) ? '' : '&redirect_url=' . rawurlencode($urlOptions['redirect_url']);
413
        $baseUrl .= (strpos($baseUrl, '?') === false) ? '?' : '&';
414
415
        return sprintf('%suuid=%s%s', $baseUrl, $job->uuid, $redirectUrl);
416
    }
417
418
    /**
419
     * {@inheritdoc}
420
     */
421
    public function implementedEvents()
422
    {
423
        return [
424
            'Auth.signup' => 'sendMail',
425
            'Auth.signupActivation' => 'sendActivationMail',
426
        ];
427
    }
428
}
429