Passed
Push — master ( c71157...ab1459 )
by Rutger
13:25
created

Oauth2Client::setAllowVariableRedirectUriQuery()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 2
c 0
b 0
f 0
dl 0
loc 4
ccs 3
cts 3
cp 1
rs 10
cc 1
nc 1
nop 1
crap 1
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
        $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 2
    public function isVariableRedirectUriQueryAllowed()
142
    {
143 2
        return $this->allow_variable_redirect_uri_query;
144
    }
145 1
    public function setAllowVariableRedirectUriQuery($allowVariableRedirectUriQuery)
146
    {
147 1
        $this->allow_variable_redirect_uri_query = $allowVariableRedirectUriQuery;
148 1
        return $this;
149
    }
150
151
    /**
152
     * @inheritDoc
153
     */
154 4
    public function getUserAccountSelection()
155
    {
156 4
        return $this->user_account_selection;
157
    }
158
159 3
    public function endUsersMayAuthorizeClient()
160
    {
161 3
        return $this->end_users_may_authorize_client;
162
    }
163
164
    public function setEndUsersMayAuthorizeClient($endUsersMayAuthorizeClient)
165
    {
166
        $this->end_users_may_authorize_client = $endUsersMayAuthorizeClient;
167
        return $this;
168
    }
169
170
    /**
171
     * @inheritDoc
172
     */
173 1
    public function setUserAccountSelection($userAccountSelectionConfig)
174
    {
175 1
        $this->user_account_selection = $userAccountSelectionConfig;
176 1
        return $this;
177
    }
178
179
    /**
180
     * @inheritDoc
181
     */
182 4
    public function isAuthCodeWithoutPkceAllowed()
183
    {
184 4
        return (bool)$this->allow_auth_code_without_pkce;
185
    }
186
187
    /**
188
     * @inheritDoc
189
     */
190 1
    public function setAllowAuthCodeWithoutPkce($allowAuthCodeWithoutPkce)
191
    {
192 1
        $this->allow_auth_code_without_pkce = $allowAuthCodeWithoutPkce;
193 1
        return $this;
194
    }
195
196
    /**
197
     * @inheritDoc
198
     */
199 2
    public function skipAuthorizationIfScopeIsAllowed()
200
    {
201 2
        return (bool)$this->skip_authorization_if_scope_is_allowed;
202
    }
203
204
    /**
205
     * @inheritDoc
206
     */
207 1
    public function setSkipAuthorizationIfScopeIsAllowed($skipAuthIfScopeIsAllowed)
208
    {
209 1
        $this->skip_authorization_if_scope_is_allowed = $skipAuthIfScopeIsAllowed;
210 1
        return $this;
211
    }
212
213
    /**
214
     * @inheritDoc
215
     */
216 1
    public function getClientCredentialsGrantUserId()
217
    {
218 1
        return $this->client_credentials_grant_user_id;
219
    }
220
221
    /**
222
     * @inheritDoc
223
     */
224 1
    public function setClientCredentialsGrantUserId($userId)
225
    {
226 1
        $this->client_credentials_grant_user_id = $userId;
227 1
        return $this;
228
    }
229
230
    /**
231
     * @inheritDoc
232
     */
233 1
    public function getOpenIdConnectAllowOfflineAccessWithoutConsent()
234
    {
235 1
        return (bool)$this->oidc_allow_offline_access_without_consent;
236
    }
237
238
    /**
239
     * @inheritDoc
240
     */
241 1
    public function setOpenIdConnectAllowOfflineAccessWithoutConsent($allowOfflineAccessWithoutConsent)
242
    {
243 1
        $this->oidc_allow_offline_access_without_consent = $allowOfflineAccessWithoutConsent;
244 1
        return $this;
245
    }
246
247
    /**
248
     * @inheritDoc
249
     */
250 1
    public function getOpenIdConnectUserinfoEncryptedResponseAlg()
251
    {
252 1
        return $this->oidc_userinfo_encrypted_response_alg;
253
    }
254
255
    /**
256
     * @inheritDoc
257
     */
258 1
    public function setOpenIdConnectUserinfoEncryptedResponseAlg($algorithm)
259
    {
260 1
        $this->oidc_userinfo_encrypted_response_alg = $algorithm;
261 1
        return $this;
262
    }
263
264
    /**
265
     * @inheritdoc
266
     */
267 9
    public function isConfidential()
268
    {
269 9
        return (int)$this->type !== static::TYPE_PUBLIC;
270
    }
271
272
    /**
273
     * @inheritDoc
274
     */
275 19
    public function getScopeAccess()
276
    {
277 19
        return (int)$this->scope_access;
278
    }
279
280
    /**
281
     * @inheritDoc
282
     */
283 1
    public function setScopeAccess($scopeAccess)
284
    {
285 1
        $this->scope_access = $scopeAccess;
286 1
        return $this;
287
    }
288
289
    /**
290
     * @inheritDoc
291
     */
292 3
    public static function getEncryptedAttributes()
293
    {
294 3
        return static::ENCRYPTED_ATTRIBUTES;
295
    }
296
297
    /**
298
     * @inheritDoc
299
     */
300 2
    public static function rotateStorageEncryptionKeys($encryptor, $newKeyName = null)
301
    {
302 2
        $numUpdated = 0;
303 2
        $encryptedAttributes = static::getEncryptedAttributes();
304 2
        $query = static::find()->andWhere(['NOT', array_fill_keys($encryptedAttributes, null)]);
305
306 2
        $transaction = static::getDb()->beginTransaction();
307
        try {
308
            /** @var static $client */
309 2
            foreach ($query->each() as $client) {
310 2
                $client->rotateStorageEncryptionKey($encryptor, $newKeyName);
311 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

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