Passed
Push — master ( d41c5c...f5a9d1 )
by Rutger
14:37
created

Oauth2Client::getRedirectUri()   B

Complexity

Conditions 8
Paths 19

Size

Total Lines 32
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 8.064

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 22
c 1
b 0
f 0
dl 0
loc 32
ccs 18
cts 20
cp 0.9
rs 8.4444
cc 8
nc 19
nop 0
crap 8.064
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 43
    public function behaviors()
44
    {
45 43
        return ArrayHelper::merge(parent::behaviors(), [
46 43
            'dateTimeBehavior' => DateTimeBehavior::class
47 43
        ]);
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 44
    public function __set($name, $value)
77
    {
78 44
        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 43
            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
        $uris = $this->redirect_uris;
109 5
        if (is_string($uris)) {
0 ignored issues
show
introduced by
The condition is_string($uris) is always false.
Loading history...
110
            try {
111 2
                $uris = Json::decode($uris);
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
        if (is_string($uris)) {
0 ignored issues
show
introduced by
The condition is_string($uris) is always false.
Loading history...
118
            $uris = [$uris];
119 4
        } elseif (is_array($uris)) {
0 ignored issues
show
introduced by
The condition is_array($uris) is always true.
Loading history...
120 4
            $uris = array_values($uris);
121
        } else {
122
            throw new InvalidConfigException('`redirect_uris` must be a JSON encoded string or array of strings.');
123
        }
124
125 4
        foreach ($uris as $key => $uri) {
126 4
            preg_match_all('/\${(?<name>[a-zA-Z0-9_]+)}/', $uri, $matches, PREG_SET_ORDER);
127 4
            foreach ($matches as $match) {
128 1
                $envVar = getenv($match['name']);
129 1
                if (strlen($envVar)) {
130 1
                    $uris[$key] = str_replace($match[0], $envVar, $uris[$key]);
131
                } else {
132 1
                    unset($uris[$key]);
133 1
                    break;
134
                }
135
            }
136
        }
137 4
        return array_values($uris); // Re-index array in case elements were removed.
138
    }
139
140
    /**
141
     * @inheritDoc
142
     */
143 4
    public function setRedirectUri($uri)
144
    {
145 4
        if (is_array($uri)) {
146 2
            foreach ($uri as $value) {
147 2
                if (!is_string($value)) {
148 1
                    throw new InvalidArgumentException('When $uri is an array, its values must be strings.');
149
                }
150
            }
151 1
            $uri = Json::encode($uri);
152 2
        } elseif (is_string($uri)) {
153 1
            $uri = Json::encode([$uri]);
154
        } else {
155 1
            throw new InvalidArgumentException('$uri must be a string or an array, got: ' . gettype($uri));
156
        }
157
158 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...
159
    }
160
161 2
    public function isVariableRedirectUriQueryAllowed()
162
    {
163 2
        return (bool)$this->allow_variable_redirect_uri_query;
164
    }
165 1
    public function setAllowVariableRedirectUriQuery($allowVariableRedirectUriQuery)
166
    {
167 1
        $this->allow_variable_redirect_uri_query = $allowVariableRedirectUriQuery;
168 1
        return $this;
169
    }
170
171
    /**
172
     * @inheritDoc
173
     */
174 4
    public function getUserAccountSelection()
175
    {
176 4
        return $this->user_account_selection;
177
    }
178
179 3
    public function endUsersMayAuthorizeClient()
180
    {
181 3
        return $this->end_users_may_authorize_client;
182
    }
183
184
    public function setEndUsersMayAuthorizeClient($endUsersMayAuthorizeClient)
185
    {
186
        $this->end_users_may_authorize_client = $endUsersMayAuthorizeClient;
187
        return $this;
188
    }
189
190
    /**
191
     * @inheritDoc
192
     */
193 1
    public function setUserAccountSelection($userAccountSelectionConfig)
194
    {
195 1
        $this->user_account_selection = $userAccountSelectionConfig;
196 1
        return $this;
197
    }
198
199
    /**
200
     * @inheritDoc
201
     */
202 4
    public function isAuthCodeWithoutPkceAllowed()
203
    {
204 4
        return (bool)$this->allow_auth_code_without_pkce;
205
    }
206
207
    /**
208
     * @inheritDoc
209
     */
210 1
    public function setAllowAuthCodeWithoutPkce($allowAuthCodeWithoutPkce)
211
    {
212 1
        $this->allow_auth_code_without_pkce = $allowAuthCodeWithoutPkce;
213 1
        return $this;
214
    }
215
216
    /**
217
     * @inheritDoc
218
     */
219 2
    public function skipAuthorizationIfScopeIsAllowed()
220
    {
221 2
        return (bool)$this->skip_authorization_if_scope_is_allowed;
222
    }
223
224
    /**
225
     * @inheritDoc
226
     */
227 1
    public function setSkipAuthorizationIfScopeIsAllowed($skipAuthIfScopeIsAllowed)
228
    {
229 1
        $this->skip_authorization_if_scope_is_allowed = $skipAuthIfScopeIsAllowed;
230 1
        return $this;
231
    }
232
233
    /**
234
     * @inheritDoc
235
     */
236 1
    public function getClientCredentialsGrantUserId()
237
    {
238 1
        return $this->client_credentials_grant_user_id;
239
    }
240
241
    /**
242
     * @inheritDoc
243
     */
244 1
    public function setClientCredentialsGrantUserId($userId)
245
    {
246 1
        $this->client_credentials_grant_user_id = $userId;
247 1
        return $this;
248
    }
249
250
    /**
251
     * @inheritDoc
252
     */
253 1
    public function getOpenIdConnectAllowOfflineAccessWithoutConsent()
254
    {
255 1
        return (bool)$this->oidc_allow_offline_access_without_consent;
256
    }
257
258
    /**
259
     * @inheritDoc
260
     */
261 1
    public function setOpenIdConnectAllowOfflineAccessWithoutConsent($allowOfflineAccessWithoutConsent)
262
    {
263 1
        $this->oidc_allow_offline_access_without_consent = $allowOfflineAccessWithoutConsent;
264 1
        return $this;
265
    }
266
267
    /**
268
     * @inheritDoc
269
     */
270 1
    public function getOpenIdConnectUserinfoEncryptedResponseAlg()
271
    {
272 1
        return $this->oidc_userinfo_encrypted_response_alg;
273
    }
274
275
    /**
276
     * @inheritDoc
277
     */
278 1
    public function setOpenIdConnectUserinfoEncryptedResponseAlg($algorithm)
279
    {
280 1
        $this->oidc_userinfo_encrypted_response_alg = $algorithm;
281 1
        return $this;
282
    }
283
284
    /**
285
     * @inheritdoc
286
     */
287 9
    public function isConfidential()
288
    {
289 9
        return (int)$this->type !== static::TYPE_PUBLIC;
290
    }
291
292
    /**
293
     * @inheritDoc
294
     */
295 19
    public function getScopeAccess()
296
    {
297 19
        return (int)$this->scope_access;
298
    }
299
300
    /**
301
     * @inheritDoc
302
     */
303 1
    public function setScopeAccess($scopeAccess)
304
    {
305 1
        $this->scope_access = $scopeAccess;
306 1
        return $this;
307
    }
308
309
    /**
310
     * @inheritDoc
311
     */
312 3
    public static function getEncryptedAttributes()
313
    {
314 3
        return static::ENCRYPTED_ATTRIBUTES;
315
    }
316
317
    /**
318
     * @inheritDoc
319
     */
320 2
    public static function rotateStorageEncryptionKeys($encryptor, $newKeyName = null)
321
    {
322 2
        $numUpdated = 0;
323 2
        $encryptedAttributes = static::getEncryptedAttributes();
324 2
        $query = static::find()->andWhere(['NOT', array_fill_keys($encryptedAttributes, null)]);
325
326 2
        $transaction = static::getDb()->beginTransaction();
327
        try {
328
            /** @var static $client */
329 2
            foreach ($query->each() as $client) {
330 2
                $client->rotateStorageEncryptionKey($encryptor, $newKeyName);
331 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

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