Passed
Pull Request — 1.11.x (#4739)
by Angel Fernando Quiroz
08:48
created

OAuth2::updateUser()   A

Complexity

Conditions 4
Paths 6

Size

Total Lines 51
Code Lines 36

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 36
c 2
b 0
f 0
dl 0
loc 51
rs 9.344
cc 4
nc 6
nop 2

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
/* For license terms, see /license.txt */
3
4
use Chamilo\CoreBundle\Entity\ExtraFieldValues;
5
use Chamilo\CoreBundle\Entity\TrackELogin;
6
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
7
use League\OAuth2\Client\Provider\AbstractProvider;
8
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
9
use League\OAuth2\Client\Provider\GenericProvider;
10
use League\OAuth2\Client\Token\AccessToken;
11
use League\OAuth2\Client\Tool\ArrayAccessorTrait;
12
13
/**
14
 * OAuth2 plugin class.
15
 *
16
 * @author Sébastien Ducoulombier <[email protected]>
17
 * inspired by AzureActiveDirectory plugin class from Angel Fernando Quiroz Campos <[email protected]>
18
 *
19
 * @package chamilo.plugin.oauth2
20
 */
21
class OAuth2 extends Plugin
22
{
23
    use ArrayAccessorTrait;
24
25
    public const SETTING_ENABLE = 'enable';
26
27
    public const SETTING_FORCE_REDIRECT = 'force_redirect';
28
    public const SETTING_SKIP_FORCE_REDIRECT_IN = 'skip_force_redirect_in';
29
30
    public const SETTING_CLIENT_ID = 'client_id';
31
    public const SETTING_CLIENT_SECRET = 'client_secret';
32
33
    public const SETTING_AUTHORIZE_URL = 'authorize_url';
34
    public const SETTING_SCOPES = 'scopes';
35
    public const SETTING_SCOPE_SEPARATOR = 'scope_separator';
36
37
    public const SETTING_ACCESS_TOKEN_URL = 'access_token_url';
38
    public const SETTING_ACCESS_TOKEN_METHOD = 'access_token_method';
39
    // const SETTING_ACCESS_TOKEN_RESOURCE_OWNER_ID = 'access_token_resource_owner_id';
40
41
    public const SETTING_RESOURCE_OWNER_DETAILS_URL = 'resource_owner_details_url';
42
43
    public const SETTING_RESPONSE_ERROR = 'response_error';
44
    public const SETTING_RESPONSE_CODE = 'response_code';
45
    public const SETTING_RESPONSE_RESOURCE_OWNER_ID = 'response_resource_owner_id';
46
47
    public const SETTING_UPDATE_USER_INFO = 'update_user_info';
48
    public const SETTING_CREATE_NEW_USERS = 'create_new_users';
49
    public const SETTING_RESPONSE_RESOURCE_OWNER_FIRSTNAME = 'response_resource_owner_firstname';
50
    public const SETTING_RESPONSE_RESOURCE_OWNER_LASTNAME = 'response_resource_owner_lastname';
51
    public const SETTING_RESPONSE_RESOURCE_OWNER_STATUS = 'response_resource_owner_status';
52
    public const SETTING_RESPONSE_RESOURCE_OWNER_TEACHER_STATUS = 'response_resource_owner_teacher_status';
53
    public const SETTING_RESPONSE_RESOURCE_OWNER_SESSADMIN_STATUS = 'response_resource_owner_sessadmin_status';
54
    public const SETTING_RESPONSE_RESOURCE_OWNER_DRH_STATUS = 'response_resource_owner_drh_status';
55
    public const SETTING_RESPONSE_RESOURCE_OWNER_STUDENT_STATUS = 'response_resource_owner_student_status';
56
    public const SETTING_RESPONSE_RESOURCE_OWNER_ANON_STATUS = 'response_resource_owner_anon_status';
57
    public const SETTING_RESPONSE_RESOURCE_OWNER_EMAIL = 'response_resource_owner_email';
58
    public const SETTING_RESPONSE_RESOURCE_OWNER_USERNAME = 'response_resource_owner_username';
59
60
    public const SETTING_RESPONSE_RESOURCE_OWNER_URLS = 'response_resource_owner_urls';
61
62
    public const SETTING_LOGOUT_URL = 'logout_url';
63
64
    public const SETTING_BLOCK_NAME = 'block_name';
65
66
    public const SETTING_MANAGEMENT_LOGIN_ENABLE = 'management_login_enable';
67
    public const SETTING_MANAGEMENT_LOGIN_NAME = 'management_login_name';
68
69
    public const SETTING_ALLOW_THIRD_PARTY_LOGIN = 'allow_third_party_login';
70
71
    public const EXTRA_FIELD_OAUTH2_ID = 'oauth2_id';
72
73
    private const DEBUG = false;
74
75
    protected function __construct()
76
    {
77
        parent::__construct(
78
            '0.1',
79
            'Sébastien Ducoulombier',
80
            [
81
                self::SETTING_ENABLE => 'boolean',
82
83
                self::SETTING_FORCE_REDIRECT => 'boolean',
84
                self::SETTING_SKIP_FORCE_REDIRECT_IN => 'text',
85
86
                self::SETTING_CLIENT_ID => 'text',
87
                self::SETTING_CLIENT_SECRET => 'text',
88
89
                self::SETTING_AUTHORIZE_URL => 'text',
90
                self::SETTING_SCOPES => 'text',
91
                self::SETTING_SCOPE_SEPARATOR => 'text',
92
93
                self::SETTING_ACCESS_TOKEN_URL => 'text',
94
                self::SETTING_ACCESS_TOKEN_METHOD => [
95
                    'type' => 'select',
96
                    'options' => [
97
                        AbstractProvider::METHOD_POST => 'POST',
98
                        AbstractProvider::METHOD_GET => 'GET',
99
                    ],
100
                ],
101
                // self::SETTING_ACCESS_TOKEN_RESOURCE_OWNER_ID => 'text',
102
103
                self::SETTING_RESOURCE_OWNER_DETAILS_URL => 'text',
104
105
                self::SETTING_RESPONSE_ERROR => 'text',
106
                self::SETTING_RESPONSE_CODE => 'text',
107
                self::SETTING_RESPONSE_RESOURCE_OWNER_ID => 'text',
108
109
                self::SETTING_UPDATE_USER_INFO => 'boolean',
110
                self::SETTING_CREATE_NEW_USERS => 'boolean',
111
                self::SETTING_RESPONSE_RESOURCE_OWNER_FIRSTNAME => 'text',
112
                self::SETTING_RESPONSE_RESOURCE_OWNER_LASTNAME => 'text',
113
                self::SETTING_RESPONSE_RESOURCE_OWNER_STATUS => 'text',
114
                self::SETTING_RESPONSE_RESOURCE_OWNER_TEACHER_STATUS => 'text',
115
                self::SETTING_RESPONSE_RESOURCE_OWNER_SESSADMIN_STATUS => 'text',
116
                self::SETTING_RESPONSE_RESOURCE_OWNER_DRH_STATUS => 'text',
117
                self::SETTING_RESPONSE_RESOURCE_OWNER_STUDENT_STATUS => 'text',
118
                self::SETTING_RESPONSE_RESOURCE_OWNER_ANON_STATUS => 'text',
119
                self::SETTING_RESPONSE_RESOURCE_OWNER_EMAIL => 'text',
120
                self::SETTING_RESPONSE_RESOURCE_OWNER_USERNAME => 'text',
121
122
                self::SETTING_RESPONSE_RESOURCE_OWNER_URLS => 'text',
123
124
                self::SETTING_LOGOUT_URL => 'text',
125
126
                self::SETTING_BLOCK_NAME => 'text',
127
128
                self::SETTING_MANAGEMENT_LOGIN_ENABLE => 'boolean',
129
                self::SETTING_MANAGEMENT_LOGIN_NAME => 'text',
130
131
                self::SETTING_ALLOW_THIRD_PARTY_LOGIN => 'boolean',
132
            ]
133
        );
134
    }
135
136
    /**
137
     * Instance the plugin.
138
     *
139
     * @staticvar null $result
140
     *
141
     * @return $this
142
     */
143
    public static function create(): OAuth2
144
    {
145
        static $result = null;
146
147
        return $result ?: $result = new self();
148
    }
149
150
    public function getProvider(): GenericProvider
151
    {
152
        $redirectUri = api_get_path(WEB_PLUGIN_PATH).'oauth2/src/callback.php';
153
        // In cases not precisely defined yet, this alternative version might be necessary - see BT#20611
154
        //$redirectUri = api_get_path(WEB_PATH).'authorization-code/callback';
155
        $options = [
156
            'clientId' => $this->get(self::SETTING_CLIENT_ID),
157
            'clientSecret' => $this->get(self::SETTING_CLIENT_SECRET),
158
            'redirectUri' => $redirectUri,
159
            'urlAuthorize' => $this->get(self::SETTING_AUTHORIZE_URL),
160
            'urlResourceOwnerDetails' => $this->get(self::SETTING_RESOURCE_OWNER_DETAILS_URL),
161
        ];
162
163
        if ('' === $scopeSeparator = (string) $this->get(self::SETTING_SCOPE_SEPARATOR)) {
164
            $scopeSeparator = ' ';
165
        }
166
167
        $options['scopeSeparator'] = $scopeSeparator;
168
169
        if ('' !== $scopes = (string) $this->get(self::SETTING_SCOPES)) {
170
            $options['scopes'] = explode($scopeSeparator, $scopes);
171
        }
172
173
        if ('' !== $urlAccessToken = (string) $this->get(self::SETTING_ACCESS_TOKEN_URL)) {
174
            $options['urlAccessToken'] = $urlAccessToken;
175
        }
176
177
        if ('' !== $accessTokenMethod = (string) $this->get(self::SETTING_ACCESS_TOKEN_METHOD)) {
178
            $options['accessTokenMethod'] = $accessTokenMethod;
179
        }
180
181
//        if ('' !== $accessTokenResourceOwnerId = (string) $this->get(self::SETTING_ACCESS_TOKEN_RESOURCE_OWNER_ID)) {
182
//            $options['accessTokenResourceOwnerId'] = $accessTokenResourceOwnerId;
183
//        }
184
185
        if ('' !== $responseError = (string) $this->get(self::SETTING_RESPONSE_ERROR)) {
186
            $options['responseError'] = $responseError;
187
        }
188
189
        if ('' !== $responseCode = (string) $this->get(self::SETTING_RESPONSE_CODE)) {
190
            $options['responseCode'] = $responseCode;
191
        }
192
193
        if ('' !== $responseResourceOwnerId = (string) $this->get(self::SETTING_RESPONSE_RESOURCE_OWNER_ID)) {
194
            $options['responseResourceOwnerId'] = $responseResourceOwnerId;
195
        }
196
197
        return new GenericProvider($options);
198
    }
199
200
    /**
201
     * @throws IdentityProviderException
202
     *
203
     * @return array user information, as returned by api_get_user_info(userId)
204
     */
205
    public function getUserInfo(GenericProvider $provider, AccessToken $accessToken): array
206
    {
207
        $url = $provider->getResourceOwnerDetailsUrl($accessToken);
208
        $request = $provider->getAuthenticatedRequest($provider::METHOD_GET, $url, $accessToken);
209
        $response = $provider->getParsedResponse($request);
210
        $this->log('response', print_r($response, true));
211
212
        if (false === is_array($response)) {
213
            $this->log('invalid response', print_r($response, true));
214
            throw new UnexpectedValueException($this->get_lang('InvalidJsonReceivedFromProvider'));
215
        }
216
        $resourceOwnerId = $this->getValueByKey(
217
            $response,
218
            $this->get(self::SETTING_RESPONSE_RESOURCE_OWNER_ID)
219
        );
220
        if (empty($resourceOwnerId)) {
221
            $this->log('missing setting', 'response_resource_owner_id');
222
            throw new RuntimeException($this->get_lang('WrongResponseResourceOwnerId'));
223
        }
224
        $this->log('response resource owner id', $this->get(self::SETTING_RESPONSE_RESOURCE_OWNER_ID));
225
        $extraFieldValue = new ExtraFieldValue('user');
226
        $result = $extraFieldValue->get_item_id_from_field_variable_and_field_value(
227
            self::EXTRA_FIELD_OAUTH2_ID,
228
            $resourceOwnerId
229
        );
230
        if (false === $result) {
231
            $this->log('user not found', "extrafield 'oauth2_id' with value '$resourceOwnerId'");
232
233
            $username = $this->getValueByKey(
234
                $response,
235
                $this->get(self::SETTING_RESPONSE_RESOURCE_OWNER_USERNAME),
236
                'oauth2user_'.$resourceOwnerId
237
            );
238
239
            $userInfo = api_get_user_info_from_username($username);
240
241
            if (false !== $userInfo && !empty($userInfo['id']) && 'platform' === $userInfo['auth_source']) {
242
                $this->log('platform user exists', print_r($userInfo, true));
243
244
                $userId = $userInfo['id'];
245
            } else {
246
                // authenticated user not found in internal database
247
                if ('true' !== $this->get(self::SETTING_CREATE_NEW_USERS)) {
248
                    $this->log('exception', 'create_new_users setting is disabled');
249
                    $message = sprintf(
250
                        $this->get_lang('NoUserAccountAndUserCreationNotAllowed'),
251
                        Display::encrypted_mailto_link(api_get_setting('emailAdministrator'))
252
                    );
253
                    throw new RuntimeException($message);
254
                }
255
256
                require_once __DIR__.'/../../../main/auth/external_login/functions.inc.php';
257
258
                $firstName = $this->getValueByKey(
259
                    $response,
260
                    $this->get(self::SETTING_RESPONSE_RESOURCE_OWNER_FIRSTNAME),
261
                    $this->get_lang('DefaultFirstname')
262
                );
263
                $lastName = $this->getValueByKey(
264
                    $response,
265
                    $this->get(self::SETTING_RESPONSE_RESOURCE_OWNER_LASTNAME),
266
                    $this->get_lang('DefaultLastname')
267
                );
268
                $status = $this->mapUserStatusFromResponse($response);
269
                $email = $this->getValueByKey(
270
                    $response,
271
                    $this->get(self::SETTING_RESPONSE_RESOURCE_OWNER_EMAIL),
272
                    'oauth2user_'.$resourceOwnerId.'@'.(gethostname() or 'localhost')
273
                );
274
275
                $userInfo = [
276
                    'firstname' => $firstName,
277
                    'lastname' => $lastName,
278
                    'status' => $status,
279
                    'email' => $email,
280
                    'username' => $username,
281
                    'auth_source' => 'oauth2',
282
                ];
283
                $userId = external_add_user($userInfo);
284
                if (false === $userId) {
285
                    $this->log('user not created', print_r($userInfo, true));
286
                    throw new RuntimeException($this->get_lang('FailedUserCreation'));
287
                }
288
                $this->log('user created', (string) $userId);
289
            }
290
291
            $this->updateUser($userId, $response);
292
            // Not checking function update_extra_field_value return value because not reliable
293
            UserManager::update_extra_field_value($userId, self::EXTRA_FIELD_OAUTH2_ID, $resourceOwnerId);
294
            $this->updateUserUrls($userId, $response);
295
        } else {
296
            $this->log('user found', "extrafield 'oauth2_id' with value '$resourceOwnerId'");
297
            // authenticated user found in internal database
298
            if (is_array($result) and array_key_exists('item_id', $result)) {
299
                $userId = $result['item_id'];
300
            } else {
301
                $userId = $result;
302
            }
303
            if ('true' === $this->get(self::SETTING_UPDATE_USER_INFO)) {
304
                $this->updateUser($userId, $response);
305
                $this->updateUserUrls($userId, $response);
306
            }
307
        }
308
        $userInfo = api_get_user_info($userId);
309
        if (empty($userInfo)) {
310
            $this->log('user info not found', (string) $userId);
311
            throw new LogicException($this->get_lang('InternalErrorCannotGetUserInfo'));
312
        }
313
314
        $this->log('user info', print_r($userInfo, true));
315
316
        return $userInfo;
317
    }
318
319
    public function getSignInURL(): string
320
    {
321
        return api_get_path(WEB_PLUGIN_PATH).$this->get_name().'/src/callback.php';
322
        // In cases not precisely defined yet, this alternative version might be necessary - see BT#20611
323
        //return api_get_path(WEB_PATH).'authorization-code/callback';
324
    }
325
326
    public function getLogoutUrl(): string
327
    {
328
        $token = ChamiloSession::read('oauth2AccessToken');
329
        $idToken = !empty($token['id_token']) ? $token['id_token'] : null;
330
331
        return $this->get(self::SETTING_LOGOUT_URL).'?'.http_build_query(
332
            [
333
                'id_token_hint' => $idToken,
334
                'post_logout_redirect_uri' => api_get_path(WEB_PATH),
335
            ]
336
        );
337
    }
338
339
    /**
340
     * Create extra fields for user when installing.
341
     */
342
    public function install()
343
    {
344
        UserManager::create_extra_field(
345
            self::EXTRA_FIELD_OAUTH2_ID,
346
            ExtraField::FIELD_TYPE_TEXT,
347
            $this->get_lang('OAuth2Id'),
348
            ''
349
        );
350
    }
351
352
    public static function isFirstLoginAfterAuthSource(int $userId): bool
353
    {
354
        $em = Database::getManager();
355
356
        $lastLogin = $em
357
            ->getRepository(TrackELogin::class)
358
            ->findOneBy(
359
                ['loginUserId' => $userId],
360
                ['loginDate' => 'DESC']
361
            )
362
        ;
363
364
        if (!$lastLogin) {
365
            return false;
366
        }
367
368
        $objExtraField = new ExtraField('user');
369
        $field = $objExtraField->getHandlerEntityByFieldVariable(self::EXTRA_FIELD_OAUTH2_ID);
370
371
        $fieldValue = $em
372
            ->getRepository(ExtraFieldValues::class)
373
            ->findOneBy(
374
                ['itemId' => $userId, 'field' => $field]
375
            )
376
        ;
377
378
        if (!$fieldValue) {
379
            return false;
380
        }
381
382
        return $fieldValue->getCreatedAt() >= $lastLogin->getLoginDate();
383
    }
384
385
    private function mapUserStatusFromResponse(array $response, int $defaultStatus = STUDENT): bool
386
    {
387
        $status = $this->getValueByKey(
388
            $response,
389
            $this->get(self::SETTING_RESPONSE_RESOURCE_OWNER_STATUS),
390
            $defaultStatus
391
        );
392
393
        $map = array_flip([
394
            COURSEMANAGER => $this->get(self::SETTING_RESPONSE_RESOURCE_OWNER_TEACHER_STATUS),
395
            SESSIONADMIN => $this->get(self::SETTING_RESPONSE_RESOURCE_OWNER_SESSADMIN_STATUS),
396
            DRH => $this->get(self::SETTING_RESPONSE_RESOURCE_OWNER_DRH_STATUS),
397
            STUDENT => $this->get(self::SETTING_RESPONSE_RESOURCE_OWNER_STUDENT_STATUS),
398
            ANONYMOUS => $this->get(self::SETTING_RESPONSE_RESOURCE_OWNER_ANON_STATUS),
399
        ]);
400
401
        return $map[$status] ?? $status;
402
    }
403
404
    /**
405
     * Extends ArrayAccessorTrait::getValueByKey to return a list of values
406
     * $key can contain wild card character *
407
     * It will be replaced by 0, 1, 2 and so on as long as the resulting key exists in $data
408
     * This is a recursive function, allowing for more than one occurrence of the wild card character.
409
     */
410
    private function getValuesByKey(array $data, string $key, array $default = []): array
411
    {
412
        if (!is_string($key) || empty($key) || !count($data)) {
413
            return $default;
414
        }
415
        $pos = strpos($key, '*');
416
        if ($pos === false) {
417
            $value = $this->getValueByKey($data, $key);
418
419
            return is_null($value) ? [] : [$value];
420
        }
421
        $values = [];
422
        $beginning = substr($key, 0, $pos);
423
        $remaining = substr($key, $pos + 1);
424
        $index = 0;
425
        do {
426
            $newValues = $this->getValuesByKey(
427
                $data,
428
                $beginning.$index.$remaining
429
            );
430
            $values = array_merge($values, $newValues);
431
            $index++;
432
        } while ($newValues);
433
434
        return $values;
435
    }
436
437
    /**
438
     * @throws Exception
439
     */
440
    private function updateUser($userId, $response)
441
    {
442
        $user = UserManager::getRepository()->find($userId);
443
        $user->setFirstname(
444
            $this->getValueByKey(
445
                $response,
446
                $this->get(
447
                    self::SETTING_RESPONSE_RESOURCE_OWNER_FIRSTNAME
448
                ),
449
                $user->getFirstname()
450
            )
451
        );
452
        $user->setLastname(
453
            $this->getValueByKey(
454
                $response,
455
                $this->get(self::SETTING_RESPONSE_RESOURCE_OWNER_LASTNAME),
456
                $user->getLastname()
457
            )
458
        );
459
        $user->setUserName(
460
            $this->getValueByKey(
461
                $response,
462
                $this->get(self::SETTING_RESPONSE_RESOURCE_OWNER_USERNAME),
463
                $user->getUsername()
464
            )
465
        );
466
        $user->setEmail(
467
            $this->getValueByKey(
468
                $response,
469
                $this->get(self::SETTING_RESPONSE_RESOURCE_OWNER_EMAIL),
470
                $user->getEmail()
471
            )
472
        );
473
        $status = $this->mapUserStatusFromResponse(
474
            $response,
475
            $user->getStatus()
476
        );
477
        $user->setAuthSource('oauth2');
478
        $configFilePath = __DIR__.'/../config.php';
479
        if (file_exists($configFilePath)) {
480
            require_once $configFilePath;
481
            $functionName = 'oauth2UpdateUserFromResourceOwnerDetails';
482
            if (function_exists($functionName)) {
483
                $functionName($response, $user);
484
            }
485
        }
486
487
        try {
488
            UserManager::getManager()->updateUser($user);
489
        } catch (UniqueConstraintViolationException $exception) {
490
            throw new Exception(get_lang('UserNameUsedTwice'));
491
        }
492
    }
493
494
    /**
495
     * Updates the Access URLs associated to a user
496
     * according to the OAuth2 server response resource owner
497
     * if multi-URL is enabled and SETTING_RESPONSE_RESOURCE_OWNER_URLS defined.
498
     *
499
     * @param $userId integer
500
     * @param $response array
501
     */
502
    private function updateUserUrls($userId, $response)
503
    {
504
        if (api_is_multiple_url_enabled()) {
505
            $key = (string) $this->get(self::SETTING_RESPONSE_RESOURCE_OWNER_URLS);
506
            if (!empty($key)) {
507
                $availableUrls = [];
508
                foreach (UrlManager::get_url_data() as $existingUrl) {
509
                    $urlId = $existingUrl['id'];
510
                    $availableUrls[strval($urlId)] = $urlId;
511
                    $availableUrls[$existingUrl['url']] = $urlId;
512
                }
513
                $allowedUrlIds = [];
514
                foreach ($this->getValuesByKey($response, $key) as $value) {
515
                    if (array_key_exists($value, $availableUrls)) {
516
                        $allowedUrlIds[] = $availableUrls[$value];
517
                    } else {
518
                        $newValue = ($value[-1] === '/') ? substr($value, 0, -1) : $value.'/';
519
                        if (array_key_exists($newValue, $availableUrls)) {
520
                            $allowedUrlIds[] = $availableUrls[$newValue];
521
                        }
522
                    }
523
                }
524
                $grantedUrlIds = [];
525
                foreach (UrlManager::get_access_url_from_user($userId) as $grantedUrl) {
526
                    $grantedUrlIds[] = $grantedUrl['access_url_id'];
527
                }
528
                foreach (array_diff($grantedUrlIds, $allowedUrlIds) as $extraUrlId) {
529
                    UrlManager::delete_url_rel_user($userId, $extraUrlId);
530
                }
531
                foreach (array_diff($allowedUrlIds, $grantedUrlIds) as $missingUrlId) {
532
                    UrlManager::add_user_to_url($userId, $missingUrlId);
533
                }
534
            }
535
        }
536
    }
537
538
    private function log(string $key, string $content)
539
    {
540
        if (self::DEBUG) {
541
            error_log("OAuth2 plugin: $key: $content");
542
        }
543
    }
544
}
545