Test Failed
Push — master ( 301e07...8d9b0b )
by Julien
07:35
created

Identity   F

Complexity

Total Complexity 149

Size/Duplication

Total Lines 1029
Duplicated Lines 0 %

Test Coverage

Coverage 16.05%

Importance

Changes 9
Bugs 2 Features 0
Metric Value
eloc 449
c 9
b 2
f 0
dl 0
loc 1029
ccs 78
cts 486
cp 0.1605
rs 2
wmc 149

33 Methods

Rating   Name   Duplication   Size   Complexity  
A initialize() 0 4 1
A hasRole() 0 3 2
A logoutAs() 0 18 6
A setUserAs() 0 3 1
B loginAs() 0 43 7
A getUserAs() 0 3 1
A getTypeList() 0 3 1
A getGroupList() 0 3 1
A getRoleList() 0 3 1
A setUser() 0 3 1
A getAclRoles() 0 26 5
A getUserId() 0 4 2
A getUserAsId() 0 3 1
A isLoggedIn() 0 3 1
A has() 0 18 6
D getIdentity() 0 103 22
C getUser() 0 48 13
A getJwt() 0 48 5
A isLoggedInAs() 0 3 1
A getInheritedRoleList() 0 32 4
C oauth2() 0 95 12
A findUserById() 0 8 2
A findUser() 0 12 2
B login() 0 55 10
A getKeyTokenFromClaimToken() 0 6 1
A logout() 0 24 3
A getClaim() 0 15 3
C reset() 0 88 11
A getSessionId() 0 4 2
B getSession() 0 25 8
B getKeyToken() 0 52 8
A getKeyTokenFromAuthorization() 0 10 4
A getJwtToken() 0 11 1

How to fix   Complexity   

Complex Class

Complex classes like Identity often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Identity, and based on these observations, apply Extract Interface, too.

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 6
    public function initialize(): void
