Completed
Push — master ( b0095b...64e902 )
by Damien
05:32
created

User::sync()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 20

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 1

Importance

Changes 0
Metric Value
dl 0
loc 20
ccs 9
cts 9
cp 1
rs 9.6
c 0
b 0
f 0
cc 1
nc 1
nop 5
crap 1
1
<?php
2
3
/**
4
 * @copyright  Copyright (c) Flipbox Digital Limited
5
 */
6
7
namespace flipbox\saml\sp\services\login;
8
9
use craft\base\Field;
10
use craft\elements\User as UserElement;
11
use craft\models\FieldLayout;
12
use flipbox\saml\core\exceptions\InvalidMessage;
13
use flipbox\saml\core\helpers\MessageHelper;
14
use flipbox\saml\core\helpers\ProviderHelper;
15
use flipbox\saml\core\records\ProviderInterface;
16
use flipbox\saml\sp\helpers\UserHelper;
17
use flipbox\saml\sp\models\Settings;
18
use flipbox\saml\sp\records\ProviderIdentityRecord;
19
use flipbox\saml\sp\records\ProviderRecord;
20
use flipbox\saml\sp\Saml;
21
use SAML2\Response as SamlResponse;
22
use yii\base\UserException;
23
24
/**
25
 * Class User
26
 * @package flipbox\saml\sp\services
27
 */
