Passed
Push — master ( e9591a...c0a685 )
by Rutger
13:20
created

Oauth2Client   F

Complexity

Total Complexity 81

Size/Duplication

Total Lines 556
Duplicated Lines 0 %

Test Coverage

Coverage 90.21%

Importance

Changes 3
Bugs 0 Features 0
Metric Value
wmc 81
eloc 193
c 3
b 0
f 0
dl 0
loc 556
ccs 212
cts 235
cp 0.9021
rs 2

39 Methods

Rating   Name   Duplication   Size   Complexity  
A __set() 0 6 2
A getName() 0 3 1
A setRedirectUri() 0 16 5
A getRedirectUri() 0 12 4
A setName() 0 4 1
A getUserAccountSelection() 0 3 1
A setScopeAccess() 0 4 1
A endUsersMayAuthorizeClient() 0 3 1
A getOpenIdConnectUserinfoEncryptedResponseAlg() 0 3 1
A setOpenIdConnectUserinfoEncryptedResponseAlg() 0 4 1
A setUserAccountSelection() 0 4 1
A getScopeAccess() 0 3 1
A getDecryptedOldSecret() 0 3 1
A isAuthCodeWithoutPkceAllowed() 0 3 1
A validateNewSecret() 0 8 2
A rotateStorageEncryptionKeys() 0 23 4
A setSkipAuthorizationIfScopeIsAllowed() 0 4 1
A setAllowAuthCodeWithoutPkce() 0 4 1
A rotateStorageEncryptionKey() 0 10 4
A skipAuthorizationIfScopeIsAllowed() 0 3 1
A setOpenIdConnectAllowOfflineAccessWithoutConsent() 0 4 1
A isConfidential() 0 3 1
A validateGrantType() 0 8 2
A getUsedStorageEncryptionKeys() 0 21 5
A setClientCredentialsGrantUserId() 0 4 1
A getOldSecretValidUntil() 0 3 1
A getDecryptedSecret() 0 3 1
A setEndUsersMayAuthorizeClient() 0 4 1
A getEncryptedAttributes() 0 3 1
A getClientCredentialsGrantUserId() 0 3 1
A getGrantTypes() 0 3 1
A getOpenIdConnectAllowOfflineAccessWithoutConsent() 0 3 1
A setGrantTypes() 0 13 4
A rules() 0 12 1
B getAllowedScopes() 0 57 6
B validateSecret() 0 11 7
A validateAuthRequestScopes() 0 19 3
A behaviors() 0 4 1
B setSecret() 0 34 7

How to fix   Complexity   

Complex Class

