Passed
Push — master ( 0a6cdc...dbb3a5 )
by Rutger
13:40
created

Oauth2Client::rules()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 7
c 1
b 0
f 0
dl 0
loc 12
ccs 4
cts 4
cp 1
rs 10
cc 1
nc 1
nop 0
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 41
    public function behaviors()
44
    {
45 41
        return ArrayHelper::merge(parent::behaviors(), [
46
            'dateTimeBehavior' => DateTimeBehavior::class
47
        ]);
48
    }
49
50
    /**
51
     * @inheritDoc
52
     */
53 1
    public function rules()
54
    {
55 1
        return ArrayHelper::merge(parent::rules(), [
56
            [
57 1
                ['secret'],
58
                'required',
59 1
                'when' => fn(self $model) => $model->isConfidential(),
60
            ],
61
            [
62
                ['scope_access'],
63
                'in',
64
                'range' => static::SCOPE_ACCESSES,
65
            ]
66
        ]);
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, it\'s 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
    /**
150
     * @inheritDoc
151
     */
152 1
    public function setUserAccountSelection($userAccountSelectionConfig)
153
    {
154 1
        $this->user_account_selection = $userAccountSelectionConfig;
155 1
        return $this;
156
    }
157
158
159
    /**
160
     * @inheritDoc
161
     */
162 4
    public function isAuthCodeWithoutPkceAllowed()
163
    {
164 4
        return (bool)$this->allow_auth_code_without_pkce;
165
    }
166
167
    /**
168
     * @inheritDoc
169
     */
170 1
    public function setAllowAuthCodeWithoutPkce($allowAuthCodeWithoutPkce)
171
    {
172 1
        $this->allow_auth_code_without_pkce = $allowAuthCodeWithoutPkce;
173 1
        return $this;
174
    }
175
176
    /**
177
     * @inheritDoc
178
     */
179 2
    public function skipAuthorizationIfScopeIsAllowed()
180
    {
181 2
        return (bool)$this->skip_authorization_if_scope_is_allowed;
182
    }
183
184
    /**
185
     * @inheritDoc
186
     */
187 1
    public function setSkipAuthorizationIfScopeIsAllowed($skipAuthIfScopeIsAllowed)
188
    {
189 1
        $this->skip_authorization_if_scope_is_allowed = $skipAuthIfScopeIsAllowed;
190 1
        return $this;
191
    }
192
193
    /**
194
     * @inheritDoc
195
     */
196 1
    public function getClientCredentialsGrantUserId()
197
    {
198 1
        return $this->client_credentials_grant_user_id;
199
    }
200
201
    /**
202
     * @inheritDoc
203
     */
204 1
    public function setClientCredentialsGrantUserId($userId)
205
    {
206 1
        $this->client_credentials_grant_user_id = $userId;
207 1
        return $this;
208
    }
209
210
    /**
211
     * @inheritDoc
212
     */
213 1
    public function getOpenIdConnectAllowOfflineAccessWithoutConsent()
214
    {
215 1
        return (bool)$this->oidc_allow_offline_access_without_consent;
216
    }
217
218
    /**
219
     * @inheritDoc
220
     */
221 1
    public function setOpenIdConnectAllowOfflineAccessWithoutConsent($allowOfflineAccessWithoutConsent)
222
    {
223 1
        $this->oidc_allow_offline_access_without_consent = $allowOfflineAccessWithoutConsent;
224 1
        return $this;
225
    }
226
227
    /**
228
     * @inheritDoc
229
     */
230 1
    public function getOpenIdConnectUserinfoEncryptedResponseAlg()
231
    {
232 1
        return $this->oidc_userinfo_encrypted_response_alg;
233
    }
234
235
    /**
236
     * @inheritDoc
237
     */
238 1
    public function setOpenIdConnectUserinfoEncryptedResponseAlg($algorithm)
239
    {
240 1
        $this->oidc_userinfo_encrypted_response_alg = $algorithm;
241 1
        return $this;
242
    }
243
244
    /**
245
     * @inheritdoc
246
     */
247 9
    public function isConfidential()
248
    {
249 9
        return (int)$this->type !== static::TYPE_PUBLIC;
250
    }
251
252
    /**
253
     * @inheritDoc
254
     */
255 19
    public function getScopeAccess()
256
    {
257 19
        return (int)$this->scope_access;
258
    }
259
260
    /**
261
     * @inheritDoc
262
     */
263 1
    public function setScopeAccess($scopeAccess)
264
    {
265 1
        $this->scope_access = $scopeAccess;
266 1
        return $this;
267
    }
268
269
    /**
270
     * @inheritDoc
271
     */
272 3
    public static function getEncryptedAttributes()
273
    {
274 3
        return static::ENCRYPTED_ATTRIBUTES;
275
    }
276
277
    /**
278
     * @inheritDoc
279
     */
280 2
    public static function rotateStorageEncryptionKeys($encryptor, $newKeyName = null)
281
    {
282 2
        $numUpdated = 0;
283 2
        $encryptedAttributes = static::getEncryptedAttributes();
284 2
        $query = static::find()->andWhere(['NOT', array_fill_keys($encryptedAttributes, null)]);
285
286 2
        $transaction = static::getDb()->beginTransaction();
287
        try {
288
            /** @var static $client */
289 2
            foreach ($query->each() as $client) {
290 2
                $client->rotateStorageEncryptionKey($encryptor, $newKeyName);
291 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

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