Completed
Push — 4-cactus ( 59c50f...21a27c )
by Alberto
02:40
created

BEdita/Core/src/Model/Action/SignupUserAction.php (1 issue)

1
<?php
2
/**
3
 * BEdita, API-first content management framework
4
 * Copyright 2017 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\Validation\Validator;
32
33
/**
34
 * Command to signup a user.
35
 *
36
 * @since 4.0.0
37
 */
38
class SignupUserAction extends BaseAction implements EventListenerInterface
39
{
40
41
    use EventDispatcherTrait;
42
    use MailerAwareTrait;
43
44
    /**
45
     * The UsersTable table
46
     *
47
     * @var \BEdita\Core\Model\Table\UsersTable
48
     */
49
    protected $Users;
50
51
    /**
52
     * The AsyncJobs table
53
     *
54
     * @var \BEdita\Core\Model\Table\AsyncJobsTable
55
     */
56
    protected $AsyncJobs;
57
58
    /**
59
     * The RolesTable table
60
     *
61
     * @var \BEdita\Core\Model\Table\RolesTable
62
     */
63
    protected $Roles;
64
65
    /**
66
     * {@inheritdoc}
67
     */
68
    protected function initialize(array $config)
69
    {
70
        $this->Users = TableRegistry::get('Users');
71
        $this->AsyncJobs = TableRegistry::get('AsyncJobs');
72
        $this->Roles = TableRegistry::get('Roles');
73
74
        $this->getEventManager()->on($this);
75
    }
76
77
    /**
78
     * {@inheritDoc}
79
     *
80
     * @return \BEdita\Core\Model\Entity\User
81
     * @throws \Cake\Network\Exception\BadRequestException When validation of URL options fails
82
     * @throws \Cake\Network\Exception\UnauthorizedException Upon external authorization check failure.
83
     */
84
    public function execute(array $data = [])
85
    {
86
        $data = $this->normalizeInput($data);
87
        $errors = $this->validate($data['data']);
88
        if (!empty($errors)) {
89
            throw new BadRequestException([
90
                'title' => __d('bedita', 'Invalid data'),
91
                'detail' => $errors,
92
            ]);
93
        }
94
95
        // operations are not in transaction because AsyncJobs could use a different connection
96
        $user = $this->createUser($data['data']);
97
        try {
98
            // add roles to user, with validity check
99
            $this->addRoles($user, $data['data']);
100
101
            if (empty($data['data']['auth_provider'])) {
102
                $job = $this->createSignupJob($user);
103
                $activationUrl = $this->getActivationUrl($job, $data['data']);
104
105
                $this->dispatchEvent('Auth.signup', [$user, $job, $activationUrl], $this->Users);
106
            } else {
107
                $this->dispatchEvent('Auth.signupActivation', [$user], $this->Users);
108
            }
109
        } catch (\Exception $e) {
110
            // if async job or send mail fail remove user created and re-throw the exception
111
            $this->Users->delete($user);
112
113
            throw $e;
114
        }
115
116
        return (new GetObjectAction(['table' => $this->Users]))->execute(['primaryKey' => $user->id, 'contain' => 'Roles']);
117
    }
118
119
    /**
120
     * Normalize input data to plain JSON if in JSON API format
121
     *
122
     * @param array $data Input data
123
     * @return array Normalized array
124
     */
125
    protected function normalizeInput(array $data)
126
    {
127
        if (!empty($data['data']['data']['attributes'])) {
128
            $meta = !empty($data['data']['data']['meta']) ? $data['data']['data']['meta'] : [];
129
            $data['data'] = array_merge($data['data']['data']['attributes'], $meta);
130
        }
131
132
        return $data;
133
    }
134
135
    /**
136
     * Validate data.
137
     *
138
     * It needs to validate some data that don't concern the User entity
139
     * as `activation_url` and `redirect_url`
140
     *
141
     * @param array $data The data to validate
142
     * @return array
143
     */
144
    protected function validate(array $data)
145
    {
146
        $validator = new Validator();
147
        $validator->setProvider('bedita', Validation::class);
148
149
        if (empty($data['auth_provider'])) {
150
            $validator->requirePresence('activation_url');
151
        } else {
152
            $validator
153
                ->requirePresence('provider_username')
154
                ->requirePresence('access_token');
155
        }
156
157
        $validator
158
            ->add('activation_url', 'customUrl', [
159
                'rule' => 'url',
160
                'provider' => 'bedita',
161
            ])
162
163
            ->add('redirect_url', 'customUrl', [
164
                'rule' => 'url',
165
                'provider' => 'bedita',
166
            ]);
167
168
        return $validator->errors($data);
169
    }
170
171
    /**
172
     * Create a new user with status:
173
     *  - `on` if an external auth provider is used or no activation
174
     *    is required via `Signup.requireActivation` config
175
     *  - `draft` in other cases.
176
     *
177
     * The user is validated using 'signup' validation.
178
     *
179
     * @param array $data The data to save
180
     * @return \BEdita\Core\Model\Entity\User|bool User created or `false` on error
181
     * @throws \Cake\Network\Exception\UnauthorizedException Upon external authorization check failure.
182
     */
183
    protected function createUser(array $data)
184
    {
185
        if (!LoggedUser::getUser()) {
186
            LoggedUser::setUser(['id' => 1]);
187
        }
188
189
        $status = 'draft';
190
        if (Configure::read('Signup.requireActivation') === false) {
191
            $status = 'on';
192
        }
193
        unset($data['status']);
194
195
        if (empty($data['auth_provider'])) {
196
            return $this->createUserEntity($data, $status, 'signup');
197
        }
198
199
        $authProvider = $this->checkExternalAuth($data);
200
201
        $user = $this->createUserEntity($data, 'on', 'signupExternal');
202
203
        // create `ExternalAuth` entry
204
        $this->Users->dispatchEvent('Auth.externalAuth', [
205
            'authProvider' => $authProvider,
206
            'providerUsername' => $data['provider_username'],
207
            'userId' => $user->get('id'),
208
            'params' => empty($data['provider_userdata']) ? null : $data['provider_userdata'],
209
        ]);
210
211
        return $user;
212
    }
213
214
    /**
215
     * Create User model entity.
216
     *
217
     * @param array $data The signup data
218
     * @param string $status User `status`, `on` or `draft`
219
     * @param string $validate Validation options to use
220
     * @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...
221
     */
222
    protected function createUserEntity(array $data, $status, $validate)
223
    {
224
        $action = new SaveEntityAction(['table' => $this->Users]);
225
226
        return $action([
227
            'entity' => $this->Users->newEntity()->set('status', $status),
228
            'data' => $data,
229
            'entityOptions' => [
230
                'validate' => $validate,
231
            ],
232
        ]);
233
    }
234
235
    /**
236
     * Check external auth data validity
237
     *
238
     * To perform external auth check these fields are mandatory:
239
     *  - "auth_provider": provider name like "google", "facebook"... must be in `auth_providers`
240
     *  - "provider_username": id or username of the provider
241
     *  - "access_token": token returned by provider to use in check
242
     *
243
     * @param array $data The signup data
244
     * @return \BEdita\Core\Model\Entity\AuthProvider AuthProvider entity
245
     * @throws \Cake\Network\Exception\UnauthorizedException Upon external authorization check failure.
246
     */
247
    protected function checkExternalAuth(array $data)
248
    {
249
        /** @var \BEdita\Core\Model\Entity\AuthProvider $authProvider */
250
        $authProvider = TableRegistry::get('AuthProviders')->find('enabled')
251
            ->where(['name' => $data['auth_provider']])
252
            ->first();
253
        if (empty($authProvider)) {
254
            throw new UnauthorizedException(__d('bedita', 'External auth provider not found'));
255
        }
256
        $providerResponse = $this->getOAuth2Response($authProvider->get('url'), $data['access_token']);
257
        if (!$authProvider->checkAuthorization($providerResponse, $data['provider_username'])) {
258
            throw new UnauthorizedException(__d('bedita', 'External auth failed'));
259
        }
260
261
        return $authProvider;
262
    }
263
264
    /**
265
     * Get response from an OAuth2 provider
266
     *
267
     * @param string $url OAuth2 provider URL
268
     * @param string $accessToken Access token to use in request
269
     * @return array Response from an OAuth2 provider
270
     * @codeCoverageIgnore
271
     */
272
    protected function getOAuth2Response($url, $accessToken)
273
    {
274
        $response = (new Client())->get($url, [], ['headers' => ['Authorization' => 'Bearer ' . $accessToken]]);
275
276
        return !empty($response->json) ? $response->json : [];
277
    }
278
279
    /**
280
     * Add roles to user if requested, with validity check
281
     *
282
     * @param \BEdita\Core\Model\Entity\User $entity The user created
283
     * @param array $data The signup data
284
     * @return void
285
     */
286
    protected function addRoles(User $entity, array $data)
287
    {
288
        if (empty($data['roles'])) {
289
            return;
290
        }
291
        $roles = $this->loadRoles($data['roles']);
292
        $association = $this->Users->associations()->getByProperty('roles');
293
        $association->link($entity, $roles);
294
    }
295
296
    /**
297
     * Load requested roles entities with validation
298
     *
299
     * @param array $roles Requested role names
300
     * @return \BEdita\Core\Model\Entity\Role[] requested role entities
301
     * @throws \Cake\Network\Exception\BadRequestException When role validation fails
302
     */
303
    protected function loadRoles(array $roles)
304
    {
305
        $entities = [];
306
        $allowed = (array)Configure::read('Signup.roles');
307
        foreach ($roles as $name) {
308
            $role = $this->Roles->find()->where(compact('name'))->first();
309
            if (RolesTable::ADMIN_ROLE === $role->get('id') || !in_array($name, $allowed)) {
310
                throw new BadRequestException(__d('bedita', 'Role "{0}" not allowed on signup', [$name]));
311
            }
312
            $entities[] = $role;
313
        }
314
315
        return $entities;
316
    }
317
318
    /**
319
     * Create the signup async job
320
     *
321
     * @param \BEdita\Core\Model\Entity\User $user The user created
322
     * @return \BEdita\Core\Model\Entity\AsyncJob
323
     */
324
    protected function createSignupJob(User $user)
325
    {
326
        $action = new SaveEntityAction(['table' => $this->AsyncJobs]);
327
328
        return $action([
329
            'entity' => $this->AsyncJobs->newEntity(),
330
            'data' => [
331
                'service' => 'signup',
332
                'payload' => [
333
                    'user_id' => $user->id,
334
                ],
335
                'scheduled_from' => new Time('1 day'),
336
                'priority' => 1,
337
            ],
338
        ]);
339
    }
340
341
    /**
342
     * Send confirmation email to user
343
     *
344
     * @param \Cake\Event\Event $event Dispatched event.
345
     * @param \BEdita\Core\Model\Entity\User $user The user
346
     * @param \BEdita\Core\Model\Entity\AsyncJob $job The referred async job
347
     * @param string $activationUrl URL to be used for activation.
348
     * @return void
349
     */
350
    public function sendMail(Event $event, User $user, AsyncJob $job, $activationUrl)
351
    {
352
        $options = [
353
            'params' => compact('activationUrl', 'user'),
354
        ];
355
        $this->getMailer('BEdita/Core.User')->send('signup', [$options]);
356
    }
357
358
    /**
359
     * Send welcome email to user to inform of successfully activation
360
     * External auth users are already activated
361
     *
362
     * @param \Cake\Event\Event $event Dispatched event.
363
     * @param \BEdita\Core\Model\Entity\User $user The user
364
     * @return void
365
     */
366
    public function sendActivationMail(Event $event, User $user)
367
    {
368
        $options = [
369
            'params' => compact('user'),
370
        ];
371
        $this->getMailer('BEdita/Core.User')->send('welcome', [$options]);
372
    }
373
374
    /**
375
     * Return the signup activation url
376
     *
377
     * @param \BEdita\Core\Model\Entity\AsyncJob $job The async job entity
378
     * @param array $urlOptions The options used to build activation url
379
     * @return string
380
     */
381
    protected function getActivationUrl(AsyncJob $job, array $urlOptions)
382
    {
383
        $baseUrl = $urlOptions['activation_url'];
384
        $redirectUrl = empty($urlOptions['redirect_url']) ? '' : '&redirect_url=' . rawurlencode($urlOptions['redirect_url']);
385
        $baseUrl .= (strpos($baseUrl, '?') === false) ? '?' : '&';
386
387
        return sprintf('%suuid=%s%s', $baseUrl, $job->uuid, $redirectUrl);
388
    }
389
390
    /**
391
     * {@inheritdoc}
392
     */
393
    public function implementedEvents()
394
    {
395
        return [
396
            'Auth.signup' => 'sendMail',
397
            'Auth.signupActivation' => 'sendActivationMail',
398
        ];
399
    }
400
}
401