57
    {
58 6
        $this->sessionKey = $this->getOption('sessionKey') ?? $this->sessionKey;
59 6
        $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
        // retrieve or create a new session
138
        $sessionClass = $this->getSessionClass();
139
        $session = $this->getSession($key, $token) ?? new $sessionClass();
140
        assert($session instanceof SessionInterface);
141
        
142
        // save the key token into the store (database or session)
143
        $session->setKey($key);
144
        $session->setToken($session->hash($key . $newToken));
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),
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 (isset($user)) {
193
            if (!empty($user->rolelist)) {
194
                foreach ($user->rolelist as $role) {
195
                    $roleList [$role->getIndex()] = $role;
196
                }
197
            }
198
            
199
            if (!empty($user->grouplist)) {
200
                foreach ($user->grouplist as $group) {
201
                    $groupList [$group->getIndex()] = $group;
202
                    if (!empty($group->rolelist)) {
203
                        foreach ($group->rolelist as $role) {
204
                            $roleList [$role->getIndex()] = $role;
205
                        }
206
                    }
207
                }
208
            }
209
            
210
            if (!empty($user->typelist)) {
211
                foreach ($user->typelist as $type) {
212
                    $typeList [$type->getIndex()] = $type;
213
                    if (!empty($type->grouplist)) {
214
                        foreach ($type->grouplist as $group) {
215
                            $groupList [$group->getIndex()] = $group;
216
                            if (!empty($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
                SecurityBehavior::staticStart();
238
                $roleClass = $this->getRoleClass();
239
                $inheritedRoleList = $this->models->getRole()::find([
240
                    'index in ({role:array})',
241
                    'bind' => ['role' => $inheritedRoleIndexList],
242
                    'bindTypes' => ['role' => Column::BIND_PARAM_STR],
243
                ]);
244
                SecurityBehavior::staticStop();
245
                
246
                assert(is_iterable($inheritedRoleList));
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
     * Return the user object based on the session
344
     *
345
     * @param bool $as Flag to indicate whether to get the user as another user
346
     * @param bool|null $force Flag to indicate whether to force the retrieval of the user object
347
     * 
348
     * @return UserInterface|null The user object or null if session is not available
349
     */
350 3
    public function getUser(bool $as = false, ?bool $force = null): ?UserInterface
351
    {
352
        // session required to fetch user
353 3
        $session = $this->getSession();
354 3
        if (!$session) {
355 3
            return null;
356
        }
357
        
358
        $force = $force
359
            || ($as && empty($this->userAs))
360
            || (!$as && empty($this->user));
361
        
362
        if ($force) {
363
            
364
            $userId = $as
365
                ? $session->getAsUserId()
366
                : $session->getUserId();
367
            
368
            $user = null;
369
            if (!empty($userId)) {
370
                SecurityBehavior::staticStart();
371
                
372
                $user = $this->models->getUser()::findFirstWith([
373
                    'RoleList',
374
                    'GroupList.RoleList',
375
                    'TypeList.GroupList.RoleList',
376
                ], [
377
                    'id = :id:',
378
                    'bind' => ['id' => (int)$userId],
379
                    'bindTypes' => ['id' => Column::BIND_PARAM_INT]
380
                ]);
381
                if ($user) {
382
                    assert($user instanceof UserInterface);
383
                }
384
                
385
                SecurityBehavior::staticStop();
386
            }
387
            
388
            $as
389
                ? $this->setUserAs($user)
390
                : $this->setUser($user);
391
            
392
            return $user instanceof UserInterface? $user : null;
393
        }
394
        
395
        return $as
396
            ? $this->userAs
397
            : $this->user;
398
    }
399
    
400
    /**
401
     * Get Identity User (As)
402
     */
403 1
    public function getUserAs(): ?UserInterface
404
    {
405 1
        return $this->getUser(true);
406
    }
407
    
408
    /**
409
     * Set Identity User
410
     */
411
    public function setUser(?UserInterface $user): void
412
    {
413
        $this->user = $user;
414
    }
415
    
416
    /**
417
     * Set Identity User (As)
418
     */
419
    public function setUserAs(?UserInterface $user): void
420
    {
421
        $this->userAs = $user;
422
    }
423
    
424
    /**
425
     * Get the "Roles" related to the current session
426
     */
427 1
    public function getRoleList(bool $inherit = true): array
428
    {
429 1
        return $this->getIdentity($inherit)['roleList'] ?? [];
430
    }
431
    
432
    /**
433
     * Get the "Groups" related to the current session
434
     */
435
    public function getGroupList(bool $inherit = true): array
436
    {
437
        return $this->getIdentity($inherit)['groupList'] ?? [];
438
    }
439
    
440
    /**
441
     * Get the "Types" related to the current session
442
     */
443
    public function getTypeList(bool $inherit = true): array
444
    {
445
        return $this->getIdentity($inherit)['typeList'] ?? [];
446
    }
447
    
448
    /**
449
     * Return the list of ACL roles
450
     * - Reserved roles: guest, cli, everyone
451
     *
452
     * @param array|null $roleList
453
     * @return array
454
     */
455 1
    public function getAclRoles(?array $roleList = null): array
456
    {
457 1
        $roleList ??= $this->getRoleList();
458 1
        $aclRoles = [];
459
        
460
        // Add everyone role
461 1
        $aclRoles['everyone'] = new Role('everyone', 'Everyone');
462
        
463
        // Add guest role if no roles was detected
464 1
        if (count($roleList) === 0) {
465 1
            $aclRoles['guest'] = new Role('guest', 'Guest');
466
        }
467
        
468
        // Add roles from databases
469 1
        foreach ($roleList as $role) {
470
            if ($role) {
471
                $aclRoles[$role->getIndex()] ??= new Role($role->getIndex());
472
            }
473
        }
474
        
475
        // Add console role
476 1
        if ($this->bootstrap->isCli()) {
477
            $aclRoles['cli'] = new Role('cli', 'Cli');
478
        }
479
        
480 1
        return array_filter(array_values(array_unique($aclRoles)));
481
    }
482
    
483
    /**
484
     * Login as User
485
     */
486
    public function loginAs(?array $params = []): array
487
    {
488
        $session = $this->getSession();
489
        
490
        // Validation
491
        $validation = new Validation();
492
        $validation->add('userId', new PresenceOf(['message' => 'required']));
493
        $validation->add('userId', new Numericality(['message' => 'not-numeric']));
494
        $validation->validate($params);
495
        $messages = $validation->getMessages();
496
    
497
        $saved = false;
498
        
499
        // must be an admin
500
        if (isset($session, $params['userId']) && !$messages->count() && $this->hasRole(['admin', 'dev'])) {
501
            $userId = $session->getUserId();
502
            
503
            // himself, return back to normal login
504
            if ((int)$params['userId'] === (int)$userId) {
505
                return $this->logoutAs();
506
            }
507
    
508
            // login as using id
509
            $asUser = $this->findUserById((int)$params['userId']);
510
            if (isset($asUser)) {
511
                $session->setAsUserId($userId);
512
                $session->setUserId($params['userId']);
513
            }
514
            else {
515
                $validation->appendMessage(new Message('User Not Found', 'userId', 'PresenceOf', 404));
516
            }
517
    
518
            $saved = $session->save();
519
            foreach ($session->getMessages() as $message) {
520
                $validation->appendMessage($message);
521
            }
522
        }
523
        
524
        return [
525
            'saved' => $saved,
526
            'messages' => $validation->getMessages(),
527
            'loggedIn' => $this->isLoggedIn(false, true),
528
            'loggedInAs' => $this->isLoggedIn(true, true),
529
        ];
530
    }
531
    
532
    /**
533
     * Log off User (As)
534
     */
535
    public function logoutAs(): array
536
    {
537
        $session = $this->getSession();
538
        
539
        if ($session) {
540
            $asUserId = $session->getAsUserId();
541
            $userId = $session->getUserId();
542
            if (!empty($asUserId) && !empty($userId)) {
543
                $session->setUserId($asUserId);
544
                $session->setAsUserId(null);
545
            }
546
        }
547
        
548
        return [
549
            'saved' => $session && $session->save(),
550
            'messages' => $session && $session->getMessages(),
551
            'loggedIn' => $this->isLoggedIn(false, true),
552
            'loggedInAs' => $this->isLoggedIn(true, true),
553
        ];
554
    }
555
    
556
    /**
557
     * OAuth2 authentication
558
     *
559
     * @param string $provider The OAuth2 provider
560
     * @param int $providerUuid The UUID associated with the provider
561
     * @param string $accessToken The access token provided by the provider
562
     * @param string|null $refreshToken The refresh token provided by the provider (optional)
563
     * @param array|null $meta Additional metadata associated with the user (optional)
564
     *
565
     * @return array Returns an array with the following keys:
566
     *   - 'saved': Indicates whether the OAuth2 entity was saved successfully
567
     *   - 'loggedIn': Indicates whether the user is currently logged in
568
     *   - 'loggedInAs': Indicates the user that is currently logged in
569
     *   - 'messages': An array of validation messages
570
     * 
571
     * @throws \Phalcon\Filter\Exception
572
     */
573
    public function oauth2(string $provider, int $providerUuid, string $accessToken, ?string $refreshToken = null, ?array $meta = []): array
574
    {
575
        $loggedInUser = null;
576
        $saved = false;
577
        
578
        // get the current session
579
        $session = $this->getSession();
580
        
581
        // prepare validation
582
        $validation = new Validation();
583
        
584
        // a session is required
585
        if (!isset($session)) {
586
            $validation->appendMessage(new Message('A session is required', 'session', 'PresenceOf', 403));
587
        }
588
        else {
589
            // retrieve and prepare oauth2 entity
590
            $oauth2 = Oauth2::findFirst([
591
                'provider = :provider: and provider_uuid = :providerUuid:',
592
                'bind' => [
593
                    'provider' => $this->filter->sanitize($provider, 'string'),
594
                    'providerUuid' => $providerUuid,
595
                ],
596
                'bindTypes' => [
597
                    'provider' => Column::BIND_PARAM_STR,
598
                    'id' => Column::BIND_PARAM_STR,
599
                ],
600
            ]);
601
            if (!$oauth2) {
602
                $oauth2 = new Oauth2();
603
                $oauth2->setProvider($provider);
604
                $oauth2->setProviderUuid($providerUuid);
605
            }
606
            $oauth2->setAccessToken($accessToken);
607
            $oauth2->setRefreshToken($refreshToken);
608
            $oauth2->setMeta(!empty($meta)? json_encode($meta) : null);
609
            $oauth2->setName($meta['name'] ?? null);
610
            $oauth2->setFirstName($meta['first_name'] ?? null);
611
            $oauth2->setLastName($meta['last_name'] ?? null);
612
            $oauth2->setEmail($meta['email'] ?? null);
613
            
614
            // link the current user to the oauth2 entity
615
            $oauth2UserId = $oauth2->getUserId();
616
            $sessionUserId = $session->getUserId();
617
            if (empty($oauth2UserId) && !empty($sessionUserId)) {
618
                $oauth2->setUserId($sessionUserId);
619
            }
620
            
621
            // save the oauth2 entity
622
            $saved = $oauth2->save();
623
            
624
            // append oauth2 error messages
625
            foreach ($oauth2->getMessages() as $message) {
626
                $validation->appendMessage($message);
627
            }
628
            
629
            // user id is required
630
            $validation->add('userId', new PresenceOf(['message' => 'userId is required']));
631
            $validation->validate($oauth2->toArray());
632
            
633
            // All validation passed
634
            if ($saved && !$validation->getMessages()->count()) {
635
                $user = $this->findUserById($oauth2->getUserId());
636
                
637
                // user not found, login failed
638
                if (!isset($user)) {
639
                    $validation->appendMessage(new Message('Login Failed', ['id'], 'LoginFailed', 401));
640
                }
641
                
642
                // access forbidden, login failed
643
                elseif ($user->isDeleted()) {
644
                    $validation->appendMessage(new Message('Login Forbidden', 'password', 'LoginForbidden', 403));
645
                }
646
                
647
                // login success
648
                else {
649
                    $loggedInUser = $user;
650
                }
651
                
652
                // Set the oauth user id into the session
653
                $session->setUserId($loggedInUser?->getId());
654
                $saved = $session->save();
655
                
656
                // append session error messages
657
                foreach ($session->getMessages() as $message) {
658
                    $validation->appendMessage($message);
659
                }
660
            }
661
        }
662
        
663
        return [
664
            'saved' => $saved,
665
            'loggedIn' => $this->isLoggedIn(false, true),
666
            'loggedInAs' => $this->isLoggedIn(true, true),
667
            'messages' => $validation->getMessages(),
668
        ];
669
    }
670
    
671
    /**
672
     * Login request
673
     * Requires an active session to bind the logged in userId
674
     */
675
    public function login(array $params = null): array
676
    {
677
        $loggedInUser = null;
678
        $saved = null;
679
        $session = $this->getSession();
680
        $validation = new Validation();
681
        $validation->add('email', new PresenceOf(['message' => 'required']));
682
        $validation->add('password', new PresenceOf(['message' => 'required']));
683
        $validation->validate($params);
684
        
685
        if (!$session) {
686
            $validation->appendMessage(new Message('required', 'Session Required', 'PresenceOf', 403));
687
        }
688
        
689
        $messages = $validation->getMessages();
690
        if (isset($session, $params['email'], $params['password']) && !$messages->count()) {
691
            $user = empty($params['email'])? null : $this->findUser($params['email']);
692
            
693
            $loginFailedMessage = new Message('Login Failed', ['email', 'password'], 'LoginFailed', 401);
694
            $loginForbiddenMessage = new Message('Login Forbidden', ['email', 'password'], 'LoginForbidden', 403);
695
            
696
            if (!isset($user)) {
697
                // user not found, login failed
698
                $validation->appendMessage($loginFailedMessage);
699
            }
700
            elseif (empty($user->getPassword())) {
701
                // password disabled, login failed
702
                $validation->appendMessage($loginFailedMessage);
703
            }
704
            elseif (!$user->checkHash($user->getPassword(), $params['password'])) {
705
                // password failed, login failed
706
                $validation->appendMessage($loginFailedMessage);
707
            }
708
            elseif ($user->isDeleted()) {
709
                // password match, user is deleted login forbidden
710
                $validation->appendMessage($loginForbiddenMessage);
711
            }
712
            
713
            // login success
714
            else {
715
                $loggedInUser = $user;
716
            }
717
            
718
            $session->setUserId($loggedInUser?->getId());
719
            $saved = $session->save();
720
            foreach ($session->getMessages() as $message) {
721
                $validation->appendMessage($message);
722
            }
723
        }
724
        
725
        return [
726
            'saved' => $saved,
727
            'loggedIn' => $this->isLoggedIn(false, true),
728
            'loggedInAs' => $this->isLoggedIn(true, true),
729
            'messages' => $validation->getMessages(),
730
        ];
731
    }
732
    
733
    /**
734
     * Logout the user and return the logout status, login status, and validation messages
735
     * 
736
     * @return array The array containing the logout status, login status, and validation messages
737
     */
738
    public function logout(): array
739
    {
740
        $saved = false;
741
        $sessionEntity = $this->getSession();
742
        $validation = new Validation();
743
        $validation->validate();
744
        if (!$sessionEntity) {
745
            $validation->appendMessage(new Message('A session is required', 'session', 'PresenceOf', 403));
746
        }
747
        else {
748
            // Logout
749
            $sessionEntity->setUserId(null);
750
            $sessionEntity->setAsUserId(null);
751
            $saved = $sessionEntity->save();
752
            foreach ($sessionEntity->getMessages() as $message) {
753
                $validation->appendMessage($message);
754
            }
755
        }
756
        
757
        return [
758
            'saved' => $saved,
759
            'loggedIn' => $this->isLoggedIn(false, true),
760
            'loggedInAs' => $this->isLoggedIn(true, true),
761
            'messages' => $validation->getMessages(),
762
        ];
763
    }
764
    
765
    /**
766
     * @param array|null $params
767
     *
768
     * @return array
769
     */
770
    public function reset(array $params = null)
771
    {
772
        $saved = false;
773
        $sent = false;
774
        $session = $this->getSession();
775
        $validation = new Validation();
776
        $validation->add('email', new PresenceOf(['message' => 'email is required']));
777
        $validation->validate($params);
778
        if (!$session) {
779
            $validation->appendMessage(new Message('A session is required', 'session', 'PresenceOf', 403));
780
        }
781
        else {
782
            $user = false;
783
            if (isset($params['email'])) {
784
                $user = $this->models->getUser()::findFirst([
785
                    'email = :email:',
786
                    'bind' => ['email' => $params['email']],
787
                    'bindTypes' => ['email', Column::BIND_PARAM_STR]
788
                ]);
789
            }
790
            
791
            // Reset
792
            if ($user) {
793
                // Password reset request
794
                if (empty($params['token'])) {
795
                    
796
                    // Generate a new token
797
                    $token = $user->prepareToken();
798
                    
799
                    // Send it by email
800
                    $emailClass = $this->getEmailClass();
801
                    $email = new $emailClass();
802
                    $email->setViewPath('template/email');
803
                    $email->setTemplateByIndex('reset-password');
804
                    $email->setTo([$user->getEmail()]);
805
                    $meta = [];
806
                    $meta['user'] = $user->expose(['User' => [
807
                        false,
808
                        'firstName',
809
                        'lastName',
810
                        'email',
811
                    ]]);
812
                    $meta['resetLink'] = $this->url->get('/reset-password/' . $token);
813
                    $email->setMeta($meta);
814
                    $saved = $user->save();
815
                    $sent = $saved ? $email->send() : false;
816
                    
817
                    // Appending error messages
818
                    foreach (['user', 'email'] as $e) {
819
                        foreach ($$e->getMessages() as $message) {
820
                            $validation->appendMessage($message);
821
                        }
822
                    }
823
                }
824
                
825
                // Password reset
826
                else {
827
                    $validation->add('password', new PresenceOf(['message' => 'password is required']));
828
                    $validation->add('passwordConfirm', new PresenceOf(['message' => 'password confirm is required']));
829
                    $validation->add('password', new Confirmation(['message' => 'password does not match passwordConfirm', 'with' => 'passwordConfirm']));
830
                    $validation->validate($params);
831
                    if (!$user->checkToken($params['token'])) {
832
                        $validation->appendMessage(new Message('invalid token', 'token', 'NotValid', 400));
833
                    }
834
                    elseif (!$validation->getMessages()->count()) {
835
                        $params['token'] = null;
836
                        $user->assign($params, ['token', 'password', 'passwordConfirm']);
837
                        $saved = $user->save();
838
                    }
839
                }
840
                
841
                // Appending error messages
842
                foreach ($user->getMessages() as $message) {
843
                    $validation->appendMessage($message);
844
                }
845
            }
846
            else {
847
                // removed - OWASP Protect User Enumeration
848
//                $validation->appendMessage(new Message('User not found', 'user', 'PresenceOf', 404));
849
                $saved = true;
850
                $sent = true;
851
            }
852
        }
853
        
854
        return [
855
            'saved' => $saved,
856
            'sent' => $sent,
857
            'messages' => $validation->getMessages(),
858
        ];
859
    }
860
    
861
    /**
862
     * Retrieve the key and token from various authorization sources
863
     *
864
     * @param string|null $jwt The JWT token
865
     * @param string|null $key The key
866
     * @param string|null $token The token
867
     * @return array An array containing the key and token
868
     * @throws ValidatorException
869
     */
870 3
    public function getKeyToken(string $jwt = null, string $key = null, string $token = null): array
871
    {
872 3
        $json = $this->request->getJsonRawBody();
873 3
        $refreshToken = $this->request->get('refreshToken', 'string', $json->refreshToken ?? null);
874 3
        $jwt ??= $this->request->get('jwt', 'string', $json->jwt ?? null);
875 3
        $key ??= $this->request->get('key', 'string', $this->store['key'] ?? $json->key ?? null);
876 3
        $token ??= $this->request->get('token', 'string', $this->store['token'] ?? $json->token ?? null);
877
        
878
        // Using provided key & token
879 3
        if (isset($key, $token)) {
880
            return [$key, $token];
881
        }
882
        
883
        // Using refresh token
884 3
        if (!empty($refreshToken)) {
885
            return $this->getKeyTokenFromClaimToken($refreshToken, $this->sessionKey . '-refresh');
886
        }
887
        
888
        // Using JWT
889 3
        if (!empty($jwt)) {
890
            return $this->getKeyTokenFromClaimToken($jwt, $this->sessionKey);
891
        }
892
        
893
        // Using Basic Auth from HTTP request
894 3
        $basicAuth = $this->request->getBasicAuth();
895 3
        if (!empty($basicAuth)) {
896
            return [
897
                $basicAuth['username'] ?? null,
898
                $basicAuth['password'] ?? null,
899
            ];
900
        }
901
        
902
        // Using X-Authorization Header
903 3
        $authorizationHeaderKey = $this->config->path('identity.authorizationHeader', 'Authorization');
904 3
        $authorizationHeaderValue = $this->request->getHeader($authorizationHeaderKey);
905 3
        $authorization = array_filter(explode(' ', $authorizationHeaderValue));
906 3
        if (!empty($authorization)) {
907
            return $this->getKeyTokenFromAuthorization($authorization);
908
        }
909
        
910
        // Using Session Fallback
911 3
        $sessionFallback = $this->config->path('identity.sessionFallback', false);
912 3
        if ($sessionFallback && $this->session->has($this->sessionKey)) {
913
            $sessionStore = $this->session->get($this->sessionKey);
914
            return [
915
                $sessionStore['key'] ?? null,
916
                $sessionStore['token'] ?? null,
917
            ];
918
        }
919
        
920
        // Unsupported authorization method
921 3
        return [null, null];
922
    }
923
    
924
    /**
925
     * Get key and token from authorization
926
     * @param array $authorization The authorization array, where the first element is the authorization type and the second element is the authorization token
927
     * @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.
928
     * @throws ValidatorException
929
     */
930
    public function getKeyTokenFromAuthorization(array $authorization): array
931
    {
932
        $authorizationType = $authorization[0] ?? null;
933
        $authorizationToken = $authorization[1] ?? null;
934
        
935
        if ($authorizationType && $authorizationToken && strtolower($authorizationType) === 'bearer') {
936
            return $this->getKeyTokenFromClaimToken($authorizationToken, $this->sessionKey);
937
        }
938
        
939
        return [null, null];
940
    }
941
    
942
    /**
943
     * Get the key and token from the claim token
944
     *
945
     * @param string $claimToken The claim token
946
     * @param string $sessionKey The session key
947
     * @return array The key and token, [key, token]
948
     * @throws ValidatorException
949
     */
950
    public function getKeyTokenFromClaimToken(string $claimToken, string $sessionKey): array
951
    {
952
        $sessionClaim = $this->getClaim($claimToken, $sessionKey);
953
        $key = $sessionClaim['key'] ?? null;
954
        $token = $sessionClaim['token'] ?? null;
955
        return [$key, $token];
956
    }
957
    
958
    /**
959
     * Return the session by key if the token is valid
960
     */
961 3
    public function getSession(?string $key = null, ?string $token = null, bool $refresh = false): ?SessionInterface
962
    {
963 3
        if (!isset($key, $token)) {
964 3
            [$key, $token] = $this->getKeyToken();
965
        }
966
        
967 3
        if (empty($key) || empty($token)) {
968 3
            return null;
969
        }
970
        
971
        if ($refresh) {
972
            $this->currentSession = null;
973
        }
974
        
975
        if (isset($this->currentSession)) {
976
            return $this->currentSession;
977
        }
978
        
979
        $sessionClass = $this->getSessionClass();
980
        $sessionEntity = $sessionClass::findFirstByKey($this->filter->sanitize($key, 'string'));
981
        if ($sessionEntity && $sessionEntity->checkHash($sessionEntity->getToken(), $key . $token)) {
982
            $this->currentSession = $sessionEntity;
983
        }
984
        
985
        return $this->currentSession;
986
    }
987
    
988
    /**
989
     * Return the session ID if available, otherwise return null
990
     *
991
     * @return int|null
992
     */
993
    public function getSessionId(): ?int
994
    {
995
        $session = $this->getSession();
996
        return $session instanceof EntityInterface? $session->readAttribute('id') : null;
997
    }
998
    
999
    /**
1000
     * @param string $token
1001
     * @param string|null $claim
1002
     * @return array
1003
     * @throws ValidatorException
1004
     */
1005
    public function getClaim(string $token, string $claim = null): array
1006
    {
1007
        $uri = $this->request->getScheme() . '://' . $this->request->getHttpHost();
1008
        
1009
        $token = $this->jwt->parseToken($token);
1010
        
1011
        $this->jwt->validateToken($token, 0, [
1012
            'issuer' => $uri,
1013
            'audience' => $uri,
1014
            'id' => $claim,
1015
        ]);
1016
        $claims = $token->getClaims();
1017
        
1018
        $ret = $claims->has('sub') ? json_decode($claims->get('sub'), true) : [];
1019
        return is_array($ret) ? $ret : [];
1020
    }
1021
    
1022
    /**
1023
     * Generate a new JWT Token (string)
1024
     * @throws ValidatorException
1025
     */
1026
    public function getJwtToken(string $id, array $data = [], array $options = []): string
1027
    {
1028
        $uri = $this->request->getScheme() . '://' . $this->request->getHttpHost();
1029
        
1030
        $options['issuer'] ??= $uri;
1031
        $options['audience'] ??= $uri;
1032
        $options['id'] ??= $id;
1033
        $options['subject'] ??= json_encode($data);
1034
        
1035
        $builder = $this->jwt->builder($options);
1036
        return $builder->getToken()->getToken();
1037
    }
1038
    
1039
    /**
1040
     * Get the User from the database using the ID
1041
     */
1042
    public function findUserById(int $id): ?UserInterface
1043
    {
1044
        $user = $this->models->getUser()::findFirst([
1045
            'id = :id:',
1046
            'bind' => ['id' => $id],
1047
            'bindTypes' => ['id' => Column::BIND_PARAM_INT],
1048
        ]);
1049
        return $user instanceof UserInterface? $user : null;
1050
    }
1051
    
1052
    /**
1053
     * Get the user from the database using the username or email
1054
     */
1055
    public function findUser(string $string): ?UserInterface
1056
    {
1057
        $user = $this->models->getUser()::findFirst([
1058
            'email = :email:',
1059
            'bind' => [
1060
                'email' => $string,
1061
            ],
1062
            'bindTypes' => [
1063
                'email' => Column::BIND_PARAM_STR,
1064
            ],
1065
        ]);
1066
        return $user instanceof UserInterface? $user : null;
1067
    }
1068
}
1069