Completed
Push — master ( 1223fc...6af157 )
by Damien
05:06
created

Login::assignUserProperty()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 26

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

Changes 0
Metric Value
dl 0
loc 26
ccs 0
cts 25
cp 0
rs 9.504
c 0
b 0
f 0
cc 4
nc 3
nop 3
crap 20
1
<?php
2
/**
3
 * Created by PhpStorm.
4
 * User: dsmrt
5
 * Date: 1/11/18
6
 * Time: 8:30 PM
7
 */
8
9
namespace flipbox\saml\sp\services;
10
11
use craft\base\Component;
12
use craft\elements\User;
13
use craft\helpers\StringHelper;
14
use craft\models\UserGroup;
15
use flipbox\keychain\records\KeyChainRecord;
16
use flipbox\saml\core\exceptions\InvalidMessage;
17
use flipbox\saml\core\records\ProviderInterface;
18
use flipbox\saml\sp\helpers\UserHelper;
19
use flipbox\saml\sp\records\ProviderIdentityRecord;
20
use flipbox\saml\sp\Saml;
21
use LightSaml\Model\Assertion\Assertion;
22
use LightSaml\Model\Assertion\Attribute;
23
use LightSaml\Model\Protocol\Response as SamlResponse;
24
use yii\base\Event;
25
use yii\base\UserException;
26
27
class Login extends Component
28
{
29
30
    /**
31
     * Use before or after now
32
     * @deprecated
33
     */
34
    const EVENT_RESPONSE_TO_USER = 'eventResponseToUser';
35
    const EVENT_BEFORE_RESPONSE_TO_USER = 'eventBeforeResponseToUser';
36
    const EVENT_AFTER_RESPONSE_TO_USER = 'eventAfterResponseToUser';
37
38
    protected $isAssertionDecrypted = false;
39
40
    /**
41
     * @param SamlResponse $response
42
     * @return \flipbox\saml\sp\records\ProviderIdentityRecord
43
     * @throws InvalidMessage
44
     * @throws UserException
45
     * @throws \Exception
46
     * @throws \Throwable
47
     * @throws \craft\errors\ElementNotFoundException
48
     * @throws \yii\base\Exception
49
     */
50
    public function login(SamlResponse $response)
51
    {
52
53
        $assertion = $this->getFirstAssertion($response);
54
        Saml::getInstance()->getResponse()->isValidTimeAssertion($assertion);
55
        Saml::getInstance()->getResponse()->isValidAssertion($assertion);
56
57
        /**
58
         * Get User
59
         */
60
        $user = $this->getUserByResponse($response);
61
62
        /**
63
         * Sync User
64
         */
65
        $this->syncUser($user, $response);
66
67
        /**
68
         * Get Identity
69
         */
70
        $identity = $this->getIdentityByUserAndResponse($user, $response);
71
72
        /**
73
         * Log user in
74
         */
75
        if (! $this->loginUser($identity)) {
76
            throw new UserException("Unknown error while logging in.");
77
        }
78
79
        /**
80
         * User's successfully logged in so we can now set the lastLogin for the
81
         * provider identity and save it to the db.
82
         */
83
        $identity->lastLoginDate = new \DateTime();
0 ignored issues
show
Documentation introduced by
The property lastLoginDate does not exist on object<flipbox\saml\sp\r...ProviderIdentityRecord>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
84
        if (! Saml::getInstance()->getProviderIdentity()->save($identity)) {
85
            throw new UserException("Error while saving identity.");
86
        }
87
88
        return $identity;
89
    }
90
91
    /**
92
     * @param KeyChainRecord $keyChainRecord
93
     * @param SamlResponse $response
94
     */
95
    protected function decryptAssertions(KeyChainRecord $keyChainRecord, \LightSaml\Model\Protocol\Response $response)
96
    {
97
        Saml::getInstance()->getResponse()->decryptAssertions(
98
            $response,
99
            $keyChainRecord
100
        );
101
    }
102
103
    /**
104
     * @param SamlResponse $response
105
     * @return Assertion
106
     * @throws InvalidMessage
107
     */
108
    public function getFirstAssertion(\LightSaml\Model\Protocol\Response $response)
109
    {
110
111
        $ownProvider = Saml::getInstance()->getProvider()->findOwn();
112
113
        if ($ownProvider->keychain &&
114
            $response->getFirstEncryptedAssertion() &&
115
            $this->isAssertionDecrypted === false
116
        ) {
117
            $this->decryptAssertions(
118
                $ownProvider->keychain,
119
                $response
120
            );
121
            /**
122
             * try to only do this once
123
             */
124
            $this->isAssertionDecrypted = true;
125
        }
126
127
        $assertions = $response->getAllAssertions();
128
129
        if (! isset($assertions[0])) {
130
            throw new InvalidMessage("Invalid message. No assertions found in response.");
131
        }
132
        /**
133
         * Just grab the first one for now.
134
         */
135
        return $assertions[0];
136
    }
137
138
    /**
139
     * @param SamlResponse $response
140
     * @return User
141
     * @throws InvalidMessage
142
     * @throws UserException
143
     * @throws \Throwable
144
     */
145
    protected function getUserByResponse(\LightSaml\Model\Protocol\Response $response)
146
    {
147
148
        $assertion = $this->getFirstAssertion($response);
149
150
        /**
151
         * Get username from the NameID
152
         *
153
         * @todo Give an option to map another attribute value to $username (like email)
154
         */
155
        $username = $assertion->getSubject()->getNameID()->getValue();
156
157
        return $this->getUser($username);
158
    }
159
160
    /**
161
     * @param User $user
162
     * @param SamlResponse $response
163
     * @throws UserException
164
     * @throws \Throwable
165
     * @throws \craft\errors\ElementNotFoundException
166
     * @throws \yii\base\Exception
167
     */
168
    protected function syncUser(User $user, \LightSaml\Model\Protocol\Response $response)
169
    {
170
        /**
171
         * Before user save
172
         */
173
        $event = new Event();
174
        $event->sender = $response;
175
        $event->data = $user;
176
177
        $this->trigger(
178
            static::EVENT_BEFORE_RESPONSE_TO_USER,
179
            $event
180
        );
181
182
        /**
183
         * enable and transform the user
184
         */
185
        $this->constructUser($user, $response);
186
187
        /**
188
         * Save
189
         */
190
        $this->saveUser($user);
191
192
        /**
193
         * Sync groups depending on the plugin setting.
194
         */
195
        $this->syncUserGroupsByAssertion($user, $this->getFirstAssertion($response));
196
197
        /**
198
         * after user save
199
         */
200
        $event = new Event();
201
        $event->sender = $response;
202
        $event->data = $user;
203
204
        $this->trigger(
205
            static::EVENT_AFTER_RESPONSE_TO_USER,
206
            $event
207
        );
208
    }
209
210
    /**
211
     * @param User $user
212
     * @param SamlResponse $response
213
     * @return ProviderIdentityRecord
214
     * @throws InvalidMessage
215
     * @throws UserException
216
     */
217
    protected function getIdentityByUserAndResponse(User $user, \LightSaml\Model\Protocol\Response $response)
218
    {
219
220
        $idpProvider = Saml::getInstance()->getProvider()->findByEntityId(
221
            $response->getIssuer()->getValue()
222
        )->one();
223
224
        /**
225
         * Get Identity
226
         */
227
        $identity = $this->forceGetIdentity(
228
            $this->getFirstAssertion($response)->getSubject()->getNameID()->getValue(),
229
            $idpProvider
230
        );
231
232
        /**
233
         * Get Session
234
         */
235
        $sessionIndex = null;
236
        if ($response->getFirstAssertion()->hasAnySessionIndex()) {
237
            $sessionIndex = $response->getFirstAssertion()->getFirstAuthnStatement()->getSessionIndex();
238
        }
239
240
        /**
241
         * Set Identity Properties
242
         */
243
        $identity->userId = $user->id;
244
        $identity->enabled = true;
245
        $identity->sessionId = $sessionIndex;
246
        return $identity;
247
    }
248
249
    /**
250
     * @param User $user
251
     * @return bool
252
     * @throws UserException
253
     * @throws \Throwable
254
     * @throws \craft\errors\ElementNotFoundException
255
     * @throws \yii\base\Exception
256
     */
257
    protected function saveUser(User $user)
258
    {
259
        if (! \Craft::$app->getElements()->saveElement($user)) {
260
            Saml::error(
261
                'User save failed: ' . json_encode($user->getErrors())
262
            );
263
            throw new UserException("User save failed: " . json_encode($user->getErrors()));
264
        }
265
266
        return true;
267
    }
268
269
    /**
270
     * @param $username
271
     * @return User
272
     * @throws UserException
273
     */
274
    protected function getUser($username)
275
    {
276
        return $this->forceGetUser($username);
277
    }
278
279
    /**
280
     * @param User $user
281
     * @param SamlResponse $response
282
     * @throws UserException
283
     * @throws \Throwable
284
     */
285
    protected function constructUser(User $user, \LightSaml\Model\Protocol\Response $response)
286
    {
287
        /**
288
         * Is User Active?
289
         */
290
        if (! UserHelper::isUserActive($user)) {
291
            if (! Saml::getInstance()->getSettings()->enableUsers) {
292
                throw new UserException('User access denied.');
293
            }
294
            UserHelper::enableUser($user);
295
        }
296
297
        $assertion = $this->getFirstAssertion($response);
298
299
        if ($assertion->getFirstAttributeStatement()) {
300
            /**
301
             *
302
             */
303
            $this->transformToUser($response, $user);
304
        } else {
305
306
            /**
307
             * There doesn't seem to be any attribute statements.
308
             * Try and use username for the email and move on.
309
             */
310
            \Craft::warning(
311
                'No attribute statements found! Trying to assign username as the email.',
312
                Saml::getInstance()->getHandle()
313
            );
314
            $user->email = $user->email ?: $user->username;
315
        }
316
    }
317
318
    /**
319
     * @param string $nameId
320
     * @param ProviderInterface $provider
321
     * @return ProviderIdentityRecord
322
     * @throws UserException
323
     */
324
    protected function forceGetIdentity($nameId, ProviderInterface $provider)
325
    {
326
327
        /**
328
         * @var \flipbox\saml\sp\records\ProviderIdentityRecord $identity
329
         */
330
        if (! $identity = Saml::getInstance()->getProviderIdentity()->findByNameId(
331
            $nameId,
332
            $provider
333
        )->one()
334
        ) {
335
            if (! Saml::getInstance()->getSettings()->createUser) {
336
                throw new UserException("System doesn't have permission to create a new user.");
337
            }
338
339
            /**
340
             * Create the new identity if one wasn't found above.
341
             * Since we now have the user id, and we might not have above,
342
             * do this last.
343
             */
344
            $identity = new ProviderIdentityRecord(
345
                [
346
                    'providerId' => $provider->id,
0 ignored issues
show
Bug introduced by
Accessing id on the interface flipbox\saml\core\records\ProviderInterface suggest that you code against a concrete implementation. How about adding an instanceof check?

If you access a property on an interface, you most likely code against a concrete implementation of the interface.

Available Fixes

  1. Adding an additional type check:

    interface SomeInterface { }
    class SomeClass implements SomeInterface {
        public $a;
    }
    
    function someFunction(SomeInterface $object) {
        if ($object instanceof SomeClass) {
            $a = $object->a;
        }
    }
    
  2. Changing the type hint:

    interface SomeInterface { }
    class SomeClass implements SomeInterface {
        public $a;
    }
    
    function someFunction(SomeClass $object) {
        $a = $object->a;
    }
    
Loading history...
347
                    'nameId'     => $nameId,
348
                ]
349
            );
350
        }
351
352
        return $identity;
353
    }
354
355
    /**
356
     * @param $username
357
     * @return User
358
     * @throws UserException
359
     */
360
    protected function forceGetUser($username)
361
    {
362
363
        /**
364
         * Is there a user that exists already?
365
         */
366
        if ($user = $this->getUserByUsernameOrEmail($username)) {
367
            /**
368
             * System check for whether we are allowed merge with this this user
369
             */
370
            if (! Saml::getInstance()->getSettings()->mergeLocalUsers) {
371
                //don't continue
372
                throw new UserException(
373
                    sprintf(
374
                        "User (%s) already exists.",
375
                        $username
376
                    )
377
                );
378
            }
379
        } else {
380
            /**
381
             * New User
382
             */
383
            $user = new User(
384
                [
385
                    'username' => $username
386
                ]
387
            );
388
        }
389
390
        return $user;
391
    }
392
393
    /**
394
     * @param $emailOrUsername
395
     * @return User|null
396
     */
397
    protected function getUserByUsernameOrEmail($usernameOrEmail, bool $archived = false)
398
    {
399
400
        return User::find()
401
            ->where(
402
                [
403
                    'or',
404
                    ['username' => $usernameOrEmail],
405
                    ['email' => $usernameOrEmail]
406
                ]
407
            )
408
            ->addSelect(['users.password', 'users.passwordResetRequired'])
409
            ->status(null)
410
            ->archived($archived)
411
            ->one();
412
    }
413
414
    /**
415
     * @param User $user
416
     * @param Assertion $assertion
417
     * @return bool
418
     * @throws UserException
419
     */
420
    protected function syncUserGroupsByAssertion(User $user, Assertion $assertion)
421
    {
422
        /**
423
         * Nothing to do, move on
424
         */
425
        if (false === Saml::getInstance()->getSettings()->syncGroups) {
426
            return true;
427
        }
428
429
        $groupNames = Saml::getInstance()->getSettings()->groupAttributeNames;
430
        $groups = [];
431
        /**
432
         * Make sure there is an attribute statement
433
         */
434
        if (! $assertion->getFirstAttributeStatement()) {
435
            Saml::debug(
436
                'No attribute statement found, moving on.'
437
            );
438
            return true;
439
        }
440
441
        foreach ($assertion->getFirstAttributeStatement()->getAllAttributes() as $attribute) {
442
            Saml::debug(
443
                sprintf(
444
                    'Is attribute group? "%s" in %s',
445
                    $attribute->getName(),
446
                    json_encode($groupNames)
447
                )
448
            );
449
            /**
450
             * Is there a group name match?
451
             * Match the attribute name to the specified name in the plugin settings
452
             */
453
            if (in_array($attribute->getName(), $groupNames)) {
454
                /**
455
                 * Loop thru all of the attributes values because they could have multiple values.
456
                 * Example XML:
457
                 * <saml2:Attribute Name="groups" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
458
                 *   <saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
459
                 *           xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">
460
                 *           craft_admin
461
                 *           </saml2:AttributeValue>
462
                 *   <saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
463
                 *           xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">
464
                 *           craft_member
465
                 *           </saml2:AttributeValue>
466
                 * </saml2:Attribute>
467
                 */
468
                foreach ($attribute->getAllAttributeValues() as $value) {
469
                    if ($group = $this->findOrCreateUserGroup($value)) {
470
                        Saml::debug(
471
                            sprintf(
472
                                'Assigning group: %s',
473
                                $group->name
474
                            )
475
                        );
476
                        $groups[] = $group->id;
477
                    }
478
                }
479
            }
480
        }
481
        /**
482
         * just return if this is empty
483
         */
484
        if (empty($groups)) {
485
            return true;
486
        }
487
488
        return \Craft::$app->getUsers()->assignUserToGroups($user->id, $groups);
489
    }
490
491
    /**
492
     * @param SamlResponse $response
493
     * @param User $user
494
     * @return User
495
     */
496
    protected function transformToUser(
497
        SamlResponse $response,
498
        User $user
499
    ) {
500
501
        $assertion = $response->getFirstAssertion();
502
503
        /**
504
         * Check the provider first
505
         */
506
        $attributeMap = Provider::providerMappingToKeyValue(
507
            $idpProvider = Saml::getInstance()->getProvider()->findByEntityId(
508
                $response->getIssuer()->getValue()
509
            )->one()
510
        ) ?:
511
            Saml::getInstance()->getSettings()->responseAttributeMap;
512
513
        /**
514
         * Loop thru attributes and set to the user
515
         */
516
        foreach ($assertion->getFirstAttributeStatement()->getAllAttributes() as $attribute) {
517
            if (isset($attributeMap[$attribute->getName()])) {
518
                $craftProperty = $attributeMap[$attribute->getName()];
519
                $this->assignUserProperty(
520
                    $user,
521
                    $attribute,
522
                    $craftProperty
523
                );
524
            }
525
        }
526
527
        return $user;
528
    }
529
530
    /**
531
     * @param User $user
532
     * @param Attribute $attribute
533
     * @param string $craftProperty
534
     */
535
    protected function assignUserProperty(
536
        User $user,
537
        Attribute $attribute,
538
        string $craftProperty
539
    ) {
540
541
        if (is_string($craftProperty) && property_exists($user, $craftProperty)) {
542
            Saml::debug(
543
                sprintf(
544
                    'Attribute %s is scalar and should set value "%s" to user->%s',
545
                    $attribute->getName(),
546
                    $attribute->getFirstAttributeValue(),
547
                    $craftProperty
548
                )
549
            );
550
            $user->{$craftProperty} = $attribute->getFirstAttributeValue();
551
        } elseif (is_callable($craftProperty)) {
552
            Saml::debug(
553
                sprintf(
554
                    'Attribute %s is handled with a callable.',
555
                    $attribute->getName()
556
                )
557
            );
558
            call_user_func($craftProperty, $user, $attribute);
559
        }
560
    }
561
562
    /**
563
     * @param string $groupName
564
     * @return UserGroup
565
     * @throws UserException
566
     * @throws \craft\errors\WrongEditionException
567
     */
568
    protected function findOrCreateUserGroup($groupName): UserGroup
569
    {
570
571
        $groupHandle = StringHelper::camelCase($groupName);
572
573
        if (! $userGroup = \Craft::$app->getUserGroups()->getGroupByHandle($groupHandle)) {
574
            if (! \Craft::$app->getUserGroups()->saveGroup(
575
                $userGroup = new UserGroup(
576
                    [
577
                        'name'   => $groupName,
578
                        'handle' => $groupHandle,
579
                    ]
580
                )
581
            )
582
            ) {
583
                throw new UserException("Error saving new group {$groupHandle}");
584
            }
585
        }
586
587
        return $userGroup;
588
    }
589
590
    /**
591
     * @param ProviderIdentityRecord $identity
592
     * @return bool
593
     * @throws UserException
594
     * @throws \Throwable
595
     */
596
    protected function loginUser(\flipbox\saml\sp\records\ProviderIdentityRecord $identity)
597
    {
598
        if ($identity->getUser()->getStatus() !== User::STATUS_ACTIVE) {
599
            if (! \Craft::$app->getUsers()->activateUser($identity->getUser())) {
600
                throw new UserException("Can't activate user.");
601
            }
602
        }
603
604
        if (\Craft::$app->getUser()->login(
605
            $identity->getUser(),
606
            /**
607
             * @todo read session duration from the response
608
             */
609
            \Craft::$app->getConfig()->getGeneral()->userSessionDuration
610
        )
611
        ) {
612
            $identity->lastLoginDate = new \DateTime();
0 ignored issues
show
Documentation introduced by
The property lastLoginDate does not exist on object<flipbox\saml\sp\r...ProviderIdentityRecord>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
613
        } else {
614
            throw new UserException("User login failed.");
615
        }
616
617
        return true;
618
    }
619
}
620