Test Failed
Push — master ( 4d9b91...c71310 )
by Julien
06:31
created

Identity::getSession()   A

Complexity

Conditions 6
Paths 10

Size

Total Lines 26
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 14.3902

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 6
eloc 12
nc 10
nop 3
dl 0
loc 26
ccs 5
cts 13
cp 0.3846
crap 14.3902
rs 9.2222
c 1
b 0
f 0
1
<?php
2
3
/**
4
 * This file is part of the Zemit Framework.
5
 *
6
 * (c) Zemit Team <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE.txt
9
 * file that was distributed with this source code.
10
 */
11
12
namespace Zemit;
13
14
use Phalcon\Acl\Role;
15
use Phalcon\Db\Column;
16
use Phalcon\Encryption\Security\JWT\Exceptions\ValidatorException;
17
use Phalcon\Filter\Validation\Validator\Confirmation;
18
use Phalcon\Filter\Validation\Validator\Numericality;
19
use Phalcon\Filter\Validation\Validator\PresenceOf;
20
use Phalcon\Messages\Message;
21
use Phalcon\Mvc\EntityInterface;
22
use Phalcon\Support\Helper\Str\Random;
23
use Zemit\Di\Injectable;
24
use Zemit\Filter\Validation;
25
use Zemit\Models\Interfaces\RoleInterface;
26
use Zemit\Models\Interfaces\SessionInterface;
27
use Zemit\Models\Interfaces\UserInterface;
28
use Zemit\Models\Oauth2;
29
use Zemit\Models\User;
30
use Zemit\Mvc\Model\Behavior\Security as SecurityBehavior;
31
use Zemit\Support\ModelsMap;
32
use Zemit\Support\Options\Options;
33
use Zemit\Support\Options\OptionsInterface;
34
35
/**
36
 * Identity Management
37
 */
