Issues (219)

Branch: 4-cactus

BEdita/API/src/Controller/LoginController.php (1 issue)

Labels
Severity
1
<?php
2
/**
3
 * BEdita, API-first content management framework
4
 * Copyright 2018-2021 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\API\Controller;
15
16
use BEdita\API\Utility\JWTHandler;
17
use BEdita\Core\Model\Action\ActionTrait;
18
use BEdita\Core\Model\Action\GetObjectAction;
19
use BEdita\Core\Model\Action\SaveEntityAction;
20
use BEdita\Core\Model\Entity\User;
21
use BEdita\Core\State\CurrentApplication;
22
use Cake\Auth\PasswordHasherFactory;
23
use Cake\Controller\Component\AuthComponent;
24
use Cake\Http\Exception\BadRequestException;
25
use Cake\Http\Exception\NotFoundException;
26
use Cake\Http\Exception\UnauthorizedException;
27
use Cake\Http\Response;
28
use Cake\ORM\Association;
29
use Cake\ORM\Table;
30
use Cake\ORM\TableRegistry;
31
use Cake\Routing\Router;
32
use Cake\Utility\Hash;
33
use Cake\Utility\Inflector;
34
35
/**
36
 * Controller for `/auth` endpoint.
37
 *
38
 * @since 4.0.0
39
 * @property \BEdita\Core\Model\Table\UsersTable $Users
40
 * @property \BEdita\Core\Model\Table\AuthProvidersTable $AuthProviders
41
 */
