Test Failed
Push — master ( b8a2f6...8ea2e0 )
by Julien
07:34 queued 03:10
created

Identity::hasRole()   A

Complexity

Conditions 2
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 1
nc 1
nop 3
dl 0
loc 3
ccs 0
cts 0
cp 0
crap 6
rs 10
c 1
b 0
f 0
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\Messages\Message;
17
use Phalcon\Mvc\ModelInterface;
18
use Phalcon\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...
19
use Phalcon\Security\JWT\Exceptions\ValidatorException;
20
use Phalcon\Validation\Validator\Confirmation;
21
use Phalcon\Validation\Validator\Numericality;
22
use Phalcon\Validation\Validator\PresenceOf;
23
use Zemit\Di\Injectable;
24
use Zemit\Models\Interfaces\RoleInterface;
25
use Zemit\Models\Interfaces\SessionInterface;
26
use Zemit\Models\Interfaces\UserInterface;
27
use Zemit\Models\User;
28
use Zemit\Support\ModelsMap;
29
use Zemit\Support\Options\Options;
30
use Zemit\Support\Options\OptionsInterface;
31
use Zemit\Mvc\Model\Behavior\Security as SecurityBehavior;
32
33
/**
34
 * Identity Management
35
 */
36
class Identity extends Injectable implements OptionsInterface
37
{
38
    use Options;
39
    use ModelsMap;
40
    
41
    public string $sessionKey;
42
    
43
    public array $store = [];
44
    
45
    public ?UserInterface $user;
46
    
47
    public ?UserInterface $userAs;
48
    
49
    public ?SessionInterface $currentSession = null;
50
    
51
    /**
52
     * Forces some options
53
     */
54
    public function initialize(): void
55
    {
56
        $this->sessionKey = $this->getOption('sessionKey') ?? $this->sessionKey;
57
        $this->modelsMap = $this->getOption('modelsMap') ?? $this->modelsMap;
58
    }
59
    
60
    /**
61
     * Check whether the current identity has roles
62
     */
63
    public function hasRole(?array $roles = null, bool $or = false, bool $inherit = true): bool
64
    {
65
        return $this->has($roles, array_keys($this->getRoleList($inherit) ?: []), $or);
66
    }
67
    
68
    /**
69
     * Get the User ID
70
     */
71
    public function getUserId(bool $as = false): ?int
72
    {
73
        $user = $this->getUser($as);
74
        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...
75
    }
76
    
77
    /**
78
     * Get the User (As) ID
79
     */
80
    public function getUserAsId(): ?int
81
    {
82
        return $this->getUserId(true);
83
    }
84
    
85
    /**
86
     * Check if the needles meet the haystack using nested arrays
87
     * Reversing ANDs and ORs within each nested subarray
88
     *
89
     * $this->has(['dev', 'admin'], $this->getUser()->getRoles(), true); // 'dev' OR 'admin'
90
     * $this->has(['dev', 'admin'], $this->getUser()->getRoles(), false); // 'dev' AND 'admin'
91
     *
92
     * $this->has(['dev', 'admin'], $this->getUser()->getRoles()); // 'dev' AND 'admin'
93
     * $this->has([['dev', 'admin']], $this->getUser()->getRoles()); // 'dev' OR 'admin'
94
     * $this->has([[['dev', 'admin']]], $this->getUser()->getRoles()); // 'dev' AND 'admin'
95
     *
96
     * @param array|string|null $needles Needles to match and meet the rules
97
     * @param array $haystack Haystack array to search into
98
     * @param bool $or True to force with "OR" , false to force "AND" condition
99
     *
100
     * @return bool Return true or false if the needles rules are being met
101
     */
102
    public function has($needles = null, array $haystack = [], bool $or = false)
103
    {
104
        if (!is_array($needles)) {
105
            $needles = [$needles];
106
        }
107
        
108
        $result = [];
109
        foreach ([...$needles] as $needle) {
110
            if (is_array($needle)) {
111
                $result [] = $this->has($needle, $haystack, !$or);
112
            }
113
            else {
114
                $result [] = in_array($needle, $haystack, true);
115
            }
116
        }
117
        
118
        return $or ?
119
            !in_array(false, $result, true) :
120
            in_array(true, $result, true);
121
    }
122
    
123
    /**
124
     * Create or refresh a session
125
     * @throws Exception|ValidatorException
126
     */
127
    public function getJwt(bool $refresh = false): array
128
    {
129
        [$key, $token] = $this->getKeyToken();
130
        
131
        // generate new key & token pair if not set
132
        $key ??= $this->security->getRandom()->uuid();
133
        $token ??= $this->security->getRandom()->hex(512);
134
        
135
        // generate a new token if a refresh is requested
136
        $newToken = $refresh ? $this->security->getRandom()->hex(512) : $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);
0 ignored issues
show
Bug introduced by
The method setKey() 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

141
        $session->/** @scrutinizer ignore-call */ 
142
                  setKey($key);
Loading history...
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...
Bug introduced by
The method setToken() 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->/** @scrutinizer ignore-call */ 
143
                  setToken($session->hash($key . $newToken));
Loading history...
143
        $session->setDate(date('Y-m-d H:i:s'));
0 ignored issues
show
Bug introduced by
The method setDate() 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

143
        $session->/** @scrutinizer ignore-call */ 
144
                  setDate(date('Y-m-d H:i:s'));
Loading history...
144
        $saved = $session->save();
145
        
146
        // temporary store the new key token pair
147
        $this->store = ['key' => $session->getKey(), 'token' => $newToken];
0 ignored issues
show
Bug introduced by
The method getKey() 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

147
        $this->store = ['key' => $session->/** @scrutinizer ignore-call */ getKey(), 'token' => $newToken];
Loading history...
148
        
149
        if ($saved && $this->config->path('identity.sessionFallback', false)) {
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...
Bug introduced by
The method getToken() 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->checkHash($session->/** @scrutinizer ignore-call */ 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
    public function getIdentity(bool $inherit = true): array
182
    {
183
        $user = $this->getUser();
184
        $userAs = $this->getUserAs();
185
        
186
        $roleList = [];
187
        $groupList = [];
188
        $typeList = [];
189
        
190
        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
        if ($inherit) {
227
            $roleIndexList = [];
228
            foreach ($roleList as $role) {
229
                $roleIndexList [] = $role->getIndex();
230
            }
231
            
232
            $inheritedRoleIndexList = $this->getInheritedRoleList($roleIndexList);
233
            if (!empty($inheritedRoleIndexList)) {
234
                
235
                /** @var RoleInterface $roleClass */
236
                SecurityBehavior::staticStart();
0 ignored issues
show
Bug introduced by
The method staticStart() does not exist on Zemit\Mvc\Model\Behavior\Security. ( Ignorable by Annotation )

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

236
                SecurityBehavior::/** @scrutinizer ignore-call */ 
237
                                  staticStart();

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...
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();
0 ignored issues
show
Bug introduced by
The method staticStop() does not exist on Zemit\Mvc\Model\Behavior\Security. ( Ignorable by Annotation )

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

243
                SecurityBehavior::/** @scrutinizer ignore-call */ 
244
                                  staticStop();

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...
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)) {
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
        return [
277
            'loggedIn' => $this->isLoggedIn(),
278
            'loggedInAs' => $this->isLoggedInAs(),
279
            'user' => $user,
280
            'userAs' => $userAs,
281
            'roleList' => $roleList,
282
            'typeList' => $typeList,
283
            'groupList' => $groupList,
284
        ];
285
    }
286
    
287
    /**
288
     * Return the list of inherited role list (recursively)
289
     */
290
    public function getInheritedRoleList(array $roleIndexList = []): array
291
    {
292
        $inheritedRoleList = [];
293
        $processedRoleIndexList = [];
294
        
295
        // While we still have role index list to process
296
        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);
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
        return array_values(array_filter(array_unique($inheritedRoleList)));
322
    }
323
    
324
    /**
325
     * Return true if the user is currently logged in
326
     */
327
    public function isLoggedIn(bool $as = false, bool $force = false): bool
328
    {
329
        return !!$this->getUser($as, $force);
330
    }
331
    
332
    /**
333
     * Return true if the user is currently logged in
334
     */
335
    public function isLoggedInAs(bool $force = false): bool
336
    {
337
        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
    public function getUser(bool $as = false, ?bool $force = null): ?UserInterface
347
    {
348
        // session required to fetch user
349
        $session = $this->getSession();
350
        if (!$session) {
351
            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()
0 ignored issues
show
Bug introduced by
The method getAsUserId() 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

361
                ? $session->/** @scrutinizer ignore-call */ getAsUserId()
Loading history...
362
                : $session->getUserId();
0 ignored issues
show
Bug introduced by
The method getUserId() 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

362
                : $session->/** @scrutinizer ignore-call */ getUserId();
Loading history...
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
    public function getUserAs(): ?UserInterface
394
    {
395
        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
    public function getRoleList(bool $inherit = true): array
418
    {
419
        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
    public function getAclRoles(?array $roleList = null): array
446
    {
447
        $roleList ??= $this->getRoleList();
448
        $aclRoles = [];
449
        
450
        // Add everyone role
451
        $aclRoles['everyone'] = new Role('everyone', 'Everyone');
452
        
453
        // Add guest role if no roles was detected
454
        if (count($roleList) === 0) {
455
            $aclRoles['guest'] = new Role('guest', 'Guest');
456
        }
457
        
458
        // Add roles from databases
459
        foreach ($roleList as $role) {
460
            if ($role) {
461
                $aclRoles[$role->getIndex()] ??= new Role($role->getIndex());
462
            }
463
        }
464
        
465
        // Add console role
466
        if ($this->bootstrap->isCli()) {
467
            $aclRoles['cli'] = new Role('cli', 'Cli');
468
        }
469
        
470
        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);
0 ignored issues
show
Bug introduced by
The method setAsUserId() 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

498
                        $session->/** @scrutinizer ignore-call */ 
499
                                  setAsUserId($userId);
Loading history...
499
                        $session->setUserId($params['userId']);
0 ignored issues
show
Bug introduced by
The method setUserId() 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

499
                        $session->/** @scrutinizer ignore-call */ 
500
                                  setUserId($params['userId']);
Loading history...
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
        $saved = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $saved is dead and can be removed.
Loading history...
552
        
553
        // retrieve and prepare oauth2 entity
554
        $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...
555
            'provider = :provider: and id = :id:',
556
            'bind' => [
557
                'provider' => $this->filter->sanitize($provider, 'string'),
558
                'id' => (int)$id,
559
            ],
560
            'bindTypes' => [
561
                'provider' => Column::BIND_PARAM_STR,
562
                'id' => Column::BIND_PARAM_INT,
563
            ],
564
        ]);
565
        if (!$oauth2) {
566
            $oauth2 = new Oauth2();
567
            $oauth2->setProviderName($provider);
568
            $oauth2->setProviderId($id);
569
        }
570
        $oauth2->setAccessToken($accessToken);
571
        $oauth2->setMeta($meta);
572
        $oauth2->setName($meta['name'] ?? null);
573
        $oauth2->setFirstName($meta['first_name'] ?? null);
574
        $oauth2->setLastName($meta['last_name'] ?? null);
575
        $oauth2->setEmail($meta['email'] ?? null);
576
        
577
        // get the current session
578
        $session = $this->getSession();
579
        
580
        // link the current user to the oauth2 entity
581
        $oauth2UserId = $oauth2->getUserId();
582
        $sessionUserId = $session->getUserId();
583
        if (empty($oauth2UserId) && !empty($sessionUserId)) {
584
            $oauth2->setUserId($sessionUserId);
585
        }
586
        
587
        // prepare validation
588
        $validation = new Validation();
589
        
590
        // save the oauth2 entity
591
        $saved = $oauth2->save();
592
        
593
        // append oauth2 error messages
594
        foreach ($oauth2->getMessages() as $message) {
595
            $validation->appendMessage($message);
596
        }
597
        
598
        // a session is required
599
        if (!$session) {
600
            $validation->appendMessage(new Message('A session is required', 'session', 'PresenceOf', 403));
601
        }
602
        
603
        // user id is required
604
        $validation->add('userId', new PresenceOf(['message' => 'userId is required']));
605
        $validation->validate($oauth2 ? $oauth2->toArray() : []);
606
        
607
        // All validation passed
608
        if ($saved && !$validation->getMessages()->count()) {
609
            $user = $this->findUserById($oauth2->getUserId());
610
            
611
            // user not found, login failed
612
            if (!$user) {
613
                $validation->appendMessage(new Message('Login Failed', ['id'], 'LoginFailed', 401));
614
            }
615
            
616
            // access forbidden, login failed
617
            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\Post or Zemit\Models\PostCategory or Zemit\Models\Session or Zemit\Models\TranslateField or Zemit\Models\GroupType or Zemit\Models\Translate or Zemit\Models\Email or Zemit\Models\Data or Zemit\Models\Group or Zemit\Models\Lang or Zemit\Models\EmailFile or Zemit\Models\TranslateTable or Zemit\Models\SiteLang or Zemit\Models\UserRole or Zemit\Models\Flag or Zemit\Models\Menu or Zemit\Models\Site or Zemit\Models\Type or Zemit\Models\Channel 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

617
            elseif ($user->/** @scrutinizer ignore-call */ isDeleted()) {
Loading history...
618
                $validation->appendMessage(new Message('Login Forbidden', 'password', 'LoginForbidden', 403));
619
            }
620
            
621
            // login success
622
            else {
623
                $loggedInUser = $user;
624
            }
625
            
626
            // Set the oauth user id into the session
627
            $session->setUserId($loggedInUser ? $loggedInUser->getId() : null);
628
            $saved = $session->save();
629
            
630
            // append session error messages
631
            foreach ($session->getMessages() as $message) {
632
                $validation->appendMessage($message);
633
            }
634
        }
635
        
636
        return [
637
            'saved' => $saved,
638
            'loggedIn' => $this->isLoggedIn(false, true),
639
            'loggedInAs' => $this->isLoggedIn(true, true),
640
            'messages' => $validation->getMessages(),
641
        ];
642
    }
643
    
644
    /**
645
     * Login request
646
     * Requires an active session to bind the logged in userId
647
     */
648
    public function login(array $params = null): array
649
    {
650
        $loggedInUser = null;
651
        $saved = null;
652
        $session = $this->getSession();
653
        $validation = new Validation();
654
        $validation->add('email', new PresenceOf(['message' => 'email is required']));
655
        $validation->add('password', new PresenceOf(['message' => 'password is required']));
656
        $validation->validate($params);
657
        if (!$session) {
658
            $validation->appendMessage(new Message('A session is required', 'session', 'PresenceOf', 403));
659
        }
660
        
661
        $messages = $validation->getMessages();
662
        if (!$messages->count()) {
663
            $user = $this->findUser($params['email'] ?? $params['username']);
664
            
665
            $loginFailedMessage = new Message('Login Failed', ['email', 'password'], 'LoginFailed', 401);
666
            $loginForbiddenMessage = new Message('Login Forbidden', ['email', 'password'], 'LoginForbidden', 403);
667
            
668
            if (!$user) {
669
                // user not found, login failed
670
                $validation->appendMessage($loginFailedMessage);
671
            }
672
            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\Post or Zemit\Models\PostCategory or Zemit\Models\Session or Zemit\Models\TranslateField or Zemit\Models\GroupType or Zemit\Models\Translate or Zemit\Models\Email or Zemit\Models\Data or Zemit\Models\Group or Zemit\Models\Lang or Zemit\Models\EmailFile or Zemit\Models\TranslateTable or Zemit\Models\SiteLang or Zemit\Models\UserRole or Zemit\Models\Flag or Zemit\Models\Menu or Zemit\Models\Site or Zemit\Models\Type or Zemit\Models\Channel 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

672
            elseif (empty($user->/** @scrutinizer ignore-call */ getPassword())) {
Loading history...
673
                // password disabled, login failed
674
                $validation->appendMessage($loginFailedMessage);
675
            }
676
            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\Post or Zemit\Models\PostCategory or Zemit\Models\Session or Zemit\Models\TranslateField or Zemit\Models\GroupType or Zemit\Models\Translate or Zemit\Models\Email or Zemit\Models\Data or Zemit\Models\Group or Zemit\Models\Lang or Zemit\Models\EmailFile or Zemit\Models\TranslateTable or Zemit\Models\SiteLang or Zemit\Models\UserRole or Zemit\Models\Flag or Zemit\Models\Menu or Zemit\Models\Site or Zemit\Models\Type or Zemit\Models\Channel 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

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

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