Complex classes like Oauth2Client 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 Oauth2Client, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace rhertogh\Yii2Oauth2Server\models;
4
5
use rhertogh\Yii2Oauth2Server\helpers\DiHelper;
6
use rhertogh\Yii2Oauth2Server\interfaces\models\Oauth2ClientInterface;
7
use rhertogh\Yii2Oauth2Server\interfaces\models\Oauth2ClientScopeInterface;
8
use rhertogh\Yii2Oauth2Server\interfaces\models\Oauth2ScopeInterface;
9
use rhertogh\Yii2Oauth2Server\models\behaviors\DateTimeBehavior;
10
use rhertogh\Yii2Oauth2Server\models\traits\Oauth2ActiveRecordIdTrait;
11
use rhertogh\Yii2Oauth2Server\models\traits\Oauth2EnabledTrait;
12
use rhertogh\Yii2Oauth2Server\models\traits\Oauth2EntityIdentifierTrait;
13
use rhertogh\Yii2Oauth2Server\Oauth2Module;
14
use Yii;
15
use yii\base\Exception;
16
use yii\base\InvalidArgumentException;
17
use yii\base\InvalidConfigException;
18
use yii\base\UnknownPropertyException;
19
use yii\helpers\ArrayHelper;
20
use yii\helpers\Json;
21
22
class Oauth2Client extends base\Oauth2Client implements Oauth2ClientInterface
23
{
24
    use Oauth2ActiveRecordIdTrait;
25
    use Oauth2EntityIdentifierTrait;
26
    use Oauth2EnabledTrait;
27
28
    protected const ENCRYPTED_ATTRIBUTES = ['secret', 'old_secret'];
29
30
    /**
31
     * Minimum lenght for client secret.
32
     * @var int
33
     */
34
    public $minimumSecretLenth = 10;
35
36
    /////////////////////////////
37
    /// ActiveRecord Settings ///
38
    /////////////////////////////
39
40
    /**
41
     * @inheritDoc
42
     */
43 41
    public function behaviors()
44
    {
45 41
        return ArrayHelper::merge(parent::behaviors(), [
46 41
            'dateTimeBehavior' => DateTimeBehavior::class
47 41
        ]);
48
    }
49
50
    /**
51
     * @inheritDoc
52
     */
53 1
    public function rules()
54
    {
55 1
        return ArrayHelper::merge(parent::rules(), [
56 1
            [
57 1
                ['secret'],
58 1
                'required',
59 1
                'when' => fn(self $model) => $model->isConfidential(),
60 1
            ],
61 1
            [
62 1
                ['scope_access'],
63 1
                'in',
64 1
                'range' => static::SCOPE_ACCESSES,
65 1
            ]
66 1
        ]);
67
    }
68
69
    /////////////////////////
70
    /// Getters & Setters ///
71
    /////////////////////////
72
73
    /**
74
     * @inheritdoc
75
     */
76 42
    public function __set($name, $value)
77
    {
78 42
        if ($name === 'secret') { // Don't allow setting the secret via magic method.
79 1
            throw new UnknownPropertyException('For security the "secret" property must be set via setSecret()');
80
        } else {
81 41
            parent::__set($name, $value);
82
        }
83
    }
84
85
    /**
86
     * @inheritdoc
87
     */
88 4
    public function getName()
89
    {
90 4
        return $this->name;
91
    }
92
93
    /**
94
     * @inheritdoc
95
     */
96 1
    public function setName($name)
97
    {
98 1
        $this->name = $name;
99 1
        return $this;
100
    }
101
102
    /**
103
     * @inheritdoc
104
     * @throws InvalidConfigException
105
     */
106 5
    public function getRedirectUri()
107
    {
108 5
        $uri = $this->redirect_uris;
109 5
        if (is_string($uri)) {
0 ignored issues
show
introduced by
The condition is_string($uri) is always false.
Loading history...
110
            try {
111 2
                $uri = Json::decode($uri);
112 1
            } catch (InvalidArgumentException $e) {
113 1
                throw new InvalidConfigException('Invalid json in redirect_uris for client ' . $this->id, 0, $e);
114
            }
115
        }
116
117 4
        return is_array($uri) ? array_values($uri) : $uri;
0 ignored issues
show
introduced by
The condition is_array($uri) is always true.
Loading history...
118
    }
119
120
    /**
121
     * @inheritDoc
122
     */
123 4
    public function setRedirectUri($uri)
124
    {
125 4
        if (is_array($uri)) {
126 2
            foreach ($uri as $value) {
127 2
                if (!is_string($value)) {
128 1
                    throw new InvalidArgumentException('When $uri is an array, its values must be strings.');
129
                }
130
            }
131 1
            $uri = Json::encode($uri);
132 2
        } elseif (is_string($uri)) {
133 1
            $uri = Json::encode([$uri]);
134
        } else {
135 1
            throw new InvalidArgumentException('$uri must be a string or an array, got: ' . gettype($uri));
136
        }
137
138 2
        $this->redirect_uris = $uri;
0 ignored issues
show
Documentation Bug introduced by
It seems like $uri of type string is incompatible with the declared type array of property $redirect_uris.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
139
    }
140
141
    /**
142
     * @inheritDoc
143
     */
144 4
    public function getUserAccountSelection()
145
    {
146 4
        return $this->user_account_selection;
147
    }
148
149 3
    public function endUsersMayAuthorizeClient()
150
    {
151 3
        return $this->end_users_may_authorize_client;
152
    }
153
154
    public function setEndUsersMayAuthorizeClient($endUsersMayAuthorizeClient)
155
    {
156
        $this->end_users_may_authorize_client = $endUsersMayAuthorizeClient;
157
        return $this;
158
    }
159
160
    /**
161
     * @inheritDoc
162
     */
163 1
    public function setUserAccountSelection($userAccountSelectionConfig)
164
    {
165 1
        $this->user_account_selection = $userAccountSelectionConfig;
166 1
        return $this;
167
    }
168
169
    /**
170
     * @inheritDoc
171
     */
172 4
    public function isAuthCodeWithoutPkceAllowed()
173
    {
174 4
        return (bool)$this->allow_auth_code_without_pkce;
175
    }
176
177
    /**
178
     * @inheritDoc
179
     */
180 1
    public function setAllowAuthCodeWithoutPkce($allowAuthCodeWithoutPkce)
181
    {
182 1
        $this->allow_auth_code_without_pkce = $allowAuthCodeWithoutPkce;
183 1
        return $this;
184
    }
185
186
    /**
187
     * @inheritDoc
188
     */
189 2
    public function skipAuthorizationIfScopeIsAllowed()
190
    {
191 2
        return (bool)$this->skip_authorization_if_scope_is_allowed;
192
    }
193
194
    /**
195
     * @inheritDoc
196
     */
197 1
    public function setSkipAuthorizationIfScopeIsAllowed($skipAuthIfScopeIsAllowed)
198
    {
199 1
        $this->skip_authorization_if_scope_is_allowed = $skipAuthIfScopeIsAllowed;
200 1
        return $this;
201
    }
202
203
    /**
204
     * @inheritDoc
205
     */
206 1
    public function getClientCredentialsGrantUserId()
207
    {
208 1
        return $this->client_credentials_grant_user_id;
209
    }
210
211
    /**
212
     * @inheritDoc
213
     */
214 1
    public function setClientCredentialsGrantUserId($userId)
215
    {
216 1
        $this->client_credentials_grant_user_id = $userId;
217 1
        return $this;
218
    }
219
220
    /**
221
     * @inheritDoc
222
     */
223 1
    public function getOpenIdConnectAllowOfflineAccessWithoutConsent()
224
    {
225 1
        return (bool)$this->oidc_allow_offline_access_without_consent;
226
    }
227
228
    /**
229
     * @inheritDoc
230
     */
231 1
    public function setOpenIdConnectAllowOfflineAccessWithoutConsent($allowOfflineAccessWithoutConsent)
232
    {
233 1
        $this->oidc_allow_offline_access_without_consent = $allowOfflineAccessWithoutConsent;
234 1
        return $this;
235
    }
236
237
    /**
238
     * @inheritDoc
239
     */
240 1
    public function getOpenIdConnectUserinfoEncryptedResponseAlg()
241
    {
242 1
        return $this->oidc_userinfo_encrypted_response_alg;
243
    }
244
245
    /**
246
     * @inheritDoc
247
     */
248 1
    public function setOpenIdConnectUserinfoEncryptedResponseAlg($algorithm)
249
    {
250 1
        $this->oidc_userinfo_encrypted_response_alg = $algorithm;
251 1
        return $this;
252
    }
253
254
    /**
255
     * @inheritdoc
256
     */
257 9
    public function isConfidential()
258
    {
259 9
        return (int)$this->type !== static::TYPE_PUBLIC;
260
    }
261
262
    /**
263
     * @inheritDoc
264
     */
265 19
    public function getScopeAccess()
266
    {
267 19
        return (int)$this->scope_access;
268
    }
269
270
    /**
271
     * @inheritDoc
272
     */
273 1
    public function setScopeAccess($scopeAccess)
274
    {
275 1
        $this->scope_access = $scopeAccess;
276 1
        return $this;
277
    }
278
279
    /**
280
     * @inheritDoc
281
     */
282 3
    public static function getEncryptedAttributes()
283
    {
284 3
        return static::ENCRYPTED_ATTRIBUTES;
285
    }
286
287
    /**
288
     * @inheritDoc
289
     */
290 2
    public static function rotateStorageEncryptionKeys($encryptor, $newKeyName = null)
291
    {
292 2
        $numUpdated = 0;
293 2
        $encryptedAttributes = static::getEncryptedAttributes();
294 2
        $query = static::find()->andWhere(['NOT', array_fill_keys($encryptedAttributes, null)]);
295
296 2
        $transaction = static::getDb()->beginTransaction();
297
        try {
298
            /** @var static $client */
299 2
            foreach ($query->each() as $client) {
300 2
                $client->rotateStorageEncryptionKey($encryptor, $newKeyName);
301 2
                if ($client->getDirtyAttributes($encryptedAttributes)) {
0 ignored issues
show
Bug introduced by
The method getDirtyAttributes() does not exist on rhertogh\Yii2Oauth2Serve...s\Oauth2ClientInterface. Since it exists in all sub-types, consider adding an abstract or default implementation to rhertogh\Yii2Oauth2Serve...s\Oauth2ClientInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

301
                if ($client->/** @scrutinizer ignore-call */ getDirtyAttributes($encryptedAttributes)) {
Loading history...
302 2
                    $client->persist();
303 1
                    $numUpdated++;
304
                }
305
            }
306 1
            $transaction->commit();
307 1
        } catch (\Exception $e) {
308 1
            $transaction->rollBack();
309 1
            throw $e;
310
        }
311
312 1
        return $numUpdated;
313
    }
314
315
    /**
316
     * @inheritDoc
317
     */
318
    public static function getUsedStorageEncryptionKeys($encryptor)
319
    {
320
        $encryptedAttributes = static::getEncryptedAttributes();
321
        $query = static::find()->andWhere(['NOT', array_fill_keys($encryptedAttributes, null)]);
322
323
        $keyUsage = [];
324
        foreach ($query->each() as $client) {
325
            foreach ($encryptedAttributes as $encryptedAttribute) {
326
                $data = $client->$encryptedAttribute;
327
                if (!empty($data)) {
328
                    ['keyName' => $keyName] = $encryptor->parseData($data);
329
                    if (array_key_exists($keyName, $keyUsage)) {
330
                        $keyUsage[$keyName][] = $client->getPrimaryKey();
331
                    } else {
332
                        $keyUsage[$keyName] = [$client->getPrimaryKey()];
333
                    }
334
                }
335
            }
336
        }
337
338
        return $keyUsage;
339
    }
340
341
    /**
342
     * @inheritDoc
343
     */
344 3
    public function rotateStorageEncryptionKey($encryptor, $newKeyName = null)
345
    {
346 3
        foreach (static::getEncryptedAttributes() as $attribute) {
347 3
            $data = $this->getAttribute($attribute);
348 3
            if ($data) {
349
                try {
350 3
                    $this->setAttribute($attribute, $encryptor->rotateKey($data, $newKeyName));
351
                } catch (\Exception $e) {
352
                    throw new Exception('Unable to rotate key for client "' . $this->identifier
353
                        . '", attribute "' . $attribute . '": ' . $e->getMessage(), 0, $e);
354
                }
355
            }
356
        }
357
    }
358
359
    /**
360
     * @inheritDoc
361
     */
362 4
    public function setSecret($secret, $encryptor, $oldSecretValidUntil = null, $keyName = null)
363
    {
364 4
        if ($this->isConfidential()) {
365 4
            if (!$this->validateNewSecret($secret, $error)) {
366 1
                throw new InvalidArgumentException($error);
367
            }
368
369
            // Ensure we clear out any old secret.
370 3
            $this->setAttribute('old_secret', null);
371 3
            $this->setAttribute('old_secret_valid_until', null);
372
373 3
            if ($oldSecretValidUntil) {
374 1
                $oldSecretData = $this->getAttribute('secret') ?? null;
375 1
                if ($oldSecretData) {
376
                    // Ensure correct encryption key.
377 1
                    $oldSecretData = $encryptor->encryp($encryptor->decrypt($oldSecretData), $keyName);
378 1
                    $this->setAttribute('old_secret', $oldSecretData);
379
380 1
                    if ($oldSecretValidUntil instanceof \DateInterval) {
381 1
                        $oldSecretValidUntil = (new \DateTimeImmutable())->add($oldSecretValidUntil);
382
                    }
383 1
                    $this->setAttribute('old_secret_valid_until', $oldSecretValidUntil);
384
                }
385
            }
386
387 3
            $this->setAttribute('secret', $encryptor->encryp($secret, $keyName));
388
        } else {
389 1
            if ($secret !== null) {
390 1
                throw new InvalidArgumentException(
391 1
                    'The secret for a non-confidential client can only be set to `null`.'
392 1
                );
393
            }
394
395 1
            $this->setAttribute('secret', null);
396
        }
397
    }
398
399
    /**
400
     * @inheritDoc
401
     */
402 4
    public function validateNewSecret($secret, &$error)
403
    {
404 4
        $error = null;
405 4
        if (mb_strlen($secret) < $this->minimumSecretLenth) {
406 1
            $error = 'Secret should be at least ' . $this->minimumSecretLenth . ' characters.';
407
        }
408
409 4
        return $error === null;
410
    }
411
412
    /**
413
     * @inheritDoc
414
     */
415 2
    public function getDecryptedSecret($encryptor)
416
    {
417 2
        return $encryptor->decrypt($this->secret);
418
    }
419
420
    /**
421
     * @inheritDoc
422
     */
423
    public function getDecryptedOldSecret($encryptor)
424
    {
425
        return $encryptor->decrypt($this->old_secret);
426
    }
427
428
    /**
429
     * @inheritDoc
430
     */
431
    public function getOldSecretValidUntil()
432
    {
433
        return $this->old_secret_valid_until;
434
    }
435
436
    /**
437
     * @inheritdoc
438
     */
439 1
    public function validateSecret($secret, $encryptor)
440
    {
441 1
        return is_string($secret)
442 1
            && strlen($secret)
443 1
            && (
444 1
                Yii::$app->security->compareString($this->getDecryptedSecret($encryptor), $secret)
445 1
                || (
446 1
                    !empty($this->old_secret)
447 1
                    && !empty($this->old_secret_valid_until)
448 1
                    && $this->old_secret_valid_until > (new \DateTime())
449 1
                    && Yii::$app->security->compareString($encryptor->decrypt($this->old_secret), $secret)
450 1
                )
451 1
            );
452
    }
453
454
    /**
455
     * @inheritDoc
456
     */
457 1
    public function getGrantTypes()
458
    {
459 1
        return (int)$this->grant_types;
460
    }
461
462
    /**
463
     * @inheritDoc
464
     */
465 2
    public function setGrantTypes($grantTypes)
466
    {
467 2
        $grantTypeIds = array_flip(Oauth2Module::GRANT_TYPE_MAPPING);
468 2
        for ($i = (int)log(PHP_INT_MAX, 2); $i >= 0; $i--) {
469 2
            $grantTypeId = (int)pow(2, $i);
470 2
            if ($grantTypes & $grantTypeId) {
471 2
                if (!array_key_exists($grantTypeId, $grantTypeIds)) {
472 1
                    throw new InvalidArgumentException('Unknown Grant Type ID: ' . $grantTypeId);
473
                }
474
            }
475
        }
476
477 1
        $this->grant_types = $grantTypes;
478
    }
479
480
    /**
481
     * @inheritDoc
482
     */
483 2
    public function validateGrantType($grantTypeIdentifier)
484
    {
485 2
        $grantTypeId = Oauth2Module::getGrantTypeId($grantTypeIdentifier);
486 2
        if (empty($grantTypeId)) {
487 1
            throw new InvalidArgumentException('Unknown grant type "' . $grantTypeIdentifier . '".');
488
        }
489
490 1
        return (bool)($this->getGrantTypes() & $grantTypeId);
491
    }
492
493
    /**
494
     * @inheritDoc
495
     */
496 14
    public function validateAuthRequestScopes($scopeIdentifiers, &$unauthorizedScopes = [])
497
    {
498
        if (
499 14
            empty($scopeIdentifiers)
500
            // Quiet mode will always allow the request (scopes will silently be limited to the defined ones).
501 14
            || $this->getScopeAccess() === static::SCOPE_ACCESS_STRICT_QUIET
502
        ) {
503 4
            $unauthorizedScopes = [];
504 4
            return true;
505
        }
506
507 10
        $allowedScopeIdentifiers = array_map(
508 10
            fn($scope) => $scope->getIdentifier(),
509 10
            $this->getAllowedScopes($scopeIdentifiers)
510 10
        );
511
512 9
        $unauthorizedScopes = array_values(array_diff($scopeIdentifiers, $allowedScopeIdentifiers));
513
514 9
        return empty($unauthorizedScopes);
515
    }
516
517
    /**
518
     * @inheritDoc
519
     * @throws InvalidConfigException
520
     */
521 15
    public function getAllowedScopes($requestedScopeIdentifiers = [])
522
    {
523
        /** @var Oauth2ClientScopeInterface $clientScopeClass */
524 15
        $clientScopeClass = DiHelper::getValidatedClassName(Oauth2ClientScopeInterface::class);
525 15
        $clientScopeTableName = $clientScopeClass::tableName();
526
        /** @var Oauth2ScopeInterface $scopeClass */
527 15
        $scopeClass = DiHelper::getValidatedClassName(Oauth2ScopeInterface::class);
528 15
        $scopeTableName = $scopeClass::tableName();
529
530 15
        $possibleScopesConditions = [
531
            // Default scopes defined for this client.
532 15
            ['AND',
533 15
                [$clientScopeTableName . '.client_id' => $this->getPrimaryKey()],
534 15
                [$clientScopeTableName . '.enabled' => 1],
535 15
                ['OR',
536 15
                    ...(
537 15
                        !empty($requestedScopeIdentifiers)
538 12
                            ? [[$scopeTableName . '.identifier' => $requestedScopeIdentifiers]]
539
                            : []
540 15
                    ),
541 15
                    ['NOT', [$clientScopeTableName . '.applied_by_default' => Oauth2ScopeInterface::APPLIED_BY_DEFAULT_NO]],
542 15
                    ['AND',
543 15
                        [$clientScopeTableName . '.applied_by_default' => null],
544 15
                        ['NOT', [$scopeTableName . '.applied_by_default' => Oauth2ScopeInterface::APPLIED_BY_DEFAULT_NO]],
545 15
                    ],
546 15
                ],
547 15
            ],
548 15
        ];
549
550 15
        $scopeAccess = $this->getScopeAccess();
551 15
        if ($scopeAccess === Oauth2Client::SCOPE_ACCESS_PERMISSIVE) {
552
            // Default scopes defined by scope for all client.
553 4
            $possibleScopesConditions[] = ['AND',
554 4
                [$clientScopeTableName . '.client_id' => null],
555 4
                ['OR',
556 4
                    ...(
557 4
                        !empty($requestedScopeIdentifiers)
558 3
                            ? [[$scopeTableName . '.identifier' => $requestedScopeIdentifiers]]
559
                            : []
560 4
                    ),
561 4
                    ['NOT', [$scopeTableName . '.applied_by_default' => Oauth2ScopeInterface::APPLIED_BY_DEFAULT_NO]],
562 4
                ],
563 4
            ];
564
        } elseif (
565 11
            ($scopeAccess !== Oauth2Client::SCOPE_ACCESS_STRICT)
566 11
            && ($scopeAccess !== Oauth2Client::SCOPE_ACCESS_STRICT_QUIET)
567
        ) {
568
            // safeguard against unknown types.
569 1
            throw new \LogicException('Unknown scope_access: "' . $scopeAccess . '".');
570
        }
571
572 14
        return $scopeClass::find()
573 14
            ->joinWith('clientScopes', true)
574 14
            ->enabled()
575 14
            ->andWhere(['OR', ...$possibleScopesConditions])
576 14
            ->orderBy('id')
577 14
            ->all();
578
    }
579
}
580