42
class LoginController extends AppController
43
{
44
    use ActionTrait;
45
46
    /**
47
     * Default password hasher settings.
48
     *
49
     * @var array
50
     */
51
    public const PASSWORD_HASHER = [
52
        'className' => 'Fallback',
53
        'hashers' => [
54
            'Default',
55
            'Weak' => ['hashType' => 'md5'],
56
        ],
57
    ];
58
59
    /**
60
     * @inheritDoc
61
     */
62
    public function initialize(): void
63
    {
64
        parent::initialize();
65
66
        $this->loadModel('Users');
67
        $this->loadModel('AuthProviders');
68
69
        if ($this->request->contentType() === 'application/json') {
70
            $this->RequestHandler->setConfig('inputTypeMap.json', ['json_decode', true], false);
71
        }
72
73
        if (in_array($this->request->getParam('action'), ['login', 'optout'])) {
74
            $authenticationComponents = [
75
                AuthComponent::ALL => [
76
                  'finder' => 'loginRoles',
77
                ],
78
                'Form' => [
79
                    'fields' => [
80
                        'username' => 'username',
81
                        'password' => 'password_hash',
82
                    ],
83
                    'passwordHasher' => self::PASSWORD_HASHER,
84
                ],
85
                'BEdita/API.Jwt',
86
            ];
87
88
            $authenticationComponents += $this->AuthProviders
89
                ->find('authenticate')
90
                ->toArray();
91
92
            $this->Auth->setConfig('authenticate', $authenticationComponents, false);
93
        }
94
95
        if ($this->request->getParam('action') === 'optout') {
96
            $this->Auth->setConfig('loginAction', ['_name' => 'api:login:optout']);
97
        }
98
99
        if ($this->request->getParam('action') === 'change') {
100
            $this->Auth->getAuthorize('BEdita/API.Endpoint')->setConfig('defaultAuthorized', true);
101
        }
102
    }
103
104
    /**
105
     * Login action via user identification with classic username/password, OTP, Oauth2 or 2FA.
106
     * See `identify` method for more details.
107
     *
108
     * @return void
109
     * @throws \Cake\Http\Exception\UnauthorizedException Throws an exception if user credentials are invalid or acces is not authorized
110
     */
111
    public function login(): void
112
    {
113
        $this->setSerialize([]);
114
115
        $this->setGrantType();
116
        $this->checkClientCredentials();
117
118
        $result = $this->identify();
119
        // Check if result contains only an authorization code (OTP & 2FA use cases)
120
        if (!empty($result['authorization_code']) && count($result) === 1) {
121
            $this->set('_meta', ['authorization_code' => $result['authorization_code']]);
122
123
            return;
124
        }
125
        $user = $this->reducedUserData($result);
126
        $meta = $this->jwtTokens($user);
127
        $this->set('_meta', $meta);
128
    }
129
130
    /**
131
     * Try to setup appropriate grant type if missing looking at request data.
132
     * `grant_type` should be always set explicitly in request data.
133
     *
134
     * @return void
135
     */
136
    protected function setGrantType(): void
137
    {
138
        if (!empty($this->request->getData('grant_type'))) {
139
            return;
140
        }
141
142
        $data = $this->request->getData();
143
        if (empty($data)) {
144
            $this->request = $this->request->withData('grant_type', 'refresh_token');
145
        } elseif (!empty($data['username']) && !empty($data['password'])) {
146
            $this->request = $this->request->withData('grant_type', 'password');
147
        } elseif (!empty($data['client_id'])) {
148
            $this->request = $this->request->withData('grant_type', 'client_credentials');
149
        }
150
    }
151
152
    /**
153
     * Optout action
154
     *
155
     * 1. User should be identified like in `login` with classic username\password or OPT/2FA or Oauth2 flow
156
     * 2. User data are deleted or anonymized like calling `DELETE /users/{id}`
157
     * 3. An event `Auth.optout` is dispatched in order to (optionally) remove some user created data or trigger other actions
158
     *
159
     * @return \Cake\Http\Response|null
160
     * @throws \Cake\Http\Exception\UnauthorizedException Throws an exception if user credentials are invalid or acces is not authorized
161
     */
162
    public function optout(): ?Response
163
    {
164
        $result = $this->identify();
165
        // Check if result contains only an authorization code (OTP & 2FA use cases)
166
        if (!empty($result['authorization_code']) && count($result) === 1) {
167
            $meta = ['authorization_code' => $result['authorization_code']];
168
            $this->setSerialize([]);
169
            $this->set('_meta', $meta);
170
171
            return null;
172
        }
173
        // Execute actual optout
174
        $action = new GetObjectAction(['table' => $this->Users]);
175
        $user = $action(['primaryKey' => $result['id']]);
176
        // setup special `_optout` property to allow self-removal
177
        $user->set('_optout', true);
178
        $this->Users->deleteOrFail($user);
179
        $this->dispatchEvent('Auth.optout', [$result]);
180
181
        return $this->response->withStatus(204);
182
    }
183
184
    /**
185
     * User identification, used by `login` and `optout` actions:
186
     *
187
     *  - classic username and password
188
     *  - only with username, first step of OTP login
189
     *  - with username, authorization code and secret token as OTP login or 2FA access
190
     *  - via JWT on refresh token grant type
191
     *
192
     * @return array
193
     * @throws \Cake\Http\Exception\UnauthorizedException Throws an exception if user credentials are invalid or access is unauthorized
194
     */
195
    protected function identify(): array
196
    {
197
        $this->request->allowMethod('post');
198
199
        if ($this->clientCredentialsOnly()) {
200
            return [];
201
        }
202
203
        if ($this->request->getData('password')) {
204
            $this->request = $this->request
205
                ->withData('password_hash', $this->request->getData('password'))
206
                ->withData('password', null);
207
        }
208
209
        $result = $this->Auth->identify();
210
        if (!$result || !is_array($result)) {
211
            throw new UnauthorizedException(__('Login request not successful'));
212
        }
213
        // Result is a user; check endpoint permission on `/auth`
214
        if (empty($result['authorization_code']) && !$this->Auth->isAuthorized($result)) {
215
            throw new UnauthorizedException(__('Login not authorized'));
216
        }
217
218
        return $result;
219
    }
220
221
    /**
222
     * Check if we are dealing with client credentials only.
223
     * In case of `client_credentials` grant type or `refresh_token` grant type
224
     * with only client credentials renew we avoid user identification and return
225
     * only application related tokens.
226
     *
227
     * @return bool
228
     */
229
    protected function clientCredentialsOnly(): bool
230
    {
231
        $grant = $this->request->getData('grant_type');
232
        if (
233
            $grant === 'client_credentials' ||
234
            ($grant === 'refresh_token' && $this->Auth->getConfig('renewClientCredentials') === true)
235
        ) {
236
            return true;
237
        }
238
239
        return false;
240
    }
241
242
    /**
243
     * Verify client application credentials `client_id/client_secret`.
244
     * Upon success the matching application is set via `CurrentApplication` otherwise
245
     * an `UnauthorizedException` will be thrown.
246
     *
247
     * @return void
248
     * @throws \Cake\Http\Exception\UnauthorizedException
249
     */
250
    protected function checkClientCredentials(): void
251
    {
252
        $grantType = $this->request->getData('grant_type');
253
        if (empty($this->request->getData('client_id')) && $grantType !== 'client_credentials') {
254
            return;
255
        }
256
        /** @var \BEdita\Core\Model\Entity\Application|null $application */
257
        $application = TableRegistry::getTableLocator()->get('Applications')
258
            ->find('credentials', [
259
                'client_id' => $this->request->getData('client_id'),
260
                'client_secret' => $this->request->getData('client_secret'),
261
            ])
262
            ->first();
263
        if (empty($application)) {
264
            throw new UnauthorizedException(__('App authentication failed'));
265
        }
266
        CurrentApplication::setApplication($application);
267
    }
268
269
    /**
270
     * Return a reduced version of user data with only
271
     * `id`, `username` and for each role `id` and `name
272
     *
273
     * @param array $userInput Complete user data
274
     * @return array Reduced user data (can be empty in case of client credentials)
275
     */
276
    protected function reducedUserData(array $userInput)
277
    {
278
        $user = array_intersect_key($userInput, array_flip(['id', 'username']));
279
        $user['roles'] = array_map(
280
            function ($role) {
281
                return [
282
                    'id' => Hash::get((array)$role, 'id'),
283
                    'name' => Hash::get((array)$role, 'name'),
284
                ];
285
            },
286
            (array)Hash::get($userInput, 'roles')
287
        );
288
289
        return array_filter($user);
290
    }
291
292
    /**
293
     * Calculate JWT token for auth and renew operations
294
     *
295
     * @param array $user Minimal user data to encode in JWT
296
     * @return array JWT tokens requested
297
     */
298
    protected function jwtTokens(array $user)
299
    {
300
        return JWTHandler::tokens($user, Router::reverse($this->request, true));
301
    }
302
303
    /**
304
     * Read logged user data.
305
     *
306
     * @return void
307
     */
308
    public function whoami()
309
    {
310
        $this->request->allowMethod('get');
311
312
        $user = $this->userEntity();
313
314
        $this->set('_fields', $this->request->getQuery('fields', []));
315
        $this->set(compact('user'));
316
        $this->setSerialize(['user']);
317
    }
318
319
    /**
320
     * Update user profile data.
321
     *
322
     * @return void
323
     * @throws \Cake\Http\Exception\BadRequestException On invalid input data
324
     */
325
    public function update()
326
    {
327
        $this->request->allowMethod('patch');
328
329
        $entity = $this->userEntity();
330
        $entity->setAccess(['username', 'password_hash'], false);
331
        if (!empty($entity->get('email'))) {
332
            $entity->setAccess('email', false);
333
        }
334
335
        $data = $this->request->getData();
336
        $this->checkPassword($entity, $data);
0 ignored issues
show
It seems like $data can also be of type null; however, parameter $data of BEdita\API\Controller\Lo...roller::checkPassword() does only seem to accept array, 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

336
        $this->checkPassword($entity, /** @scrutinizer ignore-type */ $data);
Loading history...
337
338
        $action = new SaveEntityAction(['table' => $this->Users]);
339
        $action(compact('entity', 'data'));
340
341
        // reload entity to cancel previous `setAccess` (otherwise `username` and `email` will appear in `meta`)
342
        $entity = $this->userEntity();
343
        $this->set(compact('entity'));
344
        $this->setSerialize(['entity']);
345
    }
346
347
    /**
348
     * Check current password if a password change is requested.
349
     * If `password` is set in requesta data current valid password must be in `old_password`
350
     *
351
     * @param \BEdita\Core\Model\Entity\User $entity Logged user entity.
352
     * @param array $data Request data.
353
     * @throws \Cake\Http\Exception\BadRequestException Throws an exception if current password is not correct.
354
     * @return void
355
     */
356
    protected function checkPassword(User $entity, array $data)
357
    {
358
        if (empty($data['password'])) {
359
            return;
360
        }
361
362
        if (empty($data['old_password'])) {
363
            throw new BadRequestException(__d('bedita', 'Missing current password'));
364
        }
365
366
        $hasher = PasswordHasherFactory::build(self::PASSWORD_HASHER);
367
        if (!$hasher->check($data['old_password'], $entity->password_hash)) {
368
            throw new BadRequestException(__d('bedita', 'Wrong password'));
369
        }
370
    }
371
372
    /**
373
     * Read logged user entity including roles and other related objects via `include` query string.
374
     *
375
     * @return \BEdita\Core\Model\Entity\User Logged user entity
376
     * @throws \Cake\Http\Exception\UnauthorizedException Throws an exception if user not logged or blocked/removed
377
     */
378
    protected function userEntity()
379
    {
380
        $userId = $this->Auth->user('id');
381
        if (!$userId) {
382
            $this->Auth->getAuthenticate('BEdita/API.Jwt')->unauthenticated($this->request, $this->response);
383
        }
384
        $contain = $this->prepareInclude($this->request->getQuery('include'));
385
        $contain = array_unique(array_merge($contain, ['Roles']));
386
        $conditions = ['id' => $userId];
387
388
        /** @var \BEdita\Core\Model\Entity\User|null $user */
389
        $user = $this->Users
390
            ->find('login', compact('conditions', 'contain'))
391
            ->first();
392
        if (empty($user)) {
393
            throw new UnauthorizedException(__('Request not authorized'));
394
        }
395
396
        return $user;
397
    }
398
399
    /**
400
     * @inheritDoc
401
     */
402
    protected function findAssociation(string $relationship, ?Table $table = null): Association
403
    {
404
        $relationship = Inflector::underscore($relationship);
405
        $association = $this->Users->associations()->getByProperty($relationship);
406
        if (empty($association)) {
407
            throw new NotFoundException(__d('bedita', 'Relationship "{0}" does not exist', $relationship));
408
        }
409
410
        return $association;
411
    }
412
413
    /**
414
     * Change access credentials (password)
415
     * If a valid token is passed actual change is perfomed, otherwise change is requested and token is
416
     * sent directly to user, tipically via email
417
     *
418
     * @return \Cake\Http\Response|null
419
     */
420
    public function change()
421
    {
422
        $this->request->allowMethod(['patch', 'post']);
423
424
        if ($this->request->is('post')) {
425
            $action = $this->createAction('ChangeCredentialsRequestAction');
426
            $action($this->request->getData());
427
428
            return $this->response
429
                ->withStatus(204);
430
        }
431
432
        $action = $this->createAction('ChangeCredentialsAction');
433
        $user = $action($this->request->getData());
434
435
        $meta = [];
436
        if ($this->request->getData('login')) {
437
            $userJwt = $this->reducedUserData($user->toArray());
438
            $meta = $this->jwtTokens($userJwt);
439
        }
440
441
        $this->set(compact('user'));
442
        $this->setSerialize(['user']);
443
        $this->set('_meta', $meta);
444
445
        return null;
446
    }
447
}
448