28
class User
29
{
30
    use AssertionTrait;
31
    /**
32
     * @var FieldLayout|null
33
     */
34
    private $fieldLayout;
35
    /**
36
     * @var Field[]
37
     */
38
    private $fields = [];
39
40
    /**
41
     * @param SamlResponse $response
42
     * @return UserElement
43
     * @throws InvalidMessage
44
     * @throws UserException
45
     */
46 3
    public function getByResponse(SamlResponse $response, ProviderRecord $serviceProvider)
47
    {
48
49 3
        $assertion = $this->getFirstAssertion($response, $serviceProvider);
50
51 3
        if (! $assertion->getNameId()) {
52 3
            throw new InvalidMessage('Name ID is missing.');
53
        }
54
55 3
        Saml::debug('NameId: ' . $assertion->getNameId()->getValue());
56
        /**
57
         * Get username from the NameID
58
         *
59
         * @todo Give an option to map another attribute value to $username (like email)
60
         */
61 3
        $username = $assertion->getNameId()->getValue();
62
63 3
        return $this->find($username);
64
    }
65
66
    /**
67
     * @param ProviderIdentityRecord $identity
68
     * @return bool
69
     * @throws UserException
70
     * @throws \Throwable
71
     */
72
    public function login(\flipbox\saml\sp\records\ProviderIdentityRecord $identity)
73
    {
74
        if ($identity->getUser()->getStatus() !== UserElement::STATUS_ACTIVE) {
75
            if (! \Craft::$app->getUsers()->activateUser($identity->getUser())) {
76
                throw new UserException("Can't activate user.");
77
            }
78
        }
79
80
        if (\Craft::$app->getUser()->login(
81
            $identity->getUser(),
82
            /**
83
             * @todo read session duration from the response
84
             */
85
            \Craft::$app->getConfig()->getGeneral()->userSessionDuration
86
        )
87
        ) {
88
            $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...
89
        } else {
90
            throw new UserException("User login failed.");
91
        }
92
93
        return true;
94
    }
95
96
    /**
97
     * @param UserElement $user
98
     * @param SamlResponse $response
99
     * @param ProviderRecord $idp
100
     * @param Settings $settings
101
     * @throws UserException
102
     * @throws \Throwable
103
     * @throws \craft\errors\ElementNotFoundException
104
     * @throws \yii\base\Exception
105
     */
106 3
    public function sync(
107
        UserElement $user,
108
        SamlResponse $response,
109
        ProviderRecord $idp,
110
        ProviderRecord $sp,
111
        Settings $settings
112
    ) {
113
114
        // enable and transform the user
115 3
        $this->construct(
116 3
            $user,
117 1
            $response,
118 1
            $idp,
119 1
            $sp,
120 1
            $settings
121
        );
122
123
        // Save
124 3
        $this->save($user);
125 3
    }
126
127
    /**
128
     * @param UserElement $user
129
     * @return bool
130
     * @throws UserException
131
     * @throws \Throwable
132
     * @throws \craft\errors\ElementNotFoundException
133
     * @throws \yii\base\Exception
134
     */
135 3
    protected function save(UserElement $user)
136
    {
137 3
        if (! \Craft::$app->getElements()->saveElement($user)) {
138
            Saml::error(
139
                'User save failed: ' . \json_encode($user->getErrors())
140
            );
141
            throw new UserException("User save failed: " . \json_encode($user->getErrors()));
142
        }
143
144 3
        return true;
145
    }
146
147
    /**
148
     * Response Based Methods
149
     */
150
151
    /**
152
     * @param UserElement $user
153
     * @param SamlResponse $response
154
     * @param ProviderRecord $idp
155
     * @param Settings $settings
156
     * @throws UserException
157
     * @throws \Throwable
158
     */
159 3
    protected function construct(
160
        UserElement $user,
161
        SamlResponse $response,
162
        ProviderRecord $idp,
163
        ProviderRecord $sp,
164
        Settings $settings
165
    ) {
166
        /**
167
         * Is User Active?
168
         */
169 3
        if (! UserHelper::isUserActive($user)) {
170
            if (! $settings->enableUsers) {
171
                throw new UserException('User access denied.');
172
            }
173
            UserHelper::enableUser($user);
174
        }
175
176 3
        foreach ($this->getAssertions($response, $sp) as $assertion) {
177 3
            $hasAttributes = count($assertion->getAttributes()) > 0;
178 3
            Saml::debug('assertion attributes: ' . \json_encode($assertion->getAttributes()));
179 3
            if ($hasAttributes) {
180 3
                $this->transform(
181 3
                    $user,
182 1
                    $response,
183 1
                    $idp,
184 1
                    $sp,
185 1
                    $settings
186
                );
187
            } else {
188
                /**
189
                 * There doesn't seem to be any attribute statements.
190
                 * Try and use username for the email and move on.
191
                 */
192
                Saml::warning(
193
                    'No attribute statements found! Trying to assign username as the email.'
194
                );
195
                $user->email = $user->email ?: $user->username;
196
            }
197
        }
198 3
    }
199
200
    /**
201
     * @param UserElement $user
202
     * @param SamlResponse $response
203
     * @return UserElement
204
     */
205 3
    protected function transform(
206
        UserElement $user,
207
        SamlResponse $response,
208
        ProviderRecord $idp,
209
        ProviderRecord $sp,
210
        Settings $settings
211
    ) {
212
213 3
        foreach ($this->getAssertions($response, $sp) as $assertion) {
214
            /**
215
             * Check the provider first
216
             */
217 3
            $attributeMap = ProviderHelper::providerMappingToKeyValue(
218 3
                $idp
219
            ) ?:
220 3
                $settings->responseAttributeMap;
221
222 3
            Saml::debug('Attribute Map: ' . \json_encode($attributeMap));
223
224
            /**
225
             * Loop thru attributes and set to the user
226
             */
227 3
            foreach ($assertion->getAttributes() as $attributeName => $attributeValue) {
228 3
                Saml::debug('Attributes: ' . $attributeName . ' ' . \json_encode($attributeValue));
229 3
                if (isset($attributeMap[$attributeName])) {
230 3
                    $craftProperty = $attributeMap[$attributeName];
231 3
                    $this->assignProperty(
232 3
                        $user,
233 1
                        $attributeName,
234 1
                        $attributeValue,
235 1
                        $craftProperty
236
                    );
237
                } else {
238 3
                    Saml::debug('No match for: ' . $attributeName);
239
                }
240
            }
241
        }
242
243 3
        return $user;
244
    }
245
246
    /**
247
     * @param UserElement $user
248
     * @param $attributeName
249
     * @param $attributeValue
250
     * @param $craftProperty
251
     */
252 3
    protected function assignProperty(
253
        UserElement $user,
254
        $attributeName,
255
        $attributeValue,
256
        $craftProperty
257
    ) {
258
259 3
        $originalValues = $attributeValue;
260 3
        if (is_array($attributeValue)) {
261 3
            $attributeValue = isset($attributeValue[0]) ? $attributeValue[0] : null;
262
        }
263
264 3
        if (is_string($craftProperty) && in_array($craftProperty, $user->attributes())) {
265 3
            Saml::debug(
266 3
                sprintf(
267 3
                    'Attribute %s is scalar and should set value "%s" to user->%s',
268 1
                    $attributeName,
269 1
                    $attributeValue,
270 1
                    $craftProperty
271
                )
272
            );
273
274 3
            $this->setSimpleProperty($user, $craftProperty, $attributeValue);
275
        } elseif (is_callable($craftProperty)) {
276
            Saml::debug(
277
                sprintf(
278
                    'Attribute %s is handled with a callable.',
279
                    $attributeName
280
                )
281
            );
282
283
            call_user_func($craftProperty, $user, [
284
                $attributeName => $originalValues,
285
            ]);
286
        }
287 3
    }
288
289
    /**
290
     * @param UserElement $user
291
     * @return Field|null
292
     */
293 3
    protected function getFieldLayoutField(UserElement $user, $fieldHandle)
294
    {
295 3
        if (! $this->fieldLayout) {
296 3
            $this->fieldLayout = $user->getFieldLayout();
297
        }
298 3
        if (is_null($this->fieldLayout)) {
299
            return null;
300
        }
301
302 3
        if (! isset($this->fields[$fieldHandle])) {
303 3
            $this->fields[$fieldHandle] = $this->fieldLayout->getFieldByHandle($fieldHandle);
304
        }
305
306
307 3
        return $this->fields[$fieldHandle];
308
    }
309
310
    /**
311
     * @param UserElement $user
312
     * @param string $name
313
     * @param mixed $value
314
     */
315 3
    private function setSimpleProperty(UserElement $user, $name, $value)
316
    {
317 3
        $field = $this->getFieldLayoutField($user, $name);
318
319 3
        Saml::info(
320 3
            sprintf(
321 3
                '%s as %s. Is Field? %s',
322 1
                $name,
323 1
                $value,
324 3
                $field instanceof Field ? $field->id : 'Nope'
325
            )
326
        );
327
328 3
        if (! is_null($field)) {
329
            $user->setFieldValue($name, $value);
330
        } else {
331 3
            $user->{$name} = $value;
332
        }
333 3
    }
334
335
    /**************************************************
336
     * Craft User Methods
337
     **************************************************/
338
339
    /**
340
     * @param $username
341
     * @return UserElement
342
     * @throws UserException
343
     */
344 3
    protected function find($username)
345
    {
346 3
        return $this->forceGet($username);
347
    }
348
349
    /**
350
     * @param $username
351
     * @return UserElement
352
     * @throws UserException
353
     */
354 3
    protected function forceGet($username)
355
    {
356
357
        /**
358
         * Is there a user that exists already?
359
         */
360 3
        if ($user = $this->getByUsernameOrEmail($username)) {
361
            /**
362
             * System check for whether we are allowed merge with this this user
363
             */
364 3
            if (! Saml::getInstance()->getSettings()->mergeLocalUsers) {
365
                //don't continue
366
                throw new UserException(
367
                    sprintf(
368 2
                        "User (%s) already exists.",
369 1
                        $username
370
                    )
371
                );
372
            }
373
        } else {
374
            /**
375
             * New UserElement
376
             */
377
            $user = new UserElement(
378
                [
379
                    'username' => $username,
380
                ]
381
            );
382
        }
383
384 3
        return $user;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return $user; (array|boolean|craft\base\ElementInterface) is incompatible with the return type documented by flipbox\saml\sp\services\login\User::forceGet of type craft\elements\User.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
385
    }
386
387
    /**
388
     * @param $usernameOrEmail
389
     * @param bool $archived
390
     * @return array|bool|\craft\base\ElementInterface|UserElement|null
391
     */
392 3
    protected function getByUsernameOrEmail($usernameOrEmail, $archived = false)
393
    {
394
395 3
        return UserElement::find()
396 3
            ->where(
397
                [
398 3
                    'or',
399 3
                    ['username' => $usernameOrEmail],
400 3
                    ['email' => $usernameOrEmail],
401
                ]
402
            )
403 3
            ->addSelect(['users.password', 'users.passwordResetRequired'])
404 3
            ->status(null)
405 3
            ->archived($archived)
406 3
            ->one();
407
    }
408
}
409