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
Bug
introduced
by
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 |