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

OAuth2::log()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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