Passed
Push — master ( 509678...44cd48 )
by Julien
04:51
created

Identity::has()   A

Complexity

Conditions 5
Paths 12

Size

Total Lines 19
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 30

Importance

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

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

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

628
            $session->setUserId($loggedInUser ? $loggedInUser->/** @scrutinizer ignore-call */ getId() : null);
Loading history...
629
            $saved = $session->save();
630
            
631
            // append session error messages
632
            foreach ($session->getMessages() as $message) {
633
                $validation->appendMessage($message);
634
            }
635
        }
636
        
637
        return [
638
            'saved' => $saved,
639
            'loggedIn' => $this->isLoggedIn(false, true),
640
            'loggedInAs' => $this->isLoggedIn(true, true),
641
            'messages' => $validation->getMessages(),
642
        ];
643
    }
644
    
645
    /**
646
     * Login request
647
     * Requires an active session to bind the logged in userId
648
     */
649
    public function login(array $params = null): array
650
    {
651
        $loggedInUser = null;
652
        $saved = null;
653
        $session = $this->getSession();
654
        $validation = new Validation();
655
        $validation->add('email', new PresenceOf(['message' => 'email is required']));
656
        $validation->add('password', new PresenceOf(['message' => 'password is required']));
657
        $validation->validate($params);
658
        if (!$session) {
659
            $validation->appendMessage(new Message('A session is required', 'session', 'PresenceOf', 403));
660
        }
661
        
662
        $messages = $validation->getMessages();
663
        if (!$messages->count()) {
664
            $user = $this->findUser($params['email'] ?? $params['username']);
665
            
666
            $loginFailedMessage = new Message('Login Failed', ['email', 'password'], 'LoginFailed', 401);
667
            $loginForbiddenMessage = new Message('Login Forbidden', ['email', 'password'], 'LoginForbidden', 403);
668
            
669
            if (!$user) {
0 ignored issues
show
introduced by
$user is of type Zemit\Mvc\Model, thus it always evaluated to true.
Loading history...
670
                // user not found, login failed
671
                $validation->appendMessage($loginFailedMessage);
672
            }
673
            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

673
            elseif (empty($user->/** @scrutinizer ignore-call */ getPassword())) {
Loading history...
674
                // password disabled, login failed
675
                $validation->appendMessage($loginFailedMessage);
676
            }
677
            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

677
            elseif (!$user->/** @scrutinizer ignore-call */ checkPassword($params['password'])) {
Loading history...
678
                // password failed, login failed
679
                $validation->appendMessage($loginFailedMessage);
680
            }
681
            elseif ($user->isDeleted()) {
682
                // password match, user is deleted login forbidden
683
                $validation->appendMessage($loginForbiddenMessage);
684
            }
685
            
686
            // login success
687
            else {
688
                $loggedInUser = $user;
689
            }
690
            
691
            $session->setUserId($loggedInUser ? $loggedInUser->getId() : null);
692
            $saved = $session->save();
693
            foreach ($session->getMessages() as $message) {
694
                $validation->appendMessage($message);
695
            }
696
        }
697
        
698
        return [
699
            'saved' => $saved,
700
            'loggedIn' => $this->isLoggedIn(false, true),
701
            'loggedInAs' => $this->isLoggedIn(true, true),
702
            'messages' => $validation->getMessages(),
703
        ];
704
    }
705
    
706
    /**
707
     * Log the user out from the database session
708
     *
709
     * @return bool|mixed|null
710
     */
711
    public function logout()
712
    {
713
        $saved = false;
714
        $sessionEntity = $this->getSession();
715
        $validation = new Validation();
716
        $validation->validate();
717
        if (!$sessionEntity) {
718
            $validation->appendMessage(new Message('A session is required', 'session', 'PresenceOf', 403));
719
        }
720
        else {
721
            // Logout
722
            $sessionEntity->setUserId(null);
723
            $sessionEntity->setAsUserId(null);
724
            $saved = $sessionEntity->save();
725
            foreach ($sessionEntity->getMessages() as $message) {
726
                $validation->appendMessage($message);
727
            }
728
        }
729
        
730
        return [
731
            'saved' => $saved,
732
            'loggedIn' => $this->isLoggedIn(false, true),
733
            'loggedInAs' => $this->isLoggedIn(true, true),
734
            'messages' => $validation->getMessages(),
735
        ];
736
    }
