Completed
Push — 4-cactus ( 5dfe85...ece855 )
by Paolo
18s queued 12s
created

LoginController::login()   B

Complexity

Conditions 7
Paths 8

Size

Total Lines 29
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 7
eloc 17
nc 8
nop 0
dl 0
loc 29
rs 8.8333
c 0
b 0
f 0
1
<?php
2
/**
3
 * BEdita, API-first content management framework
4
 * Copyright 2018 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\Core\Model\Action\ChangeCredentialsAction;
17
use BEdita\Core\Model\Action\ChangeCredentialsRequestAction;
18
use BEdita\Core\Model\Action\SaveEntityAction;
19
use BEdita\Core\Model\Entity\User;
20
use Cake\Auth\PasswordHasherFactory;
21
use Cake\Controller\Component\AuthComponent;
22
use Cake\Core\Configure;
23
use Cake\Network\Exception\BadRequestException;
24
use Cake\Network\Exception\UnauthorizedException;
25
use Cake\ORM\TableRegistry;
26
use Cake\Routing\Router;
27
use Cake\Utility\Security;
28
use Firebase\JWT\JWT;
0 ignored issues
show
Bug introduced by
The type Firebase\JWT\JWT was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
29
30
/**
31
 * Controller for `/auth` endpoint.
32
 *
33
 * @since 4.0.0
34
 */
35
class LoginController extends AppController
36
{
37
    /**
38
     * Default password hasher settings.
39
     *
40
     * @var array
41
     */
42
    const PASSWORD_HASHER = [
43
        'className' => 'Fallback',
44
        'hashers' => [
45
            'Default',
46
            'Weak' => ['hashType' => 'md5'],
47
        ],
48
    ];
49
50
    /**
51
     * {@inheritDoc}
52
     *
53
     * @codeCoverageIgnore
54
     */
55
    public function initialize()
56
    {
57
        parent::initialize();
58
59
        if ($this->request->contentType() === 'application/json') {
0 ignored issues
show
Bug introduced by
The method contentType() does not exist on null. ( Ignorable by Annotation )

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

59
        if ($this->request->/** @scrutinizer ignore-call */ contentType() === 'application/json') {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
60
            $this->RequestHandler->setConfig('inputTypeMap.json', ['json_decode', true], false);
61
        }
62
63
        if ($this->request->getParam('action') === 'login') {
64
            $authenticationComponents = [
65
                AuthComponent::ALL => [
66
                    'scope' => [
67
                        'blocked' => false,
68
                    ],
69
                    'contain' => ['Roles'],
70
                ],
71
                'Form' => [
72
                    'fields' => [
73
                        'username' => 'username',
74
                        'password' => 'password_hash',
75
                    ],
76
                    'passwordHasher' => self::PASSWORD_HASHER,
77
                    'finder' => 'login',
78
                 ],
79
                'BEdita/API.Jwt' => [
80
                    'queryDatasource' => true,
81
                ],
82
            ];
83
84
            $authenticationComponents += TableRegistry::get('AuthProviders')
85
                ->find('authenticate')
86
                ->toArray();
87
88
            $this->Auth->setConfig('authenticate', $authenticationComponents, false);
89
        }
90
91
        if ($this->request->getParam('action') === 'change') {
92
            $this->Auth->getAuthorize('BEdita/API.Endpoint')->setConfig('defaultAuthorized', true);
93
        }
94
    }
95
96
    /**
97
     * Login action use cases:
98
     *
99
     *  - classic username and password
100
     *  - only with username, first step of OTP login
101
     *  - with username, authorization code and secret token as OTP login or 2FA access
102
     *
103
     * @return void
104
     * @throws \Cake\Network\Exception\UnauthorizedException Throws an exception if user credentials are invalid.
105
     */
106
    public function login()
107
    {
108
        $this->request->allowMethod('post');
109
110
        if ($this->request->getData('password')) {
111
            $this->request = $this->request
112
                ->withData('password_hash', $this->request->getData('password'))
113
                ->withData('password', null);
114
        }
115
116
        $result = $this->Auth->identify();
117
        if (!$result || !is_array($result)) {
118
            throw new UnauthorizedException(__('Login request not successful'));
119
        }
120
121
        // Check if result contains only an authorization code (OTP & 2FA use cases)
122
        if (!empty($result['authorization_code']) && count($result) === 1) {
123
            $meta = ['authorization_code' => $result['authorization_code']];
124
        } else {
125
            // Result is a user; check endpoint permission on `/auth`
126
            if (!$this->Auth->isAuthorized($result)) {
127
                throw new UnauthorizedException(__('Login not authorized'));
128
            }
129
            $result = $this->reducedUserData($result);
130
            $meta = $this->jwtTokens($result);
131
        }
132
133
        $this->set('_serialize', []);
134
        $this->set('_meta', $meta);
135
    }
136
137
    /**
138
     * Return a reduced version of user data with only
139
     * `id`, `username` and for each role `id` and `name
140
     *
141
     * @param array $userInput Complete user data
142
     * @return array Reduced user data
143
     */
144
    protected function reducedUserData(array $userInput)
145
    {
146
        $roles = [];
147
        foreach ($userInput['roles'] as $role) {
148
            $roles[] = [
149
                'id' => $role['id'],
150
                'name' => $role['name'],
151
            ];
152
        }
153
        $user = array_intersect_key($userInput, array_flip(['id', 'username']));
154
        $user['roles'] = $roles;
155
156
        return $user;
157
    }
158
159
    /**
160
     * Calculate JWT token for auth and renew operations
161
     *
162
     * @param array $user Minimal user data to encode in JWT
163
     * @return array JWT tokens requested
164
     */
165
    protected function jwtTokens(array $user)
166
    {
167
        $algorithm = Configure::read('Security.jwt.algorithm') ?: 'HS256';
168
        $duration = Configure::read('Security.jwt.duration') ?: '+20 minutes';
169
        $currentUrl = Router::reverse($this->request, true);
170
        $claims = [
171
            'iss' => Router::fullBaseUrl(),
172
            'iat' => time(),
173
            'nbf' => time(),
174
        ];
175
176
        $jwt = JWT::encode(
177
            $user + $claims + ['exp' => strtotime($duration)],
178
            Security::getSalt(),
179
            $algorithm
180
        );
181
        $renew = JWT::encode(
182
            $claims + ['sub' => $user['id'], 'aud' => $currentUrl],
183
            Security::getSalt(),
184
            $algorithm
185
        );
186
187
        return compact('jwt', 'renew');
188
    }
189
190
    /**
191
     * Read logged user data.
192
     *
193
     * @return void
194
     */
195
    public function whoami()
196
    {
197
        $this->request->allowMethod('get');
198
199
        $user = $this->userEntity();
200
201
        $this->set('_fields', $this->request->getQuery('fields', []));
202
        $this->set(compact('user'));
203
        $this->set('_serialize', ['user']);
204
    }
205
206
    /**
207
     * Update user profile data.
208
     *
209
     * @return void
210
     * @throws \Cake\Network\Exception\BadRequestException On invalid input data
211
     */
212
    public function update()
213
    {
214
        $this->request->allowMethod('patch');
215
216
        $entity = $this->userEntity();
217
        $entity->setAccess(['username', 'password_hash', 'email'], false);
218
219
        $data = $this->request->getData();
220
        $this->checkPassword($entity, $data);
221
222
        $action = new SaveEntityAction(['table' => TableRegistry::get('Users')]);
223
        $action(compact('entity', 'data'));
224
225
        // reload entity to cancel previous `setAccess` (otherwise `username` and `email` will appear in `meta`)
226
        $entity = $this->userEntity();
227
        $this->set(compact('entity'));
228
        $this->set('_serialize', ['entity']);
229
    }
230
231
    /**
232
     * Check current password if a password change is requested.
233
     * If `password` is set in requesta data current valid password must be in `old_password`
234
     *
235
     * @param \BEdita\Core\Model\Entity\User $entity Logged user entity.
236
     * @param array $data Request data.
237
     * @throws \Cake\Network\Exception\BadRequestException Throws an exception if current password is not correct.
238
     * @return void
239
     */
240
    protected function checkPassword(User $entity, array $data)
241
    {
242
        if (empty($data['password'])) {
243
            return;
244
        }
245
246
        if (empty($data['old_password'])) {
247
            throw new BadRequestException(__d('bedita', 'Missing current password'));
248
        }
249
250
        $hasher = PasswordHasherFactory::build(self::PASSWORD_HASHER);
251
        if (!$hasher->check($data['old_password'], $entity->password_hash)) {
252
            throw new BadRequestException(__d('bedita', 'Wrong password'));
253
        }
254
    }
255
256
    /**
257
     * Read logged user entity including roles.
258
     *
259
     * @return \BEdita\Core\Model\Entity\User Logged user entity
260
     * @throws \Cake\Network\Exception\UnauthorizedException Throws an exception if user not logged.
261
     */
262
    protected function userEntity()
263
    {
264
        $userId = $this->Auth->user('id');
265
        if (!$userId) {
266
            $this->Auth->getAuthenticate('BEdita/API.Jwt')->unauthenticated($this->request, $this->response);
0 ignored issues
show
Bug introduced by
It seems like $this->request can also be of type null; however, parameter $request of Cake\Auth\BaseAuthenticate::unauthenticated() does only seem to accept Cake\Http\ServerRequest, 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

266
            $this->Auth->getAuthenticate('BEdita/API.Jwt')->unauthenticated(/** @scrutinizer ignore-type */ $this->request, $this->response);
Loading history...
Bug introduced by
It seems like $this->response can also be of type null; however, parameter $response of Cake\Auth\BaseAuthenticate::unauthenticated() does only seem to accept Cake\Http\Response, 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

266
            $this->Auth->getAuthenticate('BEdita/API.Jwt')->unauthenticated($this->request, /** @scrutinizer ignore-type */ $this->response);
Loading history...
267
        }
268
269
        return TableRegistry::get('Users')->get($userId, ['contain' => ['Roles']]);
270
    }
271
272
    /**
273
     * Change access credentials (password)
274
     * If a valid token is passed actual change is perfomed, otherwise change is requested and token is
275
     * sent directly to user, tipically via email
276
     *
277
     * @return \Cake\Http\Response|null
278
     */
279
    public function change()
280
    {
281
        $this->request->allowMethod(['patch', 'post']);
282
283
        if ($this->request->is('post')) {
284
            $action = new ChangeCredentialsRequestAction();
285
            $action($this->request->getData());
286
287
            return $this->response
288
                ->withStatus(204);
0 ignored issues
show
Bug introduced by
The method withStatus() does not exist on null. ( Ignorable by Annotation )

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

288
                ->/** @scrutinizer ignore-call */ withStatus(204);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
289
        }
290
291
        $action = new ChangeCredentialsAction();
292
        $user = $action($this->request->getData());
293
294
        $meta = [];
295
        if ($this->request->getData('login')) {
296
            $userJwt = $this->reducedUserData($user->toArray());
297
            $meta = $this->jwtTokens($userJwt);
298
        }
299
300
        $this->set(compact('user'));
301
        $this->set('_serialize', ['user']);
302
        $this->set('_meta', $meta);
303
304
        return null;
305
    }
306
}
307