38
class Identity extends Injectable implements OptionsInterface
39
{
40
    use Options;
41
    use ModelsMap;
42
    
43
    public string $sessionKey;
44
    
45
    public array $store = [];
46
    
47
    public ?UserInterface $user;
48
    
49
    public ?UserInterface $userAs;
50
    
51
    public ?SessionInterface $currentSession = null;
52
    
53
    /**
54
     * Forces some options
55
     */
56 7
    public function initialize(): void
57
    {
58 7
        $this->sessionKey = $this->getOption('sessionKey') ?? $this->sessionKey;
59 7
        $this->setModelsMap($this->getOption('modelsMap'));
60
    }
61
    
62
    /**
63
     * Check whether the current identity has roles
64
     */
65
    public function hasRole(?array $roles = null, bool $or = false, bool $inherit = true): bool
66
    {
67
        return $this->has($roles, array_keys($this->getRoleList($inherit) ?: []), $or);
68
    }
69
    
70
    /**
71
     * Get the User ID
72
     */
73 1
    public function getUserId(bool $as = false): ?int
74
    {
75 1
        $user = $this->getUser($as);
76 1
        return isset($user)? (int)$user->getId() : null;
77
    }
78
    
79
    /**
80
     * Get the User (As) ID
81
     */
82 1
    public function getUserAsId(): ?int
83
    {
84 1
        return $this->getUserId(true);
85
    }
86
    
87
    /**
88
     * Check if the needles meet the haystack using nested arrays
89
     * Reversing ANDs and ORs within each nested subarray
90
     *
91
     * $this->has(['dev', 'admin'], $this->getUser()->getRoles(), true); // 'dev' OR 'admin'
92
     * $this->has(['dev', 'admin'], $this->getUser()->getRoles(), false); // 'dev' AND 'admin'
93
     *
94
     * $this->has(['dev', 'admin'], $this->getUser()->getRoles()); // 'dev' AND 'admin'
95
     * $this->has([['dev', 'admin']], $this->getUser()->getRoles()); // 'dev' OR 'admin'
96
     * $this->has([[['dev', 'admin']]], $this->getUser()->getRoles()); // 'dev' AND 'admin'
97
     *
98
     * @param array|string|null $needles Needles to match and meet the rules
99
     * @param array $haystack Haystack array to search into
100
     * @param bool $or True to force with "OR" , false to force "AND" condition
101
     *
102
     * @return bool Return true or false if the needles rules are being met
103
     */
104
    public function has(array|string|null $needles = null, array $haystack = [], bool $or = false): bool
105
    {
106
        if (!is_array($needles)) {
107
            $needles = isset($needles)? [$needles] : [];
108
        }
109
        
110
        $result = [];
111
        foreach ($needles as $needle) {
112
            if (is_array($needle)) {
113
                $result [] = $this->has($needle, $haystack, !$or);
114
            } else {
115
                $result [] = in_array($needle, $haystack, true);
116
            }
117
        }
118
        
119
        return $or ?
120
            !in_array(false, $result, true) :
121
            in_array(true, $result, true);
122
    }
123
    
124
    /**
125
     * Create or refresh a session
126
     * @throws ValidatorException|\Phalcon\Encryption\Security\Exception
127
     */
128
    public function getJwt(bool $refresh = false): array
129
    {
130
        [$key, $token] = $this->getKeyToken();
131
        
132
        // generate new key & token pair if not set
133
        $key ??= $this->security->getRandom()->uuid();
134
        $token ??= $this->helper->random(Random::RANDOM_ALNUM, rand(111, 222));
135
        $newToken = $refresh ? $this->helper->random(Random::RANDOM_ALNUM, rand(111, 222)) : $token;
136
        
137
        $adapter = $this->config->path('identity.adapter') ?: 'database';
0 ignored issues
show
Unused Code introduced by
The assignment to $adapter is dead and can be removed.
Loading history...
138
//        if ($adapter === 'database') {
139
            // retrieve or create a new session
140
            $sessionClass = $this->getSessionClass();
141
            $session = $this->getSession($key, $token) ?? new $sessionClass();
142
            assert($session instanceof SessionInterface);
143
            
144
            // save the key token into the store (database or session)
145
            $session->setKey($key);
146
            $session->setToken($session->hash($key . $newToken));
147
            $session->setDate(date('Y-m-d H:i:s'));
148
            $saved = $session->save();
149
//        }
150
//        else if ($adapter === 'session') {
151
//            $this->session->get($key);
152
//            $this->session->set($key, $newToken);
153
//        }
154
        
155
        // temporary store the new key token pair
156
        $this->store = ['key' => $session->getKey(), 'token' => $newToken];
157
        
158
        if ($saved && $this->config->path('identity.sessionFallback', false)) {
159
            // store key & token into the session
160
            $this->session->set($this->sessionKey, $this->store);
161
        }
162
        else {
163
            // delete the session
164
            $this->session->remove($this->sessionKey);
165
        }
166
        
167
        // jwt token
168
        $tokenOptions = $this->getConfig()->pathToArray('identity.token') ?? [];
169
        $token = $this->getJwtToken($this->sessionKey, $this->store, $tokenOptions);
170
        
171
        // refresh jwt token
172
        $refreshTokenOptions = $this->getConfig()->pathToArray('identity.refreshToken') ?? [];
173
        $refreshToken = $this->getJwtToken($this->sessionKey . '-refresh', $this->store, $refreshTokenOptions);
174
        
175
        return [
176
            'saved' => $saved,
177
            'hasSession' => $this->session->has($this->sessionKey),
178
            'refreshed' => $saved && $refresh,
179
            'validated' => $session->checkHash($session->getToken(), $session->getKey() . $newToken),
180
            'messages' => $session->getMessages(),
181
            'jwt' => $token,
182
            'refreshToken' => $refreshToken,
183
        ];
184
    }
185
    
186
    /**
187
     * Get basic Identity information
188
     * @throws \Exception
189
     */
190 1
    public function getIdentity(bool $inherit = true): array
191
    {
192 1
        $user = $this->getUser();
193 1
        $userAs = $this->getUserAs();
194
        
195 1
        $roleList = [];
196 1
        $groupList = [];
197 1
        $typeList = [];
198
        
199 1
        if (isset($user)) {
200
            if (!empty($user->rolelist)) {
201
                foreach ($user->rolelist as $role) {
202
                    $roleList [$role->getIndex()] = $role;
203
                }
204
            }
205
            
206
            if (!empty($user->grouplist)) {
207
                foreach ($user->grouplist as $group) {
208
                    $groupList [$group->getIndex()] = $group;
209
                    if (!empty($group->rolelist)) {
210
                        foreach ($group->rolelist as $role) {
211
                            $roleList [$role->getIndex()] = $role;
212
                        }
213
                    }
214
                }
215
            }
216
            
217
            if (!empty($user->typelist)) {
218
                foreach ($user->typelist as $type) {
219
                    $typeList [$type->getIndex()] = $type;
220
                    if (!empty($type->grouplist)) {
221
                        foreach ($type->grouplist as $group) {
222
                            $groupList [$group->getIndex()] = $group;
223
                            if (!empty($group->rolelist)) {
224
                                foreach ($group->rolelist as $role) {
225
                                    $roleList [$role->getIndex()] = $role;
226
                                }
227
                            }
228
                        }
229
                    }
230
                }
231
            }
232
        }
233
        
234
        // Append inherit roles
235 1
        if ($inherit) {
236 1
            $roleIndexList = [];
237 1
            foreach ($roleList as $role) {
238
                $roleIndexList [] = $role->getIndex();
239
            }
240
            
241 1
            $inheritedRoleIndexList = $this->getInheritedRoleList($roleIndexList);
242 1
            if (!empty($inheritedRoleIndexList)) {
243
                
244
                SecurityBehavior::staticStart();
245
                $inheritedRoleList = $this->models->getRole()::find([
246
                    'index in ({role:array})',
247
                    'bind' => ['role' => $inheritedRoleIndexList],
248
                    'bindTypes' => ['role' => Column::BIND_PARAM_STR],
249
                ]);
250
                SecurityBehavior::staticStop();
251
                
252
                assert(is_iterable($inheritedRoleList));
253
                foreach ($inheritedRoleList as $inheritedRoleEntity) {
254
                    assert($inheritedRoleEntity instanceof RoleInterface);
255
                    $inheritedRoleIndex = $inheritedRoleEntity->getIndex();
256
                    $roleList[$inheritedRoleIndex] = $inheritedRoleEntity;
257
                    
258
                    if (($key = array_search($inheritedRoleIndex, $inheritedRoleIndexList)) !== false) {
259
                        unset($inheritedRoleIndexList[$key]);
260
                    }
261
                }
262
                
263
                // unable to find some roles by index
264
                if (!empty($inheritedRoleIndexList)) {
265
                    
266
                    // To avoid breaking stuff in production, create a new role if it doesn't exist
267
                    if (!$this->config->path('app.debug', false)) {
268
                        $roleClass = $this->getRoleClass();
269
                        foreach ($inheritedRoleIndexList as $inheritedRoleIndex) {
270
                            $roleList[$inheritedRoleIndex] = new $roleClass();
271
                            $roleList[$inheritedRoleIndex]->setIndex($inheritedRoleIndex);
272
                            $roleList[$inheritedRoleIndex]->setLabel(ucfirst($inheritedRoleIndex));
273
                        }
274
                    }
275
                    
276
                    // throw an exception under development so it can be fixed
277
                    else {
278
                        throw new \Exception('Role `' . implode('`, `', $inheritedRoleIndexList) . '` not found using the class `' . $this->getRoleClass() . '`.', 404);
279
                    }
280
                }
281
            }
282
        }
283
        
284
        // We don't need userAs group / type / role list
285 1
        return [
286 1
            'loggedIn' => $this->isLoggedIn(),
287 1
            'loggedInAs' => $this->isLoggedInAs(),
288 1
            'user' => $user,
289 1
            'userAs' => $userAs,
290 1
            'roleList' => $roleList,
291 1
            'typeList' => $typeList,
292 1
            'groupList' => $groupList,
293 1
        ];
294
    }
295
    
296
    /**
297
     * Return the list of inherited role list (recursively)
298
     */
299 1
    public function getInheritedRoleList(array $roleIndexList = []): array
300
    {
301 1
        $inheritedRoleList = [];
302 1
        $processedRoleIndexList = [];
303
        
304
        // While we still have role index list to process
305 1
        while (!empty($roleIndexList)) {
306
            
307
            // Process role index list
308
            foreach ($roleIndexList as $roleIndex) {
309
                // Get inherited roles from config service
310
                
311
                $configRoleList = $this->config->path('permissions.roles.' . $roleIndex . '.inherit', false);
312
                
313
                if ($configRoleList) {
314
                    
315
                    // Append inherited role to process list
316
                    $roleList = $configRoleList->toArray();
317
                    $roleIndexList = array_merge($roleIndexList, $roleList);
318
                    $inheritedRoleList = array_merge($inheritedRoleList, $roleList);
319
                }
320
                
321
                // Add role index to processed list
322
                $processedRoleIndexList [] = $roleIndex;
323
            }
324
            
325
            // Keep the unprocessed role index list
326
            $roleIndexList = array_filter(array_unique(array_diff($roleIndexList, $processedRoleIndexList)));
327
        }
328
        
329
        // Return the list of inherited role list (recursively)
330 1
        return array_values(array_filter(array_unique($inheritedRoleList)));
331
    }
332
    
333
    /**
334
     * Return true if the user is currently logged in
335
     */
336 1
    public function isLoggedIn(bool $as = false, bool $force = false): bool
337
    {
338 1
        return !!$this->getUser($as, $force);
339
    }
340
    
341
    /**
342
     * Return true if the user is currently logged in
343
     */
344 1
    public function isLoggedInAs(bool $force = false): bool
345
    {
346 1
        return $this->isLoggedIn(true, $force);
347
    }
348
    
349
    /**
350
     * Return the user object based on the session
351
     *
352
     * @param bool $as Flag to indicate whether to get the user as another user
353
     * @param bool|null $force Flag to indicate whether to force the retrieval of the user object
354
     * 
355
     * @return UserInterface|null The user object or null if session is not available
356
     */
357 4
    public function getUser(bool $as = false, ?bool $force = null): ?UserInterface
358
    {
359
        // session required to fetch user
360 4
        $session = $this->getSession();
361 4
        if (!$session) {
362 4
            return null;
363
        }
364
        
365
        $force = $force
366
            || ($as && empty($this->userAs))
367
            || (!$as && empty($this->user));
368
        
369
        if ($force) {
370
            
371
            $userId = $as
372
                ? $session->getAsUserId()
373
                : $session->getUserId();
374
            
375
            $user = null;
376
            if (!empty($userId)) {
377
                SecurityBehavior::staticStart();
378
                
379
                $user = $this->models->getUser()::findFirstWith([
380
                    'RoleList',
381
                    'GroupList.RoleList',
382
                    'TypeList.GroupList.RoleList',
383
                ], [
384
                    'id = :id:',
385
                    'bind' => ['id' => (int)$userId],
386
                    'bindTypes' => ['id' => Column::BIND_PARAM_INT]
387
                ]);
388
                if ($user) {
389
                    assert($user instanceof UserInterface);
390
                }
391
                
392
                SecurityBehavior::staticStop();
393
            }
394
            
395
            $as
396
                ? $this->setUserAs($user)
397
                : $this->setUser($user);
398
            
399
            return $user instanceof UserInterface? $user : null;
400
        }
401
        
402
        return $as
403
            ? $this->userAs
404
            : $this->user;
405
    }
406
    
407
    /**
408
     * Get Identity User (As)
409
     */
410 1
    public function getUserAs(): ?UserInterface
411
    {
412 1
        return $this->getUser(true);
413
    }
414
    
415
    /**
416
     * Set Identity User
417
     */
418
    public function setUser(?UserInterface $user): void
419
    {
420
        $this->user = $user;
421
    }
422
    
423
    /**
424
     * Set Identity User (As)
425
     */
426
    public function setUserAs(?UserInterface $user): void
427
    {
428
        $this->userAs = $user;
429
    }
430
    
431
    /**
432
     * Get the "Roles" related to the current session
433
     */
434 1
    public function getRoleList(bool $inherit = true): array
435
    {
436 1
        return $this->getIdentity($inherit)['roleList'] ?? [];
437
    }
438
    
439
    /**
440
     * Get the "Groups" related to the current session
441
     */
442
    public function getGroupList(bool $inherit = true): array
443
    {
444
        return $this->getIdentity($inherit)['groupList'] ?? [];
445
    }
446
    
447
    /**
448
     * Get the "Types" related to the current session
449
     */
450
    public function getTypeList(bool $inherit = true): array
451
    {
452
        return $this->getIdentity($inherit)['typeList'] ?? [];
453
    }
454
    
455
    /**
456
     * Return the list of ACL roles
457
     * - Reserved roles: guest, cli, everyone
458
     *
459
     * @param array|null $roleList
460
     * @return array
461
     */
462 1
    public function getAclRoles(?array $roleList = null): array
463
    {
464 1
        $roleList ??= $this->getRoleList();
465 1
        $aclRoles = [];
466
        
467
        // Add everyone role
468 1
        $aclRoles['everyone'] = new Role('everyone', 'Everyone');
469
        
470
        // Add guest role if no roles was detected
471 1
        if (count($roleList) === 0) {
472 1
            $aclRoles['guest'] = new Role('guest', 'Guest');
473
        }
474
        
475
        // Add roles from databases
476 1
        foreach ($roleList as $role) {
477
            if ($role) {
478
                $aclRoles[$role->getIndex()] ??= new Role($role->getIndex());
479
            }
480
        }
481
        
482
        // Add console role
483 1
        if ($this->bootstrap->isCli()) {
484
            $aclRoles['cli'] = new Role('cli', 'Cli');
485
        }
486
        
487 1
        return array_filter(array_values(array_unique($aclRoles)));
488
    }
489
    
490
    /**
491
     * Login as User
492
     */
493
    public function loginAs(?array $params = []): array
494
    {
495
        $session = $this->getSession();
496
        
497
        // Validation
498
        $validation = new Validation();
499
        $validation->add('userId', new PresenceOf(['message' => 'required']));
500
        $validation->add('userId', new Numericality(['message' => 'not-numeric']));
501
        $validation->validate($params);
502
        $messages = $validation->getMessages();
503
    
504
        $saved = false;
505
        
506
        // must be an admin
507
        if (isset($session, $params['userId']) && !$messages->count() && $this->hasRole(['admin', 'dev'])) {
508
            $userId = $session->getUserId();
509
            
510
            // himself, return back to normal login
511
            if ((int)$params['userId'] === (int)$userId) {
512
                return $this->logoutAs();
513
            }
514
    
515
            // login as using id
516
            $asUser = $this->findUserById((int)$params['userId']);
517
            if (isset($asUser)) {
518
                $session->setAsUserId($userId);
519
                $session->setUserId($params['userId']);
520
            }
521
            else {
522
                $validation->appendMessage(new Message('User Not Found', 'userId', 'PresenceOf', 404));
523
            }
524
    
525
            $saved = $session->save();
526
            foreach ($session->getMessages() as $message) {
527
                $validation->appendMessage($message);
528
            }
529
        }
530
        
531
        return [
532
            'saved' => $saved,
533
            'messages' => $validation->getMessages(),
534
            'loggedIn' => $this->isLoggedIn(false, true),
535
            'loggedInAs' => $this->isLoggedIn(true, true),
536
        ];
537
    }
538
    
539
    /**
540
     * Log off User (As)
541
     */
542
    public function logoutAs(): array
543
    {
544
        $session = $this->getSession();
545
        
546
        if ($session) {
547
            $asUserId = $session->getAsUserId();
548
            $userId = $session->getUserId();
549
            if (!empty($asUserId) && !empty($userId)) {
550
                $session->setUserId($asUserId);
551
                $session->setAsUserId(null);
552
            }
553
        }
554
        
555
        return [
556
            'saved' => $session && $session->save(),
557
            'messages' => $session && $session->getMessages(),
558
            'loggedIn' => $this->isLoggedIn(false, true),
559
            'loggedInAs' => $this->isLoggedIn(true, true),
560
        ];
561
    }
562
    
563
    /**
564
     * OAuth2 authentication
565
     *
566
     * @param string $provider The OAuth2 provider
567
     * @param int $providerUuid The UUID associated with the provider
568
     * @param string $accessToken The access token provided by the provider
569
     * @param string|null $refreshToken The refresh token provided by the provider (optional)
570
     * @param array|null $meta Additional metadata associated with the user (optional)
571
     *
572
     * @return array Returns an array with the following keys:
573
     *   - 'saved': Indicates whether the OAuth2 entity was saved successfully
574
     *   - 'loggedIn': Indicates whether the user is currently logged in
575
     *   - 'loggedInAs': Indicates the user that is currently logged in
576
     *   - 'messages': An array of validation messages
577
     * 
578
     * @throws \Phalcon\Filter\Exception
579
     */
580
    public function oauth2(string $provider, int $providerUuid, string $accessToken, ?string $refreshToken = null, ?array $meta = []): array
581
    {
582
        $loggedInUser = null;
583
        $saved = false;
584
        
585
        // get the current session
586
        $session = $this->getSession();
587
        
588
        // prepare validation
589
        $validation = new Validation();
590
        
591
        // a session is required
592
        if (!isset($session)) {
593
            $validation->appendMessage(new Message('A session is required', 'session', 'PresenceOf', 403));
594
        }
595
        else {
596
            // retrieve and prepare oauth2 entity
597
            $oauth2 = Oauth2::findFirst([
598
                'provider = :provider: and provider_uuid = :providerUuid:',
599
                'bind' => [
600
                    'provider' => $this->filter->sanitize($provider, 'string'),
601
                    'providerUuid' => $providerUuid,
602
                ],
603
                'bindTypes' => [
604
                    'provider' => Column::BIND_PARAM_STR,
605
                    'id' => Column::BIND_PARAM_STR,
606
                ],
607
            ]);
608
            if (!$oauth2) {
609
                $oauth2 = new Oauth2();
610
                $oauth2->setProvider($provider);
611
                $oauth2->setProviderUuid($providerUuid);
612
            }
613
            $oauth2->setAccessToken($accessToken);
614
            $oauth2->setRefreshToken($refreshToken);
615
            $oauth2->setMeta(!empty($meta)? json_encode($meta) : null);
616
            $oauth2->setName($meta['name'] ?? null);
617
            $oauth2->setFirstName($meta['first_name'] ?? null);
618
            $oauth2->setLastName($meta['last_name'] ?? null);
619
            $oauth2->setEmail($meta['email'] ?? null);
620
            
621
            // link the current user to the oauth2 entity
622
            $oauth2UserId = $oauth2->getUserId();
623
            $sessionUserId = $session->getUserId();
624
            if (empty($oauth2UserId) && !empty($sessionUserId)) {
625
                $oauth2->setUserId($sessionUserId);
626
            }
627
            
628
            // save the oauth2 entity
629
            $saved = $oauth2->save();
630
            
631
            // append oauth2 error messages
632
            foreach ($oauth2->getMessages() as $message) {
633
                $validation->appendMessage($message);
634
            }
635
            
636
            // user id is required
637
            $validation->add('userId', new PresenceOf(['message' => 'userId is required']));
638
            $validation->validate($oauth2->toArray());
639
            
640
            // All validation passed
641
            if ($saved && !$validation->getMessages()->count()) {
642
                $user = $this->findUserById($oauth2->getUserId());
643
                
644
                // user not found, login failed
645
                if (!isset($user)) {
646
                    $validation->appendMessage(new Message('Login Failed', ['id'], 'LoginFailed', 401));
647
                }
648
                
649
                // access forbidden, login failed
650
                elseif ($user->isDeleted()) {
651
                    $validation->appendMessage(new Message('Login Forbidden', 'password', 'LoginForbidden', 403));
652
                }
653
                
654
                // login success
655
                else {
656
                    $loggedInUser = $user;
657
                }
658
                
659
                // Set the oauth user id into the session
660
                $session->setUserId($loggedInUser?->getId());
661
                $saved = $session->save();
662
                
663
                // append session error messages
664
                foreach ($session->getMessages() as $message) {
665
                    $validation->appendMessage($message);
666
                }
667
            }
668
        }
669
        
670
        return [
671
            'saved' => $saved,
672
            'loggedIn' => $this->isLoggedIn(false, true),
673
            'loggedInAs' => $this->isLoggedIn(true, true),
674
            'messages' => $validation->getMessages(),
675
        ];
676
    }
677
    
678
    /**
679
     * Login request
680
     * Requires an active session to bind the logged in userId
681
     */
682
    public function login(array $params = null): array
683
    {
684
        $loggedInUser = null;
685
        $saved = null;
686
        $session = $this->getSession();
687
        $validation = new Validation();
688
        $validation->add('email', new PresenceOf(['message' => 'required']));
689
        $validation->add('password', new PresenceOf(['message' => 'required']));
690
        $validation->validate($params);
691
        
692
        if (!$session) {
693
            $validation->appendMessage(new Message('required', 'Session Required', 'PresenceOf', 403));
694
        }
695
        
696
        $messages = $validation->getMessages();
697
        if (isset($session, $params['email'], $params['password']) && !$messages->count()) {
698
            $user = empty($params['email'])? null : $this->findUser($params['email']);
699
            
700
            $loginFailedMessage = new Message('Login Failed', ['email', 'password'], 'LoginFailed', 401);
701
            $loginForbiddenMessage = new Message('Login Forbidden', ['email', 'password'], 'LoginForbidden', 403);
702
            
703
            if (!isset($user)) {
704
                // user not found, login failed
705
                $validation->appendMessage($loginFailedMessage);
706
            }
707
            elseif (empty($user->getPassword())) {
708
                // password disabled, login failed
709
                $validation->appendMessage($loginFailedMessage);
710
            }
711
            elseif (!$user->checkHash($user->getPassword(), $params['password'])) {
712
                // password failed, login failed
713
                $validation->appendMessage($loginFailedMessage);
714
            }
715
            elseif ($user->isDeleted()) {
716
                // password match, user is deleted login forbidden
717
                $validation->appendMessage($loginForbiddenMessage);
718
            }
719
            
720
            // login success
721
            else {
722
                $loggedInUser = $user;
723
            }
724
            
725
            $session->setUserId($loggedInUser?->getId());
726
            $saved = $session->save();
727
            foreach ($session->getMessages() as $message) {
728
                $validation->appendMessage($message);
729
            }
730
        }
731
        
732
        return [
733
            'saved' => $saved,
734
            'loggedIn' => $this->isLoggedIn(false, true),
735
            'loggedInAs' => $this->isLoggedIn(true, true),
736
            'messages' => $validation->getMessages(),
737
        ];
738
    }
739
    
740
    /**
741
     * Logout the user and return the logout status, login status, and validation messages
742
     * 
743
     * @return array The array containing the logout status, login status, and validation messages
744
     */
745
    public function logout(): array
746
    {
747
        $saved = false;
748
        $sessionEntity = $this->getSession();
749
        $validation = new Validation();
750
        $validation->validate();
751
        if (!$sessionEntity) {
752
            $validation->appendMessage(new Message('A session is required', 'session', 'PresenceOf', 403));
753
        }
754
        else {
755
            // Logout
756
            $sessionEntity->setUserId(null);
757
            $sessionEntity->setAsUserId(null);
758
            $saved = $sessionEntity->save();
759
            foreach ($sessionEntity->getMessages() as $message) {
760
                $validation->appendMessage($message);
761
            }
762
        }
763
        
764
        return [
765
            'saved' => $saved,
766
            'loggedIn' => $this->isLoggedIn(false, true),
767
            'loggedInAs' => $this->isLoggedIn(true, true),
768
            'messages' => $validation->getMessages(),
769
        ];
770
    }
771
    
772
    /**
773
     * @param array|null $params
774
     *
775
     * @return array
776
     */
777
    public function reset(array $params = null)
778
    {
779
        $saved = false;
780
        $sent = false;
781
        $session = $this->getSession();
782
        $validation = new Validation();
783
        $validation->add('email', new PresenceOf(['message' => 'email is required']));
784
        $validation->validate($params);
785
        if (!$session) {
786
            $validation->appendMessage(new Message('A session is required', 'session', 'PresenceOf', 403));
787
        }
788
        else {
789
            $user = false;
790
            if (isset($params['email'])) {
791
                $user = $this->models->getUser()::findFirst([
792
                    'email = :email:',
793
                    'bind' => ['email' => $params['email']],
794
                    'bindTypes' => ['email', Column::BIND_PARAM_STR]
795
                ]);
796
            }
797
            
798
            // Reset
799
            if ($user) {
800
                // Password reset request
801
                if (empty($params['token'])) {
802
                    
803
                    // Generate a new token
804
                    $token = $user->prepareToken();
805
                    
806
                    // Send it by email
807
                    $emailClass = $this->getEmailClass();
808
                    $email = new $emailClass();
809
                    $email->setViewPath('template/email');
810
                    $email->setTemplateByIndex('reset-password');
811
                    $email->setTo([$user->getEmail()]);
812
                    $meta = [];
813
                    $meta['user'] = $user->expose(['User' => [
814
                        false,
815
                        'firstName',
816
                        'lastName',
817
                        'email',
818
                    ]]);
819
                    $meta['resetLink'] = $this->url->get('/reset-password/' . $token);
820
                    $email->setMeta($meta);
821
                    $saved = $user->save();
822
                    $sent = $saved ? $email->send() : false;
823
                    
824
                    // Appending error messages
825
                    foreach (['user', 'email'] as $e) {
826
                        foreach ($$e->getMessages() as $message) {
827
                            $validation->appendMessage($message);
828
                        }
829
                    }
830
                }
831
                
832
                // Password reset
833
                else {
834
                    $validation->add('password', new PresenceOf(['message' => 'password is required']));
835
                    $validation->add('passwordConfirm', new PresenceOf(['message' => 'password confirm is required']));
836
                    $validation->add('password', new Confirmation(['message' => 'password does not match passwordConfirm', 'with' => 'passwordConfirm']));
837
                    $validation->validate($params);
838
                    if (!$user->checkToken($params['token'])) {
839
                        $validation->appendMessage(new Message('invalid token', 'token', 'NotValid', 400));
840
                    }
841
                    elseif (!$validation->getMessages()->count()) {
842
                        $params['token'] = null;
843
                        $user->assign($params, ['token', 'password', 'passwordConfirm']);
844
                        $saved = $user->save();
845
                    }
846
                }
847
                
848
                // Appending error messages
849
                foreach ($user->getMessages() as $message) {
850
                    $validation->appendMessage($message);
851
                }
852
            }
853
            else {
854
                // removed - OWASP Protect User Enumeration
855
//                $validation->appendMessage(new Message('User not found', 'user', 'PresenceOf', 404));
856
                $saved = true;
857
                $sent = true;
858
            }
859
        }
860
        
861
        return [
862
            'saved' => $saved,
863
            'sent' => $sent,
864
            'messages' => $validation->getMessages(),
865
        ];
866
    }
867
    
868
    /**
869
     * Retrieve the key and token from various authorization sources
870
     *
871
     * @param string|null $jwt The JWT token
872
     * @param string|null $key The key
873
     * @param string|null $token The token
874
     * @return array An array containing the key and token
875
     * @throws ValidatorException
876
     */
877 4
    public function getKeyToken(string $jwt = null, string $key = null, string $token = null): array
878
    {
879
        try {
880 4
            $json = $this->request->getJsonRawBody();
881
        } catch (\InvalidArgumentException $e) {
882
            $json = new \stdClass();
883
        }
884
        
885 4
        $refreshToken = $this->request->get('refreshToken', 'string', $json->refreshToken ?? null);
886 4
        $jwt ??= $this->request->get('jwt', 'string', $json->jwt ?? null);
887 4
        $key ??= $this->request->get('key', 'string', $this->store['key'] ?? $json->key ?? null);
888 4
        $token ??= $this->request->get('token', 'string', $this->store['token'] ?? $json->token ?? null);
889
        
890
        // Using provided key & token
891 4
        if (isset($key, $token)) {
892
            return [$key, $token];
893
        }
894
        
895
        // Using refresh token
896 4
        if (!empty($refreshToken)) {
897
            return $this->getKeyTokenFromClaimToken($refreshToken, $this->sessionKey . '-refresh');
898
        }
899
        
900
        // Using JWT
901 4
        if (!empty($jwt)) {
902
            return $this->getKeyTokenFromClaimToken($jwt, $this->sessionKey);
903
        }
904
        
905
        // Using X-Authorization Header
906 4
        $authorizationHeaderKey = $this->config->path('identity.authorizationHeader', 'Authorization');
907 4
        $authorizationHeaderValue = $this->request->getHeader($authorizationHeaderKey);
908 4
        $authorization = array_filter(explode(' ', $authorizationHeaderValue));
909 4
        if (!empty($authorization)) {
910
            return $this->getKeyTokenFromAuthorization($authorization);
911
        }
912
        
913
        // Using Basic Auth from HTTP request
914 4
        $basicAuth = $this->request->getBasicAuth();
915 4
        if (!empty($basicAuth)) {
916
            return [
917
                $basicAuth['username'] ?? null,
918
                $basicAuth['password'] ?? null,
919
            ];
920
        }
921
        
922
        // Using Session Fallback
923 4
        $sessionFallback = $this->config->path('identity.sessionFallback', false);
924 4
        if ($sessionFallback && $this->session->has($this->sessionKey)) {
925
            $sessionStore = $this->session->get($this->sessionKey);
926
            return [
927
                $sessionStore['key'] ?? null,
928
                $sessionStore['token'] ?? null,
929
            ];
930
        }
931
        
932
        // Unsupported authorization method
933 4
        return [null, null];
934
    }
935
    
936
    /**
937
     * Get key and token from authorization
938
     * @param array $authorization The authorization array, where the first element is the authorization type and the second element is the authorization token
939
     * @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.
940
     * @throws ValidatorException
941
     */
942
    public function getKeyTokenFromAuthorization(array $authorization): array
943
    {
944
        $authorizationType = $authorization[0] ?? null;
945
        $authorizationToken = $authorization[1] ?? null;
946
        
947
        if ($authorizationType && $authorizationToken && strtolower($authorizationType) === 'bearer') {
948
            return $this->getKeyTokenFromClaimToken($authorizationToken, $this->sessionKey);
949
        }
950
        
951
        return [null, null];
952
    }
953
    
954
    /**
955
     * Get the key and token from the claim token
956
     *
957
     * @param string $claimToken The claim token
958
     * @param string $sessionKey The session key
959
     * @return array The key and token, [key, token]
960
     * @throws ValidatorException
961
     */
962
    public function getKeyTokenFromClaimToken(string $claimToken, string $sessionKey): array
963
    {
964
        $sessionClaim = $this->getClaim($claimToken, $sessionKey);
965
        $key = $sessionClaim['key'] ?? null;
966
        $token = $sessionClaim['token'] ?? null;
967
        return [$key, $token];
968
    }
969
    
970
    /**
971
     * Return the session by key if the token is valid
972
     */
973 4
    public function getSession(?string $key = null, ?string $token = null, bool $refresh = false): ?SessionInterface
974
    {
975 4
        if (!isset($key, $token)) {
976 4
            [$key, $token] = $this->getKeyToken();
977
        }
978
        
979 4
        if (empty($key) || empty($token)) {
980 4
            return null;
981
        }
982
        
983
        if ($refresh) {
984
            $this->currentSession = null;
985
        }
986
        
987
        if (isset($this->currentSession)) {
988
            return $this->currentSession;
989
        }
990
        
991
        $sessionClass = $this->getSessionClass();
992
        $sessionEntity = $sessionClass::findFirstByKey($this->filter->sanitize($key, 'string'));
993
        // @todo make this configurable
994
//        if ($sessionEntity && $sessionEntity->checkHash($sessionEntity->getToken(), $key . $token)) {
995
            $this->currentSession = $sessionEntity;
996
//        }
997
        
998
        return $this->currentSession;
999
    }
1000
    
1001
    /**
1002
     * Return the session ID if available, otherwise return null
1003
     *
1004
     * @return int|null
1005
     */
1006
    public function getSessionId(): ?int
1007
    {
1008
        $session = $this->getSession();
1009
        return $session instanceof EntityInterface? $session->readAttribute('id') : null;
1010
    }
1011
    
1012
    /**
1013
     * @param string $token
1014
     * @param string|null $claim
1015
     * @return array
1016
     * @throws ValidatorException
1017
     */
1018
    public function getClaim(string $token, string $claim = null): array
1019
    {
1020
        $uri = $this->request->getScheme() . '://' . $this->request->getHttpHost();
1021
        
1022
        $token = $this->jwt->parseToken($token);
1023
        
1024
        $errors = $this->jwt->validateToken($token, 0, [
1025
            'issuer' => $uri,
1026
            'audience' => $uri,
1027
            'id' => $claim,
1028
        ]);
1029
        
1030
        // @todo improve this
1031
        if (!empty($errors)) {
1032
//            dd($errors);
1033
            return [];
1034
        }
1035
        
1036
        $claims = $token->getClaims();
1037
        
1038
        $ret = $claims->has('sub') ? json_decode($claims->get('sub'), true) : [];
1039
        return is_array($ret) ? $ret : [];
1040
    }
1041
    
1042
    /**
1043
     * Generate a new JWT Token (string)
1044
     * @throws ValidatorException
1045
     */
1046
    public function getJwtToken(string $id, array $data = [], array $options = []): string
1047
    {
1048
        $uri = $this->request->getScheme() . '://' . $this->request->getHttpHost();
1049
        
1050
        $options['issuer'] ??= $uri;
1051
        $options['audience'] ??= $uri;
1052
        $options['id'] ??= $id;
1053
        $options['subject'] ??= json_encode($data);
1054
        
1055
        $builder = $this->jwt->builder($options);
1056
        return $builder->getToken()->getToken();
1057
    }
1058
    
1059
    /**
1060
     * Get the User from the database using the ID
1061
     */
1062
    public function findUserById(int $id): ?UserInterface
1063
    {
1064
        $user = $this->models->getUser()::findFirst([
1065
            'id = :id:',
1066
            'bind' => ['id' => $id],
1067
            'bindTypes' => ['id' => Column::BIND_PARAM_INT],
1068
        ]);
1069
        return $user instanceof UserInterface? $user : null;
1070
    }
1071
    
1072
    /**
1073
     * Get the user from the database using the username or email
1074
     */
1075
    public function findUser(string $string): ?UserInterface
1076
    {
1077
        $user = $this->models->getUser()::findFirst([
1078
            'email = :email:',
1079
            'bind' => [
1080
                'email' => $string,
1081
            ],
1082
            'bindTypes' => [
1083
                'email' => Column::BIND_PARAM_STR,
1084
            ],
1085
        ]);
1086
        return $user instanceof UserInterface? $user : null;
1087
    }
1088
}
1089