737
    
738
    /**
739
     * @param array|null $params
740
     *
741
     * @return array
742
     */
743
    public function reset(array $params = null)
744
    {
745
        $saved = false;
746
        $sent = false;
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
     * Retrieve the key and token from various authorization sources
833
     *
834
     * @param string|null $jwt The JWT token
835
     * @param string|null $key The key
836
     * @param string|null $token The token
837
     * @return array An array containing the key and token
838
     * @throws ValidatorException
839
     */
840 2
    public function getKeyToken(string $jwt = null, string $key = null, string $token = null): array
841
    {
842 2
        $json = $this->request->getJsonRawBody();
843 2
        $refreshToken = $this->request->get('refreshToken', 'string', $json->refreshToken ?? null);
844 2
        $jwt ??= $this->request->get('jwt', 'string', $json->jwt ?? null);
845 2
        $key ??= $this->request->get('key', 'string', $this->store['key'] ?? $json->key ?? null);
846 2
        $token ??= $this->request->get('token', 'string', $this->store['token'] ?? $json->token ?? null);
847
        
848
        // Using provided key & token
849 2
        if (isset($key, $token)) {
850
            return [$key, $token];
851
        }
852
        
853
        // Using refresh token
854 2
        if (!empty($refreshToken)) {
855
            return $this->getKeyTokenFromClaimToken($refreshToken, $this->sessionKey . '-refresh');
856
        }
857
        
858
        // Using JWT
859 2
        if (!empty($jwt)) {
860
            return $this->getKeyTokenFromClaimToken($jwt, $this->sessionKey);
861
        }
862
        
863
        // Using Basic Auth from HTTP request
864 2
        $basicAuth = $this->request->getBasicAuth();
865 2
        if (!empty($basicAuth)) {
866
            return [
867
                $basicAuth['username'] ?? null,
868
                $basicAuth['password'] ?? null,
869
            ];
870
        }
871
        
872
        // Using X-Authorization Header
873 2
        $authorizationHeaderKey = $this->config->path('identity.authorizationHeader', 'Authorization');
874 2
        $authorizationHeaderValue = $this->request->getHeader($authorizationHeaderKey);
875 2
        $authorization = array_filter(explode(' ', $authorizationHeaderValue));
876 2
        if (!empty($authorization)) {
877
            return $this->getKeyTokenFromAuthorization($authorization);
878
        }
879
        
880
        // Using Session Fallback
881 2
        $sessionFallback = $this->config->path('identity.sessionFallback', false);
882 2
        if ($sessionFallback && $this->session->has($this->sessionKey)) {
883
            $sessionStore = $this->session->get($this->sessionKey);
884
            return [
885
                $sessionStore['key'] ?? null,
886
                $sessionStore['token'] ?? null,
887
            ];
888
        }
889
        
890
        // Unsupported authorization method
891 2
        return [null, null];
892
    }
893
    
894
    /**
895
     * Get key and token from authorization
896
     * @param array $authorization The authorization array, where the first element is the authorization type and the second element is the authorization token
897
     * @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.
898
     * @throws ValidatorException
899
     */
900
    public function getKeyTokenFromAuthorization(array $authorization): array
901
    {
902
        $authorizationType = $authorization[0] ?? null;
903
        $authorizationToken = $authorization[1] ?? null;
904
        
905
        if ($authorizationType && $authorizationToken && strtolower($authorizationType) === 'bearer') {
906
            return $this->getKeyTokenFromClaimToken($authorizationToken, $this->sessionKey);
907
        }
908
        
909
        return [null, null];
910
    }
911
    
912
    /**
913
     * Get the key and token from the claim token
914
     *
915
     * @param string $claimToken The claim token
916
     * @param string $sessionKey The session key
917
     * @return array The key and token, [key, token]
918
     * @throws ValidatorException
919
     */
920
    public function getKeyTokenFromClaimToken(string $claimToken, string $sessionKey): array
921
    {
922
        $sessionClaim = $this->getClaim($claimToken, $sessionKey);
923
        $key = $sessionClaim['key'] ?? null;
924
        $token = $sessionClaim['token'] ?? null;
925
        return [$key, $token];
926
    }
927
    
928
    /**
929
     * Return the session by key if the token is valid
930
     */
931 2
    public function getSession(?string $key = null, ?string $token = null, bool $refresh = false): ?SessionInterface
932
    {
933 2
        if (!isset($key, $token)) {
934 2
            [$key, $token] = $this->getKeyToken();
935
        }
936
        
937 2
        if (empty($key) || empty($token)) {
938 2
            return null;
939
        }
940
        
941
        if ($refresh) {
942
            $this->currentSession = null;
943
        }
944
        
945
        if (isset($this->currentSession)) {
946
            return $this->currentSession;
947
        }
948
        
949
        $sessionClass = $this->getSessionClass();
950
        $sessionEntity = $sessionClass::findFirstByKey($this->filter->sanitize($key, 'string'));
951
        if ($sessionEntity && $sessionEntity->checkHash($sessionEntity->getToken(), $key . $token)) {
952
            $this->currentSession = $sessionEntity;
953
        }
954
        
955
        return $this->currentSession;
956
    }
957
    
958
    /**
959
     * Return the session ID if available, otherwise return null
960
     *
961
     * @return int|null
962
     */
963
    public function getSessionId(): ?int
964
    {
965
        $session = $this->getSession();
966
        return $session instanceof EntityInterface? $session->readAttribute('id') : null;
967
    }
968
    
969
    /**
970
     * @param string $token
971
     * @param string|null $claim
972
     * @return array
973
     * @throws ValidatorException
974
     */
975
    public function getClaim(string $token, string $claim = null): array
976
    {
977
        $uri = $this->request->getScheme() . '://' . $this->request->getHttpHost();
978
        
979
        $token = $this->jwt->parseToken($token);
980
        
981
        $this->jwt->validateToken($token, 0, [
982
            'issuer' => $uri,
983
            'audience' => $uri,
984
            'id' => $claim,
985
        ]);
986
        $claims = $token->getClaims();
987
        
988
        $ret = $claims->has('sub') ? json_decode($claims->get('sub'), true) : [];
989
        return is_array($ret) ? $ret : [];
990
    }
991
    
992
    /**
993
     * Generate a new JWT Token (string)
994
     * @throws ValidatorException
995
     */
996
    public function getJwtToken(string $id, array $data = [], array $options = []): string
997
    {
998
        $uri = $this->request->getScheme() . '://' . $this->request->getHttpHost();
999
        
1000
        $options['issuer'] ??= $uri;
1001
        $options['audience'] ??= $uri;
1002
        $options['id'] ??= $id;
1003
        $options['subject'] ??= json_encode($data);
1004
        
1005
        $builder = $this->jwt->builder($options);
1006
        return $builder->getToken()->getToken();
1007
    }
1008
    
1009
    /**
1010
     * Get the User from the database using the ID
1011
     */
1012
    public function findUserById(int $id): ?Model
1013
    {
1014
        /** @var User $userClass */
1015
        $userClass = $this->getUserClass();
1016
        $user = $userClass::findFirst([
1017
            'id = :id:',
1018
            'bind' => ['id' => $id],
1019
            'bindTypes' => ['id' => Column::BIND_PARAM_INT],
1020
        ]);
1021
        if ($user) {
1022
            assert($user instanceof Model);
1023
            assert($user instanceof UserInterface);
1024
        }
1025
        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...
1026
    }
1027
    
1028
    /**
1029
     * Get the user from the database using the username or email
1030
     */
1031
    public function findUser(string $string): ?Model
1032
    {
1033
        /** @var User $userClass */
1034
        $userClass = $this->getUserClass();
1035
        $user = $userClass::findFirst([
1036
            'email = :email: or username = :username:',
1037
            'bind' => [
1038
                'email' => $string,
1039
                'username' => $string,
1040
            ],
1041
            'bindTypes' => [
1042
                'email' => Column::BIND_PARAM_STR,
1043
                'username' => Column::BIND_PARAM_STR,
1044
            ],
1045
        ]);
1046
        if ($user) {
1047
            assert($user instanceof Model);
1048
            assert($user instanceof UserInterface);
1049
        }
1050
        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...
1051
    }
1052
}
1053