SignupUserAction   A
last analyzed

Complexity

Total Complexity 42

Size/Duplication

Total Lines 449
Duplicated Lines 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 156
dl 0
loc 449
rs 9.0399
c 2
b 0
f 0
wmc 42

16 Methods

Rating   Name   Duplication   Size   Complexity  
A normalizeInput() 0 8 3
A initialize() 0 7 1
A loadRoles() 0 6 1
A validate() 0 39 3
B execute() 0 41 7
A sendMail() 0 9 2
A createSignupJob() 0 13 1
A implementedEvents() 0 5 1
A getActivationUrl() 0 7 3
A createUser() 0 29 5
A addRoles() 0 11 2
A getOAuth2Response() 0 3 1
A validateRoles() 0 23 5
A createUserEntity() 0 18 3
A checkExternalAuth() 0 20 3
A sendActivationMail() 0 6 1

How to fix   Complexity   

Complex Class

Complex classes like SignupUserAction 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.

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 SignupUserAction, and based on these observations, apply Extract Interface, too.

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\Exception\InvalidDataException;
17
use BEdita\Core\Exception\UserExistsException;
18
use BEdita\Core\Model\Entity\AsyncJob;
19
use BEdita\Core\Model\Entity\User;
20
use BEdita\Core\Model\Table\RolesTable;
21
use BEdita\Core\Model\Table\UsersTable;
22
use BEdita\Core\Model\Validation\Validation;
23
use BEdita\Core\Utility\LoggedUser;
24
use BEdita\Core\Utility\OAuth2;
25
use Cake\Core\Configure;
26
use Cake\Event\Event;
27
use Cake\Event\EventDispatcherTrait;
28
use Cake\Event\EventListenerInterface;
29
use Cake\Http\Exception\UnauthorizedException;
30
use Cake\I18n\FrozenTime;
31
use Cake\Mailer\MailerAwareTrait;
32
use Cake\ORM\TableRegistry;
33
use Cake\Routing\Router;
34
use Cake\Utility\Hash;
35
use Cake\Validation\Validator;
36
37
/**
38
 * Command to signup a user.
39
 *
40
 * @since 4.0.0
41
 */
42
class SignupUserAction extends BaseAction implements EventListenerInterface
43
{
44
    use EventDispatcherTrait;
45
    use MailerAwareTrait;
46
47
    /**
48
     * 400 Username already registered
49
     *
50
     * @var string
51
     * @deprecated Will be dropped in 5.x, use `UserExistsException` to use this app error code.
52
     */
53
    public const BE_USER_EXISTS = 'be_user_exists';
54
55
    /**
56
     * The UsersTable table
57
     *
58
     * @var \BEdita\Core\Model\Table\UsersTable
59
     */
60
    protected $Users;
61
62
    /**
63
     * The AsyncJobs table
64
     *
65
     * @var \BEdita\Core\Model\Table\AsyncJobsTable
66
     */
67
    protected $AsyncJobs;
68
69
    /**
70
     * The RolesTable table
71
     *
72
     * @var \BEdita\Core\Model\Table\RolesTable
73
     */
74
    protected $Roles;
75
76
    /**
77
     * Default configuration.
78
     *
79
     * - activation_url => url used for signup activation
80
     * - roles => the allowed roles on signup. if empty no role can be associated
81
     * - defaultRoles => default roles associated to user if no one was specified
82
     * - requireActivation => false if user will be active without confirm
83
     *
84
     * @var array
85
     */
86
    protected $_defaultConfig = [
87
        'activation_url' => null,
88
        'roles' => null,
89
        'defaultRoles' => null,
90
        'requireActivation' => true,
91
    ];
92
93
    /**
94
     * @inheritDoc
95
     */
96
    protected function initialize(array $config)
97
    {
98
        $this->Users = TableRegistry::getTableLocator()->get('Users');
99
        $this->AsyncJobs = TableRegistry::getTableLocator()->get('AsyncJobs');
100
        $this->Roles = TableRegistry::getTableLocator()->get('Roles');
101
102
        $this->getEventManager()->on($this);
103
    }
104
105
    /**
106
     * {@inheritDoc}
107
     *
108
     * @return \BEdita\Core\Model\Entity\User
109
     * @throws \Cake\Http\Exception\BadRequestException When validation of URL options fails
110
     * @throws \Cake\Http\Exception\UnauthorizedException Upon external authorization check failure.
111
     */
112
    public function execute(array $data = [])
113
    {
114
        $this->setConfig((array)Configure::read('Signup'));
115
116
        $data = $this->normalizeInput($data);
117
        // add activation url from config if not set
118
        $activationUrl = $this->getConfig('activationUrl');
119
        if (!empty($activationUrl) && empty($data['data']['activation_url'])) {
120
            $data['data']['activation_url'] = Router::url($activationUrl);
121
        }
122
        $signupRoles = (array)Hash::get($data, 'data.roles', $this->getConfig('defaultRoles'));
123
        if (!empty($signupRoles)) {
124
            $data['data']['roles'] = $signupRoles;
125
        }
126
        $errors = $this->validate($data['data']);
127
        if (!empty($errors)) {
128
            throw new InvalidDataException(__d('bedita', 'Invalid data'), $errors);
129
        }
130
131
        // operations are not in transaction because AsyncJobs could use a different connection
132
        $user = $this->createUser($data['data']);
133
        try {
134
            // add roles to user, with validity check
135
            $this->addRoles($user, $data['data']);
136
137
            if (empty($data['data']['auth_provider'])) {
138
                $job = $this->createSignupJob($user);
139
                $activationUrl = $this->getActivationUrl($job, $data['data']);
140
141
                $this->dispatchEvent('Auth.signup', [$user, $job, $activationUrl], $this->Users);
142
            } else {
143
                $this->dispatchEvent('Auth.signupActivation', [$user], $this->Users);
144
            }
145
        } catch (\Exception $e) {
146
            // if async job or send mail fail remove user created and re-throw the exception
147
            $this->Users->delete($user);
148
149
            throw $e;
150
        }
151
152
        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...
153
    }
154
155
    /**
156
     * Normalize input data to plain JSON if in JSON API format
157
     *
158
     * @param array $data Input data
159
     * @return array Normalized array
160
     */
161
    protected function normalizeInput(array $data)
162
    {
163
        if (!empty($data['data']['data']['attributes'])) {
164
            $meta = !empty($data['data']['data']['meta']) ? $data['data']['data']['meta'] : [];
165
            $data['data'] = array_merge($data['data']['data']['attributes'], $meta);
166
        }
167
168
        return $data;
169
    }
170
171
    /**
172
     * Validate data.
173
     *
174
     * It needs to validate some data that don't concern the User entity
175
     * as `activation_url` and `redirect_url`
176
     *
177
     * @param array $data The data to validate
178
     * @return array
179
     */
180
    protected function validate(array $data)
181
    {
182
        $validator = new Validator();
183
        $validator->setProvider('bedita', Validation::class);
184
185
        if (empty($data['auth_provider'])) {
186
            $validator->requirePresence('activation_url');
187
188
            $validator
189
                ->requirePresence('username')
190
                ->notEmptyString('username');
191
        } else {
192
            $validator
193
                ->requirePresence('provider_username')
194
                ->requirePresence('access_token');
195
        }
196
197
        $validator
198
            ->add('activation_url', 'customUrl', [
199
                'rule' => 'url',
200
                'provider' => 'bedita',
201
            ])
202
203
            ->add('redirect_url', 'customUrl', [
204
                'rule' => 'url',
205
                'provider' => 'bedita',
206
            ]);
207
208
        if (!empty($this->getConfig('roles'))) {
209
            $validator
210
            ->requirePresence('roles')
211
            ->notEmptyArray('roles');
212
        }
213
214
        $validator->add('roles', 'validateRoles', [
215
            'rule' => [$this, 'validateRoles'],
216
        ]);
217
218
        return $validator->validate($data);
219
    }
220
221
    /**
222
     * Validate roles against allowed signup roles configured.
223
     * In addtion roles can't contain ADMIN_ROLE.
224
     *
225
     * @param string|array $roles The roles to check
226
     * @return true|string
227
     */
228
    public function validateRoles($roles)
229
    {
230
        $allowedRoles = (array)$this->getConfig('roles');
231
        if (empty($allowedRoles) && !empty($roles)) {
232
            return __d('bedita', 'Roles are not allowed on signup');
233
        }
234
235
        $adminRoleName = $this->Roles->get(RolesTable::ADMIN_ROLE)->get('name');
236
237
        $roles = (array)$roles;
238
        $message = '{0} not allowed on signup';
239
        if (in_array($adminRoleName, $roles)) {
240
            return __d('bedita', $message, [$adminRoleName]);
241
        }
242
243
        $valid = array_intersect($roles, $allowedRoles);
244
        if (empty(array_diff($roles, $valid))) {
245
            return true;
246
        }
247
248
        $rolesNotAllowed = array_diff($roles, $allowedRoles);
249
250
        return __d('bedita', $message, [implode(', ', $rolesNotAllowed)]);
251
    }
252
253
    /**
254
     * Create a new user with status:
255
     *  - `on` if an external auth provider is used or no activation
256
     *    is required via `requireActivation` config
257
     *  - `draft` in other cases.
258
     *
259
     * The user is validated using 'signup' validation.
260
     *
261
     * @param array $data The data to save
262
     * @return \BEdita\Core\Model\Entity\User|bool User created or `false` on error
263
     * @throws \Cake\Http\Exception\UnauthorizedException Upon external authorization check failure.
264
     */
265
    protected function createUser(array $data)
266
    {
267
        if (!LoggedUser::getUser()) {
268
            // use user 1 (admin) role 1 (admin / unchangeable)
269
            LoggedUser::setUserAdmin();
270
        }
271
272
        $status = 'draft';
273
        if ($this->getConfig('requireActivation') === false) {
274
            $status = 'on';
275
        }
276
277
        if (empty($data['auth_provider'])) {
278
            return $this->createUserEntity($data, $status, 'signup');
279
        }
280
281
        $authProvider = $this->checkExternalAuth($data);
282
283
        $user = $this->createUserEntity($data, 'on', 'signupExternal', true);
284
285
        // create `ExternalAuth` entry
286
        $this->Users->dispatchEvent('Auth.externalAuth', [
287
            'authProvider' => $authProvider,
288
            'providerUsername' => $data['provider_username'],
289
            'userId' => $user->get('id'),
290
            'params' => empty($data['provider_userdata']) ? null : $data['provider_userdata'],
291
        ]);
292
293
        return $user;
294
    }
295
296
    /**
297
     * Create User entity.
298
     *
299
     * @param array $data The signup data
300
     * @param string $status User `status`, `on` or `draft`
301
     * @param string $validate Validation options to use
302
     * @param bool $verified Add `verified` value to entity
303
     * @return \BEdita\Core\Model\Entity\User The User entity created
304
     * @throws \Cake\Http\Exception\BadRequestException When some data is invalid.
305
     */
306
    protected function createUserEntity(array $data, $status, $validate, bool $verified = false)
307
    {
308
        if ($this->Users->exists(['username' => $data['username']])) {
309
            $this->dispatchEvent('Auth.signupUserExists', [$data], $this->Users);
310
            throw new UserExistsException(
311
                __d('bedita', 'User "{0}" already registered', $data['username'])
312
            );
313
        }
314
        $action = new SaveEntityAction(['table' => $this->Users]);
315
316
        $data['status'] = $status;
317
        $entity = $this->Users->newEntity([]);
318
        if ($verified === true) {
319
            $entity->set('verified', FrozenTime::now());
320
        }
321
        $entityOptions = compact('validate');
322
323
        return $action(compact('entity', 'data', 'entityOptions'));
324
    }
325
326
    /**
327
     * Check external auth data validity
328
     *
329
     * To perform external auth check these fields are mandatory:
330
     *  - "auth_provider": provider name like "google", "facebook"... must be in `auth_providers`
331
     *  - "provider_username": id or username of the provider
332
     *  - "access_token": token returned by provider to use in check
333
     *
334
     * @param array $data The signup data
335
     * @return \BEdita\Core\Model\Entity\AuthProvider AuthProvider entity
336
     * @throws \Cake\Http\Exception\UnauthorizedException Upon external authorization check failure.
337
     */
338
    protected function checkExternalAuth(array $data)
339
    {
340
        /** @var \BEdita\Core\Model\Entity\AuthProvider|null $authProvider */
341
        $authProvider = TableRegistry::getTableLocator()->get('AuthProviders')->find('enabled')
342
            ->where(['name' => $data['auth_provider']])
343
            ->first();
344
        if (empty($authProvider)) {
345
            throw new UnauthorizedException(__d('bedita', 'External auth provider not found'));
346
        }
347
        $options = (array)Hash::get((array)$authProvider->get('params'), 'options');
348
        $providerResponse = $this->getOAuth2Response(
349
            $authProvider->get('url'),
350
            $data['access_token'],
351
            $options
352
        );
353
        if (!$authProvider->checkAuthorization($providerResponse, $data['provider_username'])) {
354
            throw new UnauthorizedException(__d('bedita', 'External auth failed'));
355
        }
356
357
        return $authProvider;
358
    }
359
360
    /**
361
     * Get response from an OAuth2 provider
362
     *
363
     * @param string $url OAuth2 provider URL
364
     * @param string $accessToken Access token to use in request
365
     * @param array $options OAuth2 request options
366
     * @return array Response from an OAuth2 provider
367
     * @codeCoverageIgnore
368
     */
369
    protected function getOAuth2Response(string $url, string $accessToken, array $options = []): array
370
    {
371
        return (new OAuth2())->response($url, $accessToken, $options);
372
    }
373
374
    /**
375
     * Add roles to user if requested, with validity check
376
     *
377
     * @param \BEdita\Core\Model\Entity\User $entity The user created
378
     * @param array $data The signup data
379
     * @return void
380
     */
381
    protected function addRoles(User $entity, array $data)
382
    {
383
        $signupRoles = Hash::get($data, 'roles');
384
        if (empty($signupRoles)) {
385
            return;
386
        }
387
        $roles = $this->loadRoles($signupRoles);
388
389
        /** @var \Cake\ORM\Association\BelongsToMany $association */
390
        $association = $this->Users->associations()->getByProperty('roles');
391
        $association->link($entity, $roles);
392
    }
393
394
    /**
395
     * Load requested roles.
396
     *
397
     * @param array $roles Requested role names
398
     * @return \BEdita\Core\Model\Entity\Role[] requested role entities
399
     */
400
    protected function loadRoles(array $roles)
401
    {
402
        return $this->Roles->find()
403
            ->where(['name IN' => $roles])
404
            ->all()
405
            ->toList();
406
    }
407
408
    /**
409
     * Create the signup async job
410
     *
411
     * @param \BEdita\Core\Model\Entity\User $user The user created
412
     * @return \BEdita\Core\Model\Entity\AsyncJob
413
     */
414
    protected function createSignupJob(User $user)
415
    {
416
        $action = new SaveEntityAction(['table' => $this->AsyncJobs]);
417
418
        return $action([
419
            'entity' => $this->AsyncJobs->newEntity([]),
420
            'data' => [
421
                'service' => 'signup',
422
                'payload' => [
423
                    'user_id' => $user->id,
424
                ],
425
                'scheduled_from' => new FrozenTime('1 day'),
426
                'priority' => 1,
427
            ],
428
        ]);
429
    }
430
431
    /**
432
     * Send confirmation email to user
433
     *
434
     * @param \Cake\Event\Event $event Dispatched event.
435
     * @param \BEdita\Core\Model\Entity\User $user The user
436
     * @param \BEdita\Core\Model\Entity\AsyncJob $job The referred async job
437
     * @param string $activationUrl URL to be used for activation.
438
     * @return void
439
     */
440
    public function sendMail(Event $event, User $user, AsyncJob $job, $activationUrl)
441
    {
442
        if (empty($user->get('email'))) {
443
            return;
444
        }
445
        $options = [
446
            'params' => compact('activationUrl', 'user'),
447
        ];
448
        $this->getMailer('BEdita/Core.User')->send('signup', [$options]);
449
    }
450
451
    /**
452
     * Send welcome email to user to inform of successfully activation
453
     * External auth users are already activated
454
     *
455
     * @param \Cake\Event\Event $event Dispatched event.
456
     * @param \BEdita\Core\Model\Entity\User $user The user
457
     * @return void
458
     */
459
    public function sendActivationMail(Event $event, User $user)
460
    {
461
        $options = [
462
            'params' => compact('user'),
463
        ];
464
        $this->getMailer('BEdita/Core.User')->send('welcome', [$options]);
465
    }
466
467
    /**
468
     * Return the signup activation url
469
     *
470
     * @param \BEdita\Core\Model\Entity\AsyncJob $job The async job entity
471
     * @param array $urlOptions The options used to build activation url
472
     * @return string
473
     */
474
    protected function getActivationUrl(AsyncJob $job, array $urlOptions)
475
    {
476
        $baseUrl = $urlOptions['activation_url'];
477
        $redirectUrl = empty($urlOptions['redirect_url']) ? '' : '&redirect_url=' . rawurlencode($urlOptions['redirect_url']);
478
        $baseUrl .= strpos($baseUrl, '?') === false ? '?' : '&';
479
480
        return sprintf('%suuid=%s%s', $baseUrl, $job->uuid, $redirectUrl);
481
    }
482
483
    /**
484
     * @inheritDoc
485
     */
486
    public function implementedEvents(): array
487
    {
488
        return [
489
            'Auth.signup' => 'sendMail',
490
            'Auth.signupActivation' => 'sendActivationMail',
491
        ];
492
    }
493
}
494