Passed
Push — master ( 580a70...08c8a1 )
by Julien
07:31 queued 02:30
created

Identity::getRoleList()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 1
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
rs 10
1
<?php
2
3
/**
4
 * This file is part of the Zemit Framework.
5
 *
6
 * (c) Zemit Team <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE.txt
9
 * file that was distributed with this source code.
10
 */
11
12
namespace Zemit;
13
14
use Phalcon\Acl\Role;
15
use Phalcon\Db\Column;
16
use Phalcon\Encryption\Security\Exception;
0 ignored issues
show
Bug introduced by
This use statement conflicts with another class in this namespace, Zemit\Exception. Consider defining an alias.

Let?s assume that you have a directory layout like this:

.
|-- OtherDir
|   |-- Bar.php
|   `-- Foo.php
`-- SomeDir
    `-- Foo.php

and let?s assume the following content of Bar.php:

// Bar.php
namespace OtherDir;

use SomeDir\Foo; // This now conflicts the class OtherDir\Foo

If both files OtherDir/Foo.php and SomeDir/Foo.php are loaded in the same runtime, you will see a PHP error such as the following:

PHP Fatal error:  Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php

However, as OtherDir/Foo.php does not necessarily have to be loaded and the error is only triggered if it is loaded before OtherDir/Bar.php, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias:

// Bar.php
namespace OtherDir;

use SomeDir\Foo as SomeDirFoo; // There is no conflict anymore.
Loading history...
17
use Phalcon\Encryption\Security\JWT\Exceptions\ValidatorException;
18
use Phalcon\Filter\Validation\Validator\Confirmation;
19
use Phalcon\Filter\Validation\Validator\Numericality;
20
use Phalcon\Filter\Validation\Validator\PresenceOf;
21
use Phalcon\Messages\Message;
22
use Phalcon\Mvc\ModelInterface;
23
use Phalcon\Support\Helper\Str\Random;
24
use Zemit\Di\Injectable;
25
use Zemit\Filter\Validation;
26
use Zemit\Models\Interfaces\RoleInterface;
27
use Zemit\Models\Interfaces\SessionInterface;
28
use Zemit\Models\Interfaces\UserInterface;
29
use Zemit\Models\User;
30
use Zemit\Mvc\Model\Behavior\Security as SecurityBehavior;
31
use Zemit\Support\ModelsMap;
32
use Zemit\Support\Options\Options;
33
use Zemit\Support\Options\OptionsInterface;
34
35
/**
36
 * Identity Management
37
 */
38
class Identity extends Injectable implements OptionsInterface
39
{
40
    use Options;
41
    use ModelsMap;
42
    
43
    public string $sessionKey;
44
    
45
    public array $store = [];
46
    
47
    public ?UserInterface $user;
48
    
49
    public ?UserInterface $userAs;
50
    
51
    public ?SessionInterface $currentSession = null;
52
    
53
    /**
54
     * Forces some options
55
     */
56 4
    public function initialize(): void
57
    {
58 4
        $this->sessionKey = $this->getOption('sessionKey') ?? $this->sessionKey;
59 4
        $this->modelsMap = $this->getOption('modelsMap') ?? $this->modelsMap;
60
    }
61
    
62
    /**
63
     * Check whether the current identity has roles
64
     */
65
    public function hasRole(?array $roles = null, bool $or = false, bool $inherit = true): bool
66
    {
67
        return $this->has($roles, array_keys($this->getRoleList($inherit) ?: []), $or);
68
    }
69
    
70
    /**
71
     * Get the User ID
72
     */
73
    public function getUserId(bool $as = false): ?int
74
    {
75
        $user = $this->getUser($as);
76
        return $user ? $user->getId() : null;
0 ignored issues
show
introduced by
$user is of type Zemit\Models\Interfaces\UserInterface, thus it always evaluated to true.
Loading history...
77
    }
78
    
79
    /**
80
     * Get the User (As) ID
81
     */
82
    public function getUserAsId(): ?int
83
    {
84
        return $this->getUserId(true);
85
    }
86
    
87
    /**
88
     * Check if the needles meet the haystack using nested arrays
89
     * Reversing ANDs and ORs within each nested subarray
90
     *
91
     * $this->has(['dev', 'admin'], $this->getUser()->getRoles(), true); // 'dev' OR 'admin'
92
     * $this->has(['dev', 'admin'], $this->getUser()->getRoles(), false); // 'dev' AND 'admin'
93
     *
94
     * $this->has(['dev', 'admin'], $this->getUser()->getRoles()); // 'dev' AND 'admin'
95
     * $this->has([['dev', 'admin']], $this->getUser()->getRoles()); // 'dev' OR 'admin'
96
     * $this->has([[['dev', 'admin']]], $this->getUser()->getRoles()); // 'dev' AND 'admin'
97
     *
98
     * @param array|string|null $needles Needles to match and meet the rules
99
     * @param array $haystack Haystack array to search into
100
     * @param bool $or True to force with "OR" , false to force "AND" condition
101
     *
102
     * @return bool Return true or false if the needles rules are being met
103
     */
104
    public function has($needles = null, array $haystack = [], bool $or = false)
105
    {
106
        if (!is_array($needles)) {
107
            $needles = [$needles];
108
        }
109
        
110
        $result = [];
111
        foreach ([...$needles] as $needle) {
112
            if (is_array($needle)) {
113
                $result [] = $this->has($needle, $haystack, !$or);
114
            }
115
            else {
116
                $result [] = in_array($needle, $haystack, true);
117
            }
118
        }
119
        
120
        return $or ?
121
            !in_array(false, $result, true) :
122
            in_array(true, $result, true);
123
    }
124
    
125
    /**
126
     * Create or refresh a session
127
     * @throws Exception|ValidatorException
128
     */
129
    public function getJwt(bool $refresh = false): array
130
    {
131
        [$key, $token] = $this->getKeyToken();
132
        
133
        // generate new key & token pair if not set
134
        $key ??= $this->security->getRandom()->uuid();
135
        $token ??= $this->helper->random(Random::RANDOM_ALNUM, rand(111, 222));
136
        $newToken = $refresh ? $this->helper->random(Random::RANDOM_ALNUM, rand(111, 222)) : $token;
137
        
138
        // save the key token into the store (database or session)
139
        $sessionClass = $this->getSessionClass();
140
        $session = $this->getSession($key, $token) ?: new $sessionClass();
141
        $session->setKey($key);
142
        $session->setToken($session->hash($key . $newToken));
0 ignored issues
show
Bug introduced by
The method hash() does not exist on Zemit\Models\Interfaces\SessionInterface. Since it exists in all sub-types, consider adding an abstract or default implementation to Zemit\Models\Interfaces\SessionInterface. ( Ignorable by Annotation )

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

142
        $session->setToken($session->/** @scrutinizer ignore-call */ hash($key . $newToken));
Loading history...
143
        $session->setDate(date('Y-m-d H:i:s'));
144
        $saved = $session->save();
145
        
146
        // temporary store the new key token pair
147
        $this->store = ['key' => $session->getKey(), 'token' => $newToken];
148
        
149
        if ($saved && $this->config->path('identity.sessionFallback', false)) {
0 ignored issues
show
Bug Best Practice introduced by
The property config does not exist on Zemit\Identity. Since you implemented __get, consider adding a @property annotation.
Loading history...
150
            // store key & token into the session
151
            $this->session->set($this->sessionKey, $this->store);
152
        }
153
        else {
154
            // delete the session
155
            $this->session->remove($this->sessionKey);
156
        }
157
        
158
        // jwt token
159
        $tokenOptions = $this->getConfig()->pathToArray('identity.token') ?? [];
160
        $token = $this->getJwtToken($this->sessionKey, $this->store, $tokenOptions);
161
        
162
        // refresh jwt token
163
        $refreshTokenOptions = $this->getConfig()->pathToArray('identity.refreshToken') ?? [];
164
        $refreshToken = $this->getJwtToken($this->sessionKey . '-refresh', $this->store, $refreshTokenOptions);
165
        
166
        return [
167
            'saved' => $saved,
168
            'hasSession' => $this->session->has($this->sessionKey),
169
            'refreshed' => $saved && $refresh,
170
            'validated' => $session->checkHash($session->getToken(), $session->getKey() . $newToken),
0 ignored issues
show
Bug introduced by
The method checkHash() does not exist on Zemit\Models\Interfaces\SessionInterface. Since it exists in all sub-types, consider adding an abstract or default implementation to Zemit\Models\Interfaces\SessionInterface. ( Ignorable by Annotation )

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

170
            'validated' => $session->/** @scrutinizer ignore-call */ checkHash($session->getToken(), $session->getKey() . $newToken),
Loading history...
171
            'messages' => $session->getMessages(),
172
            'jwt' => $token,
173
            'refreshToken' => $refreshToken,
174
        ];
175
    }
176
    
177
    /**
178
     * Get basic Identity information
179
     * @throws \Exception
180
     */
181 1
    public function getIdentity(bool $inherit = true): array
182
    {
183 1
        $user = $this->getUser();
184 1
        $userAs = $this->getUserAs();
185
        
186 1
        $roleList = [];
187 1
        $groupList = [];
188 1
        $typeList = [];
189
        
190 1
        if ($user) {
0 ignored issues
show
introduced by
$user is of type Zemit\Models\Interfaces\UserInterface, thus it always evaluated to true.
Loading history...
191
            if (isset($user->rolelist) && is_iterable($user->rolelist)) {
0 ignored issues
show
Bug introduced by
Accessing rolelist on the interface Zemit\Models\Interfaces\UserInterface suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
192
                foreach ($user->rolelist as $role) {
193
                    $roleList [$role->getIndex()] = $role;
194
                }
195
            }
196
            
197
            if (isset($user->grouplist) && is_iterable($user->grouplist)) {
0 ignored issues
show
Bug introduced by
Accessing grouplist on the interface Zemit\Models\Interfaces\UserInterface suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
198
                foreach ($user->grouplist as $group) {
199
                    $groupList [$group->getIndex()] = $group;
200
                    if ($group->rolelist) {
201
                        foreach ($group->rolelist as $role) {
202
                            $roleList [$role->getIndex()] = $role;
203
                        }
204
                    }
205
                }
206
            }
207
            
208
            if (isset($user->typelist) && is_iterable($user->typelist)) {
0 ignored issues
show
Bug introduced by
Accessing typelist on the interface Zemit\Models\Interfaces\UserInterface suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
209
                foreach ($user->typelist as $type) {
210
                    $typeList [$type->getIndex()] = $type;
211
                    if ($type->grouplist) {
212
                        foreach ($type->grouplist as $group) {
213
                            $groupList [$group->getIndex()] = $group;
214
                            if ($group->rolelist) {
215
                                foreach ($group->rolelist as $role) {
216
                                    $roleList [$role->getIndex()] = $role;
217
                                }
218
                            }
219
                        }
220
                    }
221
                }
222
            }
223
        }
224
        
225
        // Append inherit roles
226 1
        if ($inherit) {
227 1
            $roleIndexList = [];
228 1
            foreach ($roleList as $role) {
229
                $roleIndexList [] = $role->getIndex();
230
            }
231
            
232 1
            $inheritedRoleIndexList = $this->getInheritedRoleList($roleIndexList);
233 1
            if (!empty($inheritedRoleIndexList)) {
234
                
235
                /** @var RoleInterface $roleClass */
236
                SecurityBehavior::staticStart();
237
                $roleClass = $this->getRoleClass();
238
                $inheritedRoleList = $roleClass::find([
239
                    'index in ({role:array})',
240
                    'bind' => ['role' => $inheritedRoleIndexList],
241
                    'bindTypes' => ['role' => Column::BIND_PARAM_STR],
242
                ]);
243
                SecurityBehavior::staticStop();
244
                
245
                foreach ($inheritedRoleList as $inheritedRoleEntity) {
246
                    assert($inheritedRoleEntity instanceof RoleInterface);
247
                    $inheritedRoleIndex = $inheritedRoleEntity->getIndex();
248
                    $roleList[$inheritedRoleIndex] = $inheritedRoleEntity;
249
                    
250
                    if (($key = array_search($inheritedRoleIndex, $inheritedRoleIndexList)) !== false) {
251
                        unset($inheritedRoleIndexList[$key]);
252
                    }
253
                }
254
                
255
                // unable to find some roles by index
256
                if (!empty($inheritedRoleIndexList)) {
257
                    
258
                    // To avoid breaking stuff in production, create a new role if it doesn't exist
259
                    if (!$this->config->path('app.debug', false)) {
0 ignored issues
show
Bug Best Practice introduced by
The property config does not exist on Zemit\Identity. Since you implemented __get, consider adding a @property annotation.
Loading history...
260
                        foreach ($inheritedRoleIndexList as $inheritedRoleIndex) {
261
                            $roleList[$inheritedRoleIndex] = new $roleClass();
262
                            $roleList[$inheritedRoleIndex]->setIndex($inheritedRoleIndex);
263
                            $roleList[$inheritedRoleIndex]->setLabel(ucfirst($inheritedRoleIndex));
264
                        }
265
                    }
266
                    
267
                    // throw an exception under development so it can be fixed
268
                    else {
269
                        throw new \Exception('Role `' . implode('`, `', $inheritedRoleIndexList) . '` not found using the class `' . $this->getRoleClass() . '`.', 404);
270
                    }
271
                }
272
            }
273
        }
274
        
275
        // We don't need userAs group / type / role list
276 1
        return [
277 1
            'loggedIn' => $this->isLoggedIn(),
278 1
            'loggedInAs' => $this->isLoggedInAs(),
279 1
            'user' => $user,
280 1
            'userAs' => $userAs,
281 1
            'roleList' => $roleList,
282 1
            'typeList' => $typeList,
283 1
            'groupList' => $groupList,
284 1
        ];
285
    }
286
    
287
    /**
288
     * Return the list of inherited role list (recursively)
289
     */
290 1
    public function getInheritedRoleList(array $roleIndexList = []): array
291
    {
292 1
        $inheritedRoleList = [];
293 1
        $processedRoleIndexList = [];
294
        
295
        // While we still have role index list to process
296 1
        while (!empty($roleIndexList)) {
297
            
298
            // Process role index list
299
            foreach ($roleIndexList as $roleIndex) {
300
                // Get inherited roles from config service
301
                
302
                $configRoleList = $this->config->path('permissions.roles.' . $roleIndex . '.inherit', false);
0 ignored issues
show
Bug Best Practice introduced by
The property config does not exist on Zemit\Identity. Since you implemented __get, consider adding a @property annotation.
Loading history...
303
                
304
                if ($configRoleList) {
305
                    
306
                    // Append inherited role to process list
307
                    $roleList = $configRoleList->toArray();
308
                    $roleIndexList = array_merge($roleIndexList, $roleList);
309
                    $inheritedRoleList = array_merge($inheritedRoleList, $roleList);
310
                }
311
                
312
                // Add role index to processed list
313
                $processedRoleIndexList [] = $roleIndex;
314
            }
315
            
316
            // Keep the unprocessed role index list
317
            $roleIndexList = array_filter(array_unique(array_diff($roleIndexList, $processedRoleIndexList)));
318
        }
319
        
320
        // Return the list of inherited role list (recursively)
321 1
        return array_values(array_filter(array_unique($inheritedRoleList)));
322
    }
323
    
324
    /**
325
     * Return true if the user is currently logged in
326
     */
327 1
    public function isLoggedIn(bool $as = false, bool $force = false): bool
328
    {
329 1
        return !!$this->getUser($as, $force);
330
    }
331
    
332
    /**
333
     * Return true if the user is currently logged in
334
     */
335 1
    public function isLoggedInAs(bool $force = false): bool
336
    {
337 1
        return $this->isLoggedIn(true, $force);
338
    }
339
    
340
    /**
341
     * Get Identity User
342
     * @param bool $as True to return the Identity User (As)
343
     * @param bool|null $force True to fetch the user from the database again
344
     * @return UserInterface|null Return the Identity User Model
345
     */
346 4
    public function getUser(bool $as = false, ?bool $force = null): ?UserInterface
347
    {
348
        // session required to fetch user
349 4
        $session = $this->getSession();
350 4
        if (!$session) {
351 4
            return null;
352
        }
353
        
354
        $force = $force
355
            || ($as && empty($this->userAs))
356
            || (!$as && empty($this->user));
357
        
358
        if ($force) {
359
            
360
            $userId = $as
361
                ? $session->getAsUserId()
362
                : $session->getUserId();
363
            
364
            $userClass = $this->getUserClass();
365
            
366
            if (empty($userId)) {
367
                $user = null;
368
            } else {
369
                SecurityBehavior::staticStart();
370
                $user = $userClass::findFirstWithById([
371
                    'RoleList',
372
                    'GroupList.RoleList',
373
                    'TypeList.GroupList.RoleList',
374
                ], $userId);
375
                SecurityBehavior::staticStop();
376
            }
377
            
378
            $as
379
                ? $this->setUserAs($user)
380
                : $this->setUser($user);
381
            
382
            return $user;
383
        }
384
        
385
        return $as
386
            ? $this->userAs
387
            : $this->user;
388
    }
389
    
390
    /**
391
     * Get Identity User (As)
392
     */
393 1
    public function getUserAs(): ?UserInterface
394
    {
395 1
        return $this->getUser(true);
396
    }
397
    
398
    /**
399
     * Set Identity User
400
     */
401
    public function setUser(?UserInterface $user): void
402
    {
403
        $this->user = $user;
404
    }
405
    
406
    /**
407
     * Set Identity User (As)
408
     */
409
    public function setUserAs(?UserInterface $user): void
410
    {
411
        $this->userAs = $user;
412
    }
413
    
414
    /**
415
     * Get the "Roles" related to the current session
416
     */
417 1
    public function getRoleList(bool $inherit = true): array
418
    {
419 1
        return $this->getIdentity($inherit)['roleList'] ?? [];
420
    }
421
    
422
    /**
423
     * Get the "Groups" related to the current session
424
     */
425
    public function getGroupList(bool $inherit = true): array
426
    {
427
        return $this->getIdentity($inherit)['groupList'] ?? [];
428
    }
429
    
430
    /**
431
     * Get the "Types" related to the current session
432
     */
433
    public function getTypeList(bool $inherit = true): array
434
    {
435
        return $this->getIdentity($inherit)['typeList'] ?? [];
436
    }
437
    
438
    /**
439
     * Return the list of ACL roles
440
     * - Reserved roles: guest, cli, everyone
441
     *
442
     * @param array|null $roleList
443
     * @return array
444
     */
445 1
    public function getAclRoles(?array $roleList = null): array
446
    {
447 1
        $roleList ??= $this->getRoleList();
448 1
        $aclRoles = [];
449
        
450
        // Add everyone role
451 1
        $aclRoles['everyone'] = new Role('everyone', 'Everyone');
452
        
453
        // Add guest role if no roles was detected
454 1
        if (count($roleList) === 0) {
455 1
            $aclRoles['guest'] = new Role('guest', 'Guest');
456
        }
457
        
458
        // Add roles from databases
459 1
        foreach ($roleList as $role) {
460
            if ($role) {
461
                $aclRoles[$role->getIndex()] ??= new Role($role->getIndex());
462
            }
463
        }
464
        
465
        // Add console role
466 1
        if ($this->bootstrap->isCli()) {
0 ignored issues
show
Bug Best Practice introduced by
The property bootstrap does not exist on Zemit\Identity. Since you implemented __get, consider adding a @property annotation.
Loading history...
467
            $aclRoles['cli'] = new Role('cli', 'Cli');
468
        }
469
        
470 1
        return array_filter(array_values(array_unique($aclRoles)));
471
    }
472
    
473
    /**
474
     * Login as User
475
     */
476
    public function loginAs(?array $params = []): array
477
    {
478
        $session = $this->getSession();
479
        
480
        // Validation
481
        $validation = new Validation();
482
        $validation->add('userId', new PresenceOf(['message' => 'required']));
483
        $validation->add('userId', new Numericality(['message' => 'not-numeric']));
484
        $validation->validate($params);
485
    
486
        $saved = false;
487
        
488
        if ($session) {
489
            $userId = $session->getUserId();
490
            if (!empty($userId) && !empty($params['userId'])) {
491
                if ((int)$params['userId'] === (int)$userId) {
492
                    return $this->logoutAs();
493
                }
494
        
495
                $asUser = $this->findUserById((int)$params['userId']);
496
                if ($asUser) {
497
                    if ($this->hasRole(['admin', 'dev'])) {
498
                        $session->setAsUserId($userId);
499
                        $session->setUserId($params['userId']);
500
                    }
501
                }
502
                else {
503
                    $validation->appendMessage(new Message('User Not Found', 'userId', 'PresenceOf', 404));
504
                }
505
            }
506
    
507
            $saved = $session->save();
508
            foreach ($session->getMessages() as $message) {
509
                $validation->appendMessage($message);
510
            }
511
        }
512
        
513
        return [
514
            'saved' => $saved,
515
            'messages' => $validation->getMessages(),
516
            'loggedIn' => $this->isLoggedIn(false, true),
517
            'loggedInAs' => $this->isLoggedIn(true, true),
518
        ];
519
    }
520
    
521
    /**
522
     * Log off User (As)
523
     */
524
    public function logoutAs(): array
525
    {
526
        $session = $this->getSession();
527
        
528
        if ($session) {
529
            $asUserId = $session->getAsUserId();
530
            $userId = $session->getUserId();
531
            if (!empty($asUserId) && !empty($userId)) {
532
                $session->setUserId($asUserId);
533
                $session->setAsUserId(null);
534
            }
535
        }
536
        
537
        return [
538
            'saved' => $session && $session->save(),
539
            'messages' => $session && $session->getMessages(),
540
            'loggedIn' => $this->isLoggedIn(false, true),
541
            'loggedInAs' => $this->isLoggedIn(true, true),
542
        ];
543
    }
544
    
545
    /**
546
     *
547
     */
548
    public function oauth2(string $provider, int $id, string $accessToken, ?array $meta = [])
549
    {
550
        $loggedInUser = null;
551
        
552
        // retrieve and prepare oauth2 entity
553
        $oauth2 = Oauth2::findFirst([
0 ignored issues
show
Bug introduced by
The type Zemit\Oauth2 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...
554
            'provider = :provider: and id = :id:',
555
            'bind' => [
556
                'provider' => $this->filter->sanitize($provider, 'string'),
557
                'id' => (int)$id,
558
            ],
559
            'bindTypes' => [
560
                'provider' => Column::BIND_PARAM_STR,
561
                'id' => Column::BIND_PARAM_INT,
562
            ],
563
        ]);
564
        if (!$oauth2) {
565
            $oauth2 = new Oauth2();
566
            $oauth2->setProviderName($provider);
567
            $oauth2->setProviderId($id);
568
        }
569
        $oauth2->setAccessToken($accessToken);
570
        $oauth2->setMeta($meta);
571
        $oauth2->setName($meta['name'] ?? null);
572
        $oauth2->setFirstName($meta['first_name'] ?? null);
573
        $oauth2->setLastName($meta['last_name'] ?? null);
574
        $oauth2->setEmail($meta['email'] ?? null);
575
        
576
        // get the current session
577
        $session = $this->getSession();
578
        
579
        // link the current user to the oauth2 entity
580
        $oauth2UserId = $oauth2->getUserId();
581
        $sessionUserId = $session->getUserId();
582
        if (empty($oauth2UserId) && !empty($sessionUserId)) {
583
            $oauth2->setUserId($sessionUserId);
584
        }
585
        
586
        // prepare validation
587
        $validation = new Validation();
588
        
589
        // save the oauth2 entity
590
        $saved = $oauth2->save();
591
        
592
        // append oauth2 error messages
593
        foreach ($oauth2->getMessages() as $message) {
594
            $validation->appendMessage($message);
595
        }
596
        
597
        // a session is required
598
        if (!$session) {
599
            $validation->appendMessage(new Message('A session is required', 'session', 'PresenceOf', 403));
600
        }
601
        
602
        // user id is required
603
        $validation->add('userId', new PresenceOf(['message' => 'userId is required']));
604
        $validation->validate($oauth2 ? $oauth2->toArray() : []);
605
        
606
        // All validation passed
607
        if ($saved && !$validation->getMessages()->count()) {
608
            $user = $this->findUserById($oauth2->getUserId());
609
            
610
            // user not found, login failed
611
            if (!$user) {
612
                $validation->appendMessage(new Message('Login Failed', ['id'], 'LoginFailed', 401));
613
            }
614
            
615
            // access forbidden, login failed
616
            elseif ($user->isDeleted()) {
0 ignored issues
show
Bug introduced by
The method isDeleted() does not exist on Phalcon\Mvc\ModelInterface. It seems like you code against a sub-type of Phalcon\Mvc\ModelInterface such as Phalcon\Mvc\Model or Zemit\Models\Setting or Zemit\Models\Category or Zemit\Models\Audit or Zemit\Models\UserGroup or Zemit\Models\User or Zemit\Models\Field or Zemit\Models\Page or Zemit\Models\Log or Zemit\Models\File or Zemit\Models\Role or Zemit\Models\GroupRole or Zemit\Models\Template or Zemit\Models\AuditDetail or Zemit\Models\UserType or Zemit\Models\PhalconMigrations or Zemit\Models\Post or Zemit\Models\PostCategory or Zemit\Models\Session or Zemit\Models\TranslateField or Zemit\Models\Table or Zemit\Models\GroupType or Zemit\Models\Translate or Zemit\Models\WorkspaceLang or Zemit\Models\Email or Zemit\Models\Workspace or Zemit\Models\Data or Zemit\Models\Group or Zemit\Models\Lang or Zemit\Models\EmailFile or Zemit\Models\TranslateTable or Zemit\Models\UserRole or Zemit\Models\Flag or Zemit\Models\Menu or Zemit\Models\Type or Zemit\Models\Meta. ( Ignorable by Annotation )

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

616
            elseif ($user->/** @scrutinizer ignore-call */ isDeleted()) {
Loading history...
617
                $validation->appendMessage(new Message('Login Forbidden', 'password', 'LoginForbidden', 403));
618
            }
619
            
620
            // login success
621
            else {
622
                $loggedInUser = $user;
623
            }
624
            
625
            // Set the oauth user id into the session
626
            $session->setUserId($loggedInUser ? $loggedInUser->getId() : null);
627
            $saved = $session->save();
628
            
629
            // append session error messages
630
            foreach ($session->getMessages() as $message) {
631
                $validation->appendMessage($message);
632
            }
633
        }
634
        
635
        return [
636
            'saved' => $saved,
637
            'loggedIn' => $this->isLoggedIn(false, true),
638
            'loggedInAs' => $this->isLoggedIn(true, true),
639
            'messages' => $validation->getMessages(),
640
        ];
641
    }
642
    
643
    /**
644
     * Login request
645
     * Requires an active session to bind the logged in userId
646
     */
647
    public function login(array $params = null): array
648
    {
649
        $loggedInUser = null;
650
        $saved = null;
651
        $session = $this->getSession();
652
        $validation = new Validation();
653
        $validation->add('email', new PresenceOf(['message' => 'email is required']));
654
        $validation->add('password', new PresenceOf(['message' => 'password is required']));
655
        $validation->validate($params);
656
        if (!$session) {
657
            $validation->appendMessage(new Message('A session is required', 'session', 'PresenceOf', 403));
658
        }
659
        
660
        $messages = $validation->getMessages();
661
        if (!$messages->count()) {
662
            $user = $this->findUser($params['email'] ?? $params['username']);
663
            
664
            $loginFailedMessage = new Message('Login Failed', ['email', 'password'], 'LoginFailed', 401);
665
            $loginForbiddenMessage = new Message('Login Forbidden', ['email', 'password'], 'LoginForbidden', 403);
666
            
667
            if (!$user) {
668
                // user not found, login failed
669
                $validation->appendMessage($loginFailedMessage);
670
            }
671
            elseif (empty($user->getPassword())) {
0 ignored issues
show
Bug introduced by
The method getPassword() does not exist on Phalcon\Mvc\ModelInterface. It seems like you code against a sub-type of Phalcon\Mvc\ModelInterface such as Phalcon\Mvc\Model or Zemit\Models\Setting or Zemit\Models\Category or Zemit\Models\Audit or Zemit\Models\UserGroup or Zemit\Models\User or Zemit\Models\Field or Zemit\Models\Page or Zemit\Models\Log or Zemit\Models\File or Zemit\Models\Role or Zemit\Models\GroupRole or Zemit\Models\Template or Zemit\Models\AuditDetail or Zemit\Models\UserType or Zemit\Models\PhalconMigrations or Zemit\Models\Post or Zemit\Models\PostCategory or Zemit\Models\Session or Zemit\Models\TranslateField or Zemit\Models\Table or Zemit\Models\GroupType or Zemit\Models\Translate or Zemit\Models\WorkspaceLang or Zemit\Models\Email or Zemit\Models\Workspace or Zemit\Models\Data or Zemit\Models\Group or Zemit\Models\Lang or Zemit\Models\EmailFile or Zemit\Models\TranslateTable or Zemit\Models\UserRole or Zemit\Models\Flag or Zemit\Models\Menu or Zemit\Models\Type or Zemit\Models\Meta. ( Ignorable by Annotation )

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

671
            elseif (empty($user->/** @scrutinizer ignore-call */ getPassword())) {
Loading history...
672
                // password disabled, login failed
673
                $validation->appendMessage($loginFailedMessage);
674
            }
675
            elseif (!$user->checkPassword($params['password'])) {
0 ignored issues
show
Bug introduced by
The method checkPassword() does not exist on Phalcon\Mvc\ModelInterface. It seems like you code against a sub-type of Phalcon\Mvc\ModelInterface such as Phalcon\Mvc\Model or Zemit\Models\Setting or Zemit\Models\Category or Zemit\Models\Audit or Zemit\Models\UserGroup or Zemit\Models\User or Zemit\Models\Field or Zemit\Models\Page or Zemit\Models\Log or Zemit\Models\File or Zemit\Models\Role or Zemit\Models\GroupRole or Zemit\Models\Template or Zemit\Models\AuditDetail or Zemit\Models\UserType or Zemit\Models\PhalconMigrations or Zemit\Models\Post or Zemit\Models\PostCategory or Zemit\Models\Session or Zemit\Models\TranslateField or Zemit\Models\Table or Zemit\Models\GroupType or Zemit\Models\Translate or Zemit\Models\WorkspaceLang or Zemit\Models\Email or Zemit\Models\Workspace or Zemit\Models\Data or Zemit\Models\Group or Zemit\Models\Lang or Zemit\Models\EmailFile or Zemit\Models\TranslateTable or Zemit\Models\UserRole or Zemit\Models\Flag or Zemit\Models\Menu or Zemit\Models\Type or Zemit\Models\Meta. ( Ignorable by Annotation )

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

675
            elseif (!$user->/** @scrutinizer ignore-call */ checkPassword($params['password'])) {
Loading history...
676
                // password failed, login failed
677
                $validation->appendMessage($loginFailedMessage);
678
            }
679
            elseif ($user->isDeleted()) {
680
                // password match, user is deleted login forbidden
681
                $validation->appendMessage($loginForbiddenMessage);
682
            }
683
            
684
            // login success
685
            else {
686
                $loggedInUser = $user;
687
            }
688
            
689
            $session->setUserId($loggedInUser ? $loggedInUser->getId() : null);
690
            $saved = $session->save();
691
            foreach ($session->getMessages() as $message) {
692
                $validation->appendMessage($message);
693
            }
694
        }
695
        
696
        return [
697
            'saved' => $saved,
698
            'loggedIn' => $this->isLoggedIn(false, true),
699
            'loggedInAs' => $this->isLoggedIn(true, true),
700
            'messages' => $validation->getMessages(),
701
        ];
702
    }
703
    
704
    /**
705
     * Log the user out from the database session
706
     *
707
     * @return bool|mixed|null
708
     */
709
    public function logout()
710
    {
711
        $saved = false;
712
        $sessionEntity = $this->getSession();
713
        $validation = new Validation();
714
        $validation->validate();
715
        if (!$sessionEntity) {
716
            $validation->appendMessage(new Message('A session is required', 'session', 'PresenceOf', 403));
717
        }
718
        else {
719
            // Logout
720
            $sessionEntity->setUserId(null);
721
            $sessionEntity->setAsUserId(null);
722
            $saved = $sessionEntity->save();
723
            foreach ($sessionEntity->getMessages() as $message) {
724
                $validation->appendMessage($message);
725
            }
726
        }
727
        
728
        return [
729
            'saved' => $saved,
730
            'loggedIn' => $this->isLoggedIn(false, true),
731
            'loggedInAs' => $this->isLoggedIn(true, true),
732
            'messages' => $validation->getMessages(),
733
        ];
734
    }
735
    
736
    /**
737
     * @param array|null $params
738
     *
739
     * @return array
740
     */
741
    public function reset(array $params = null)
742
    {
743
        $saved = false;
744
        $sent = false;
745
        $session = $this->getSession();
746
        $validation = new Validation();
747
        $validation->add('email', new PresenceOf(['message' => 'email is required']));
748
        $validation->validate($params);
749
        if (!$session) {
750
            $validation->appendMessage(new Message('A session is required', 'session', 'PresenceOf', 403));
751
        }
752
        else {
753
            $user = false;
754
            if (isset($params['email'])) {
755
                $userClass = $this->getUserClass();
756
                $user = $userClass::findFirstByEmail($params['email']);
757
            }
758
            
759
            // Reset
760
            if ($user) {
761
                // Password reset request
762
                if (empty($params['token'])) {
763
                    
764
                    // Generate a new token
765
                    $token = $user->prepareToken();
766
                    
767
                    // Send it by email
768
                    $emailClass = $this->getEmailClass();
769
                    $email = new $emailClass();
770
                    $email->setViewPath('template/email');
771
                    $email->setTemplateByIndex('reset-password');
772
                    $email->setTo([$user->getEmail()]);
773
                    $meta = [];
774
                    $meta['user'] = $user->expose(['User' => [
775
                        false,
776
                        'firstName',
777
                        'lastName',
778
                        'email',
779
                    ]]);
780
                    $meta['resetLink'] = $this->url->get('/reset-password/' . $token);
781
                    $email->setMeta($meta);
782
                    $saved = $user->save();
783
                    $sent = $saved ? $email->send() : false;
784
                    
785
                    // Appending error messages
786
                    foreach (['user', 'email'] as $e) {
787
                        foreach ($$e->getMessages() as $message) {
788
                            $validation->appendMessage($message);
789
                        }
790
                    }
791
                }
792
                
793
                // Password reset
794
                else {
795
                    $validation->add('password', new PresenceOf(['message' => 'password is required']));
796
                    $validation->add('passwordConfirm', new PresenceOf(['message' => 'password confirm is required']));
797
                    $validation->add('password', new Confirmation(['message' => 'password does not match passwordConfirm', 'with' => 'passwordConfirm']));
798
                    $validation->validate($params);
799
                    if (!$user->checkToken($params['token'])) {
800
                        $validation->appendMessage(new Message('invalid token', 'token', 'NotValid', 400));
801
                    }
802
                    elseif (!count($validation->getMessages())) {
803
                        $params['token'] = null;
804
                        $user->assign($params, ['token', 'password', 'passwordConfirm']);
805
                        $saved = $user->save();
806
                    }
807
                }
808
                
809
                // Appending error messages
810
                foreach ($user->getMessages() as $message) {
811
                    $validation->appendMessage($message);
812
                }
813
            }
814
            else {
815
                // removed - OWASP Protect User Enumeration
816
//                $validation->appendMessage(new Message('User not found', 'user', 'PresenceOf', 404));
817
                $saved = true;
818
                $sent = true;
819
            }
820
        }
821
        
822
        return [
823
            'saved' => $saved,
824
            'sent' => $sent,
825
            'messages' => $validation->getMessages(),
826
        ];
827
    }
828
    
829
    /**
830
     * Get key / token fields to use for the session fetch & validation
831
     */
832 4
    public function getKeyToken(string $jwt = null, string $key = null, string $token = null): array
833
    {
834 4
        $basicAuth = $this->request->getBasicAuth();
835 4
        $authorization = array_filter(explode(' ', $this->request->getHeader($this->config->path('identity.authorizationHeader', 'Authorization')) ?: ''));
0 ignored issues
show
Bug Best Practice introduced by
The property config does not exist on Zemit\Identity. Since you implemented __get, consider adding a @property annotation.
Loading history...
836
        
837 4
        $json = $this->request->getJsonRawBody();
838 4
        $refreshToken = $this->request->get('refreshToken', 'string', $json->refreshToken ?? null);
839 4
        $jwt ??= $this->request->get('jwt', 'string', $json->jwt ?? null);
840 4
        $key ??= $this->request->get('key', 'string', $this->store['key'] ?? $json->key ?? null);
841 4
        $token ??= $this->request->get('token', 'string', $this->store['token'] ?? $json->token ?? null);
842
        
843 4
        if (empty($key) || empty($token)) {
844 4
            if (!empty($refreshToken)) {
845
                $sessionClaim = $this->getClaim($refreshToken, $this->sessionKey . '-refresh');
846
                $key = $sessionClaim['key'] ?? null;
847
                $token = $sessionClaim['token'] ?? null;
848
            }
849 4
            elseif (!empty($jwt)) {
850
                $sessionClaim = $this->getClaim($jwt, $this->sessionKey);
851
                $key = $sessionClaim['key'] ?? null;
852
                $token = $sessionClaim['token'] ?? null;
853
            }
854 4
            elseif (!empty($basicAuth)) {
855
                $key = $basicAuth['username'] ?? null;
856
                $token = $basicAuth['password'] ?? null;
857
            }
858 4
            elseif (!empty($authorization)) {
859
                $authorizationType = $authorization[0] ?? 'Bearer';
860
                $authorizationToken = $authorization[1] ?? null;
861
                if (strtolower($authorizationType) === 'bearer') {
862
                    $sessionClaim = $this->getClaim($authorizationToken, $this->sessionKey);
0 ignored issues
show
Bug introduced by
It seems like $authorizationToken can also be of type null; however, parameter $token of Zemit\Identity::getClaim() does only seem to accept string, 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

862
                    $sessionClaim = $this->getClaim(/** @scrutinizer ignore-type */ $authorizationToken, $this->sessionKey);
Loading history...
863
                    $key = $sessionClaim['key'] ?? null;
864
                    $token = $sessionClaim['token'] ?? null;
865
                }
866
            }
867 4
            elseif ($this->config->path('identity.sessionFallback', false) &&
868 4
                $this->session->has($this->sessionKey)
869
            ) {
870
                $sessionStore = $this->session->get($this->sessionKey);
871
                $key = $sessionStore['key'] ?? null;
872
                $token = $sessionStore['token'] ?? null;
873
            }
874
        }
875
        
876 4
        return [$key, $token];
877
    }
878
    
879
    /**
880
     * Return the session by key if the token is valid
881
     */
882 4
    public function getSession(?string $key = null, ?string $token = null, bool $refresh = false): ?SessionInterface
883
    {
884 4
        if (!isset($key, $token)) {
885 4
            [$key, $token] = $this->getKeyToken();
886
        }
887
        
888 4
        if (empty($key) || empty($token)) {
889 4
            return null;
890
        }
891
        
892
        if ($refresh) {
893
            $this->currentSession = null;
894
        }
895
        
896
        if (isset($this->currentSession)) {
897
            return $this->currentSession;
898
        }
899
        
900
        $sessionClass = $this->getSessionClass();
901
        $sessionEntity = $sessionClass::findFirstByKey($this->filter->sanitize($key, 'string'));
902
        if ($sessionEntity && $sessionEntity->checkHash($sessionEntity->getToken(), $key . $token)) {
903
            $this->currentSession = $sessionEntity;
904
        }
905
        
906
        return $this->currentSession;
907
    }
908
    
909
    /**
910
     * @param string $token
911
     * @param string|null $claim
912
     * @return array
913
     * @throws ValidatorException
914
     */
915
    public function getClaim(string $token, string $claim = null): array
916
    {
917
        $uri = $this->request->getScheme() . '://' . $this->request->getHttpHost();
918
        
919
        $token = $this->jwt->parseToken($token);
0 ignored issues
show
Bug Best Practice introduced by
The property jwt does not exist on Zemit\Identity. Since you implemented __get, consider adding a @property annotation.
Loading history...
920
        
921
        $this->jwt->validateToken($token, 0, [
922
            'issuer' => $uri,
923
            'audience' => $uri,
924
            'id' => $claim,
925
        ]);
926
        $claims = $token->getClaims();
927
        
928
        $ret = $claims->has('sub') ? json_decode($claims->get('sub'), true) : [];
929
        return is_array($ret) ? $ret : [];
930
    }
931
    
932
    /**
933
     * Generate a new JWT Token (string)
934
     * @throws ValidatorException
935
     */
936
    public function getJwtToken(string $id, array $data = [], array $options = []): string
937
    {
938
        $uri = $this->request->getScheme() . '://' . $this->request->getHttpHost();
939
        
940
        $options['issuer'] ??= $uri;
941
        $options['audience'] ??= $uri;
942
        $options['id'] ??= $id;
943
        $options['subject'] ??= json_encode($data);
944
        
945
        $builder = $this->jwt->builder($options);
0 ignored issues
show
Bug Best Practice introduced by
The property jwt does not exist on Zemit\Identity. Since you implemented __get, consider adding a @property annotation.
Loading history...
946
        return $builder->getToken()->getToken();
947
    }
948
    
949
    /**
950
     * Get the User from the database using the ID
951
     */
952
    public function findUserById(int $id): ?ModelInterface
953
    {
954
        /** @var User $userClass */
955
        $userClass = $this->getUserClass();
956
        return $userClass::findFirst([
957
            'id = :id:',
958
            'bind' => ['id' => $id],
959
            'bindTypes' => ['id' => Column::BIND_PARAM_INT],
960
        ]) ?: null;
961
    }
962
    
963
    /**
964
     * Get the user from the database using the username or email
965
     */
966
    public function findUser(string $string): ?ModelInterface
967
    {
968
        /** @var User $userClass */
969
        $userClass = $this->getUserClass();
970
        return $userClass::findFirst([
971
            'email = :email: or username = :username:',
972
            'bind' => [
973
                'email' => $string,
974
                'username' => $string,
975
            ],
976
            'bindTypes' => [
977
                'email' => Column::BIND_PARAM_STR,
978
                'username' => Column::BIND_PARAM_STR,
979
            ],
980
        ]) ?: null;
981
    }
982
}
983