Passed
Push — master ( 3a5a77...814750 )
by Julien
05:03
created

Identity::getKeyTokenFromAuthorization()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

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

143
        $session->setToken($session->/** @scrutinizer ignore-call */ hash($key . $newToken));
Loading history...
144
        $session->setDate(date('Y-m-d H:i:s'));
145
        $saved = $session->save();
146
        
147
        // temporary store the new key token pair
148
        $this->store = ['key' => $session->getKey(), 'token' => $newToken];
149
        
150
        if ($saved && $this->config->path('identity.sessionFallback', false)) {
151
            // store key & token into the session
152
            $this->session->set($this->sessionKey, $this->store);
153
        }
154
        else {
155
            // delete the session
156
            $this->session->remove($this->sessionKey);
157
        }
158
        
159
        // jwt token
160
        $tokenOptions = $this->getConfig()->pathToArray('identity.token') ?? [];
161
        $token = $this->getJwtToken($this->sessionKey, $this->store, $tokenOptions);
162
        
163
        // refresh jwt token
164
        $refreshTokenOptions = $this->getConfig()->pathToArray('identity.refreshToken') ?? [];
165
        $refreshToken = $this->getJwtToken($this->sessionKey . '-refresh', $this->store, $refreshTokenOptions);
166
        
167
        return [
168
            'saved' => $saved,
169
            'hasSession' => $this->session->has($this->sessionKey),
170
            'refreshed' => $saved && $refresh,
171
            '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

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

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

627
            $session->setUserId($loggedInUser ? $loggedInUser->/** @scrutinizer ignore-call */ getId() : null);
Loading history...
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) {
0 ignored issues
show
introduced by
$user is of type Zemit\Mvc\Model, thus it always evaluated to true.
Loading history...
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 Zemit\Mvc\Model. Since you implemented __call, consider adding a @method annotation. ( 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 Zemit\Mvc\Model. Since you implemented __call, consider adding a @method annotation. ( 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
        $session = $this->getSession();
747
        $validation = new Validation();
748
        $validation->add('email', new PresenceOf(['message' => 'email is required']));
749
        $validation->validate($params);
750
        if (!$session) {
751
            $validation->appendMessage(new Message('A session is required', 'session', 'PresenceOf', 403));
752
        }
753
        else {
754
            $user = false;
755
            if (isset($params['email'])) {
756
                $userClass = $this->getUserClass();
757
                $user = $userClass::findFirstByEmail($params['email']);
758
            }
759
            
760
            // Reset
761
            if ($user) {
762
                // Password reset request
763
                if (empty($params['token'])) {
764
                    
765
                    // Generate a new token
766
                    $token = $user->prepareToken();
767
                    
768
                    // Send it by email
769
                    $emailClass = $this->getEmailClass();
770
                    $email = new $emailClass();
771
                    $email->setViewPath('template/email');
772
                    $email->setTemplateByIndex('reset-password');
773
                    $email->setTo([$user->getEmail()]);
774
                    $meta = [];
775
                    $meta['user'] = $user->expose(['User' => [
776
                        false,
777
                        'firstName',
778
                        'lastName',
779
                        'email',
780
                    ]]);
781
                    $meta['resetLink'] = $this->url->get('/reset-password/' . $token);
782
                    $email->setMeta($meta);
783
                    $saved = $user->save();
784
                    $sent = $saved ? $email->send() : false;
785
                    
786
                    // Appending error messages
787
                    foreach (['user', 'email'] as $e) {
788
                        foreach ($$e->getMessages() as $message) {
789
                            $validation->appendMessage($message);
790
                        }
791
                    }
792
                }
793
                
794
                // Password reset
795
                else {
796
                    $validation->add('password', new PresenceOf(['message' => 'password is required']));
797
                    $validation->add('passwordConfirm', new PresenceOf(['message' => 'password confirm is required']));
798
                    $validation->add('password', new Confirmation(['message' => 'password does not match passwordConfirm', 'with' => 'passwordConfirm']));
799
                    $validation->validate($params);
800
                    if (!$user->checkToken($params['token'])) {
801
                        $validation->appendMessage(new Message('invalid token', 'token', 'NotValid', 400));
802
                    }
803
                    elseif (!count($validation->getMessages())) {
804
                        $params['token'] = null;
805
                        $user->assign($params, ['token', 'password', 'passwordConfirm']);
806
                        $saved = $user->save();
807
                    }
808
                }
809
                
810
                // Appending error messages
811
                foreach ($user->getMessages() as $message) {
812
                    $validation->appendMessage($message);
813
                }
814
            }
815
            else {
816
                // removed - OWASP Protect User Enumeration
817
//                $validation->appendMessage(new Message('User not found', 'user', 'PresenceOf', 404));
818
                $saved = true;
819
                $sent = true;
820
            }
821
        }
822
        
823
        return [
824
            'saved' => $saved,
825
            'sent' => $sent,
826
            'messages' => $validation->getMessages(),
827
        ];
828
    }
829
    
830
    /**
831
     * Retrieve the key and token from various authorization sources
832
     *
833
     * @param string|null $jwt The JWT token
834
     * @param string|null $key The key
835
     * @param string|null $token The token
836
     * @return array An array containing the key and token
837
     * @throws ValidatorException
838
     */
839 2
    public function getKeyToken(string $jwt = null, string $key = null, string $token = null): array
840
    {
841 2
        $json = $this->request->getJsonRawBody();
842 2
        $refreshToken = $this->request->get('refreshToken', 'string', $json->refreshToken ?? null);
843 2
        $jwt ??= $this->request->get('jwt', 'string', $json->jwt ?? null);
844 2
        $key ??= $this->request->get('key', 'string', $this->store['key'] ?? $json->key ?? null);
845 2
        $token ??= $this->request->get('token', 'string', $this->store['token'] ?? $json->token ?? null);
846
        
847
        // Using provided key & token
848 2
        if (isset($key, $token)) {
849
            return [$key, $token];
850
        }
851
        
852
        // Using refresh token
853 2
        if (!empty($refreshToken)) {
854
            return $this->getKeyTokenFromClaimToken($refreshToken, $this->sessionKey . '-refresh');
855
        }
856
        
857
        // Using JWT
858 2
        if (!empty($jwt)) {
859
            return $this->getKeyTokenFromClaimToken($jwt, $this->sessionKey);
860
        }
861
        
862
        // Using Basic Auth from HTTP request
863 2
        $basicAuth = $this->request->getBasicAuth();
864 2
        if (!empty($basicAuth)) {
865
            return [
866
                $basicAuth['username'] ?? null,
867
                $basicAuth['password'] ?? null,
868
            ];
869
        }
870
        
871
        // Using X-Authorization Header
872 2
        $authorizationHeaderKey = $this->config->path('identity.authorizationHeader', 'Authorization');
873 2
        $authorizationHeaderValue = $this->request->getHeader($authorizationHeaderKey);
874 2
        $authorization = array_filter(explode(' ', $authorizationHeaderValue));
875 2
        if (!empty($authorization)) {
876
            return $this->getKeyTokenFromAuthorization($authorization);
877
        }
878
        
879
        // Using Session Fallback
880 2
        $sessionFallback = $this->config->path('identity.sessionFallback', false);
881 2
        if ($sessionFallback && $this->session->has($this->sessionKey)) {
882
            $sessionStore = $this->session->get($this->sessionKey);
883
            return [
884
                $sessionStore['key'] ?? null,
885
                $sessionStore['token'] ?? null,
886
            ];
887
        }
888
        
889
        // Unsupported authorization method
890 2
        return [null, null];
891
    }
892
    
893
    /**
894
     * Get key and token from authorization
895
     * @param array $authorization The authorization array, where the first element is the authorization type and the second element is the authorization token
896
     * @return array The key and token extracted from the authorization session claim. If the key or token is not found, null will be returned for that value.
897
     * @throws ValidatorException
898
     */
899
    public function getKeyTokenFromAuthorization(array $authorization): array
900
    {
901
        $authorizationType = $authorization[0] ?? null;
902
        $authorizationToken = $authorization[1] ?? null;
903
        
904
        if ($authorizationToken && strtolower($authorizationType) === 'bearer') {
0 ignored issues
show
Bug introduced by
It seems like $authorizationType can also be of type null; however, parameter $string of strtolower() 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

904
        if ($authorizationToken && strtolower(/** @scrutinizer ignore-type */ $authorizationType) === 'bearer') {
Loading history...
905
            return $this->getKeyTokenFromClaimToken($authorizationToken, $this->sessionKey);
906
        }
907
        
908
        return [null, null];
909
    }
910
    
911
    /**
912
     * Get the key and token from the claim token
913
     *
914
     * @param string $claimToken The claim token
915
     * @param string $sessionKey The session key
916
     * @return array The key and token, [key, token]
917
     * @throws ValidatorException
918
     */
919
    public function getKeyTokenFromClaimToken(string $claimToken, string $sessionKey): array
920
    {
921
        $sessionClaim = $this->getClaim($claimToken, $sessionKey);
922
        $key = $sessionClaim['key'] ?? null;
923
        $token = $sessionClaim['token'] ?? null;
924
        return [$key, $token];
925
    }
926
    
927
    /**
928
     * Return the session by key if the token is valid
929
     */
930 2
    public function getSession(?string $key = null, ?string $token = null, bool $refresh = false): ?SessionInterface
931
    {
932 2
        if (!isset($key, $token)) {
933 2
            [$key, $token] = $this->getKeyToken();
934
        }
935
        
936 2
        if (empty($key) || empty($token)) {
937 2
            return null;
938
        }
939
        
940
        if ($refresh) {
941
            $this->currentSession = null;
942
        }
943
        
944
        if (isset($this->currentSession)) {
945
            return $this->currentSession;
946
        }
947
        
948
        $sessionClass = $this->getSessionClass();
949
        $sessionEntity = $sessionClass::findFirstByKey($this->filter->sanitize($key, 'string'));
950
        if ($sessionEntity && $sessionEntity->checkHash($sessionEntity->getToken(), $key . $token)) {
951
            $this->currentSession = $sessionEntity;
952
        }
953
        
954
        return $this->currentSession;
955
    }
956
    
957
    /**
958
     * @param string $token
959
     * @param string|null $claim
960
     * @return array
961
     * @throws ValidatorException
962
     */
963
    public function getClaim(string $token, string $claim = null): array
964
    {
965
        $uri = $this->request->getScheme() . '://' . $this->request->getHttpHost();
966
        
967
        $token = $this->jwt->parseToken($token);
968
        
969
        $this->jwt->validateToken($token, 0, [
970
            'issuer' => $uri,
971
            'audience' => $uri,
972
            'id' => $claim,
973
        ]);
974
        $claims = $token->getClaims();
975
        
976
        $ret = $claims->has('sub') ? json_decode($claims->get('sub'), true) : [];
977
        return is_array($ret) ? $ret : [];
978
    }
979
    
980
    /**
981
     * Generate a new JWT Token (string)
982
     * @throws ValidatorException
983
     */
984
    public function getJwtToken(string $id, array $data = [], array $options = []): string
985
    {
986
        $uri = $this->request->getScheme() . '://' . $this->request->getHttpHost();
987
        
988
        $options['issuer'] ??= $uri;
989
        $options['audience'] ??= $uri;
990
        $options['id'] ??= $id;
991
        $options['subject'] ??= json_encode($data);
992
        
993
        $builder = $this->jwt->builder($options);
994
        return $builder->getToken()->getToken();
995
    }
996
    
997
    /**
998
     * Get the User from the database using the ID
999
     */
1000
    public function findUserById(int $id): ?Model
1001
    {
1002
        /** @var User $userClass */
1003
        $userClass = $this->getUserClass();
1004
        $user = $userClass::findFirst([
1005
            'id = :id:',
1006
            'bind' => ['id' => $id],
1007
            'bindTypes' => ['id' => Column::BIND_PARAM_INT],
1008
        ]);
1009
        if ($user) {
1010
            assert($user instanceof Model);
1011
            assert($user instanceof UserInterface);
1012
        }
1013
        return $user;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $user could return the type Phalcon\Mvc\ModelInterface which includes types incompatible with the type-hinted return Zemit\Mvc\Model|null. Consider adding an additional type-check to rule them out.
Loading history...
1014
    }
1015
    
1016
    /**
1017
     * Get the user from the database using the username or email
1018
     */
1019
    public function findUser(string $string): ?Model
1020
    {
1021
        /** @var User $userClass */
1022
        $userClass = $this->getUserClass();
1023
        $user = $userClass::findFirst([
1024
            'email = :email: or username = :username:',
1025
            'bind' => [
1026
                'email' => $string,
1027
                'username' => $string,
1028
            ],
1029
            'bindTypes' => [
1030
                'email' => Column::BIND_PARAM_STR,
1031
                'username' => Column::BIND_PARAM_STR,
1032
            ],
1033
        ]);
1034
        if ($user) {
1035
            assert($user instanceof Model);
1036
            assert($user instanceof UserInterface);
1037
        }
1038
        return $user;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $user could return the type Phalcon\Mvc\ModelInterface which includes types incompatible with the type-hinted return Zemit\Mvc\Model|null. Consider adding an additional type-check to rule them out.
Loading history...
1039
    }
1040
}
1041