Passed
Branch master (9b4352)
by Rutger
13:03
created

Oauth2Client::validateGrantType()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 4
c 1
b 0
f 0
dl 0
loc 8
ccs 5
cts 5
cp 1
rs 10
cc 2
nc 2
nop 1
crap 2
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
            'dateTimeBehavior' => DateTimeBehavior::class
47
        ]);
48
    }
49
50
    /**
51
     * @inheritDoc
52
     */
53 4
    public function rules()
54
    {
55 4
        return ArrayHelper::merge(parent::rules(), [
56
            [
57 4
                ['secret'],
58
                'required',
59 4
                '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 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
     * @throws InvalidConfigException
96
     */
97 5
    public function getRedirectUri()
98
    {
99 5
        $uri = $this->redirect_uris;
100 5
        if (is_string($uri)) {
0 ignored issues
show
introduced by
The condition is_string($uri) is always false.
Loading history...
101
            try {
102 2
                $uri = Json::decode($uri);
103 1
            } catch (InvalidArgumentException $e) {
104 1
                throw new InvalidConfigException('Invalid json in redirect_uris for client ' . $this->id, 0, $e);
105
            }
106
        }
107
108 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...
109
    }
110
111
    /**
112
     * @inheritDoc
113
     */
114 4
    public function setRedirectUri($uri)
115
    {
116 4
        if (is_array($uri)) {
117 2
            foreach ($uri as $value) {
118 2
                if (!is_string($value)) {
119 1
                    throw new InvalidArgumentException('When $uri is an array, it\'s values must be strings.');
120
                }
121
            }
122 1
            $uri = Json::encode($uri);
123 2
        } elseif (is_string($uri)) {
124 1
            $uri = Json::encode([$uri]);
125
        } else {
126 1
            throw new InvalidArgumentException('$uri must be a string or an array, got: ' . gettype($uri));
127
        }
128
129 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...
130
    }
131
132
    /**
133
     * @inheritDoc
134
     */
135 4
    public function getUserAccountSelection()
136
    {
137 4
        return $this->user_account_selection;
138
    }
139
140
    /**
141
     * @inheritDoc
142
     */
143 4
    public function isAuthCodeWithoutPkceAllowed()
144
    {
145 4
        return (bool)$this->allow_auth_code_without_pkce;
146
    }
147
148
    /**
149
     * @inheritDoc
150
     */
151 2
    public function skipAuthorizationIfScopeIsAllowed()
152
    {
153 2
        return (bool)$this->skip_authorization_if_scope_is_allowed;
154
    }
155
156
    /**
157
     * @inheritDoc
158
     */
159 1
    public function getClientCredentialsGrantUserId()
160
    {
161 1
        return $this->client_credentials_grant_user_id;
162
    }
163
164
    /**
165
     * @inheritDoc
166
     */
167 1
    public function getOpenIdConnectAllowOfflineAccessWithoutConsent()
168
    {
169 1
        return (bool)$this->oidc_allow_offline_access_without_consent;
170
    }
171
172
    /**
173
     * @inheritDoc
174
     */
175 1
    public function getOpenIdConnectUserinfoEncryptedResponseAlg()
176
    {
177 1
        return $this->oidc_userinfo_encrypted_response_alg;
178
    }
179
180
    /**
181
     * @inheritdoc
182
     */
183 12
    public function isConfidential()
184
    {
185 12
        return (int)$this->type !== static::TYPE_PUBLIC;
186
    }
187
188
    /**
189
     * @inheritDoc
190
     */
191 19
    public function getScopeAccess()
192
    {
193 19
        return (int)$this->scope_access;
194
    }
195
196
    /**
197
     * @inheritDoc
198
     */
199 3
    public static function getEncryptedAttributes()
200
    {
201 3
        return static::ENCRYPTED_ATTRIBUTES;
202
    }
203
204
    /**
205
     * @inheritDoc
206
     */
207 2
    public static function rotateStorageEncryptionKeys($encryptor, $newKeyName = null)
208
    {
209 2
        $numUpdated = 0;
210 2
        $encryptedAttributes = static::getEncryptedAttributes();
211 2
        $query = static::find()->andWhere(['NOT', array_fill_keys($encryptedAttributes, null)]);
212
213 2
        $transaction = static::getDb()->beginTransaction();
214
        try {
215
            /** @var static $client */
216 2
            foreach ($query->each() as $client) {
0 ignored issues
show
Bug introduced by
The method each() does not exist on rhertogh\Yii2Oauth2Serve...th2ClientQueryInterface. Since it exists in all sub-types, consider adding an abstract or default implementation to rhertogh\Yii2Oauth2Serve...th2ClientQueryInterface. ( Ignorable by Annotation )

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

216
            foreach ($query->/** @scrutinizer ignore-call */ each() as $client) {
Loading history...
217 2
                $client->rotateStorageEncryptionKey($encryptor, $newKeyName);
218 2
                if ($client->getDirtyAttributes($encryptedAttributes)) {
219 2
                    $client->persist();
220 1
                    $numUpdated++;
221
                }
222
            }
223 1
            $transaction->commit();
224 1
        } catch (\Exception $e) {
225 1
            $transaction->rollBack();
226 1
            throw $e;
227
        }
228
229 1
        return $numUpdated;
230
    }
231
232
    /**
233
     * @inheritDoc
234
     */
235
    public static function getUsedStorageEncryptionKeys($encryptor)
236
    {
237
        $encryptedAttributes = static::getEncryptedAttributes();
238
        $query = static::find()->andWhere(['NOT', array_fill_keys($encryptedAttributes, null)]);
239
240
        $keyUsage = [];
241
        foreach ($query->each() as $client) { /** @var  static $client */
242
            foreach ($encryptedAttributes as $encryptedAttribute) {
243
                $data = $client->$encryptedAttribute;
244
                if (!empty($data)) {
245
                    list('keyName' => $keyName) = $encryptor->parseData($data);
246
                    if (array_key_exists($keyName, $keyUsage)) {
247
                        $keyUsage[$keyName][] = $client->getPrimaryKey();
248
                    } else {
249
                        $keyUsage[$keyName] = [$client->getPrimaryKey()];
250
                    }
251
                }
252
            }
253
        }
254
255
        return $keyUsage;
256
    }
257
258
    /**
259
     * @inheritDoc
260
     */
261 3
    public function rotateStorageEncryptionKey($encryptor, $newKeyName = null)
262
    {
263 3
        foreach (static::getEncryptedAttributes() as $attribute) {
264 3
            $data = $this->getAttribute($attribute);
265 3
            if ($data) {
266
                try {
267 3
                    $this->setAttribute($attribute, $encryptor->rotateKey($data, $newKeyName));
268
                } catch (\Exception $e) {
269
                    throw new Exception('Unable to rotate key for client "' . $this->identifier
270
                        . '", attribute "' . $attribute . '": ' . $e->getMessage(), 0, $e);
271
                }
272
            }
273
        }
274
    }
275
276
    /**
277
     * @inheritDoc
278
     */
279 6
    public function setSecret($secret, $encryptor, $oldSecretValidUntil = null, $keyName = null)
280
    {
281 6
        if ($this->isConfidential()) {
282 6
            if (!$this->validateNewSecret($secret, $error)) {
283 1
                throw new InvalidArgumentException($error);
284
            }
285
286
            // Ensure we clear out any old secret
0 ignored issues
show
Coding Style introduced by
Inline comments must end in full-stops, exclamation marks, or question marks
Loading history...
287 5
            $this->setAttribute('old_secret', null);
288 5
            $this->setAttribute('old_secret_valid_until', null);
289
290 5
            if ($oldSecretValidUntil) {
291 1
                $oldSecretData = $this->getAttribute('secret') ?? null;
292 1
                if ($oldSecretData) {
293
                    // Ensure correct encryption key.
294 1
                    $oldSecretData = $encryptor->encryp($encryptor->decrypt($oldSecretData), $keyName);
295 1
                    $this->setAttribute('old_secret', $oldSecretData);
296
297 1
                    if ($oldSecretValidUntil instanceof \DateInterval) {
298 1
                        $oldSecretValidUntil = (new \DateTimeImmutable())->add($oldSecretValidUntil);
299
                    }
300 1
                    $this->setAttribute('old_secret_valid_until', $oldSecretValidUntil);
301
                }
302
            }
303
304 5
            $this->setAttribute('secret', $encryptor->encryp($secret, $keyName));
305
        } else {
306 1
            if ($secret !== null) {
307 1
                throw new InvalidArgumentException(
308
                    'The secret for a non-confidential client can only be set to `null`.'
309
                );
310
            }
311
312 1
            $this->setAttribute('secret', null);
313
        }
314
    }
315
316
    /**
317
     * @inheritDoc
318
     */
319 6
    public function validateNewSecret($secret, &$error)
320
    {
321 6
        $error = null;
322 6
        if (mb_strlen($secret) < $this->minimumSecretLenth) {
323 1
            $error = 'Secret should be at least ' . $this->minimumSecretLenth . ' characters.';
324
        }
325
326 6
        return $error === null;
327
    }
328
329
    /**
330
     * @inheritDoc
331
     */
332 2
    public function getDecryptedSecret($encryptor)
333
    {
334 2
        return $encryptor->decrypt($this->secret);
335
    }
336
337
    /**
338
     * @inheritDoc
339
     */
340
    public function getDecryptedOldSecret($encryptor)
341
    {
342
        return $encryptor->decrypt($this->old_secret);
343
    }
344
345
    /**
346
     * @inheritDoc
347
     */
348
    public function getOldSecretValidUntil()
349
    {
350
        return $this->old_secret_valid_until;
351
    }
352
353
    /**
354
     * @inheritdoc
355
     */
356 1
    public function validateSecret($secret, $encryptor)
357
    {
358 1
        return is_string($secret)
359 1
            && strlen($secret)
360
            && (
361 1
                Yii::$app->security->compareString($this->getDecryptedSecret($encryptor), $secret)
362
                || (
363 1
                    !empty($this->old_secret)
364 1
                    && !empty($this->old_secret_valid_until)
365 1
                    && $this->old_secret_valid_until > (new \DateTime())
366 1
                    && Yii::$app->security->compareString($encryptor->decrypt($this->old_secret), $secret)
367
                )
368
            );
369
    }
370
371
    /**
372
     * @inheritDoc
373
     */
374 1
    public function getGrantTypes()
375
    {
376 1
        return (int)$this->grant_types;
377
    }
378
379
    /**
380
     * @inheritDoc
381
     */
382 2
    public function validateGrantType($grantTypeIdentifier)
383
    {
384 2
        $grantTypeId = Oauth2Module::getGrantTypeId($grantTypeIdentifier);
385 2
        if (empty($grantTypeId)) {
386 1
            throw new InvalidArgumentException('Unknown grant type "' . $grantTypeIdentifier . '".');
387
        }
388
389 1
        return (bool)($this->getGrantTypes() & $grantTypeId);
390
    }
391
392
    /**
393
     * @inheritDoc
394
     */
395 14
    public function validateAuthRequestScopes($scopeIdentifiers, &$unauthorizedScopes = [])
396
    {
397
        if (
398 14
            empty($scopeIdentifiers)
399
            // Quiet mode will always allow the request (scopes will silently be limited to the defined ones).
400 14
            || $this->getScopeAccess() === static::SCOPE_ACCESS_STRICT_QUIET
401
        ) {
402 4
            $unauthorizedScopes = [];
403 4
            return true;
404
        }
405
406 10
        $allowedScopeIdentifiers = array_map(
407 10
            fn($scope) => $scope->getIdentifier(),
408 10
            $this->getAllowedScopes($scopeIdentifiers)
409
        );
410
411 9
        $unauthorizedScopes = array_values(array_diff($scopeIdentifiers, $allowedScopeIdentifiers));
412
413 9
        return empty($unauthorizedScopes);
414
    }
415
416
    /**
417
     * @inheritDoc
418
     * @throws InvalidConfigException
419
     */
420 15
    public function getAllowedScopes($requestedScopeIdentifiers = [])
421
    {
422
        /** @var Oauth2ClientScopeInterface $clientScopeClass */
423 15
        $clientScopeClass = DiHelper::getValidatedClassName(Oauth2ClientScopeInterface::class);
424 15
        $clientScopeTableName = $clientScopeClass::tableName();
425
        /** @var Oauth2ScopeInterface $scopeClass */
426 15
        $scopeClass = DiHelper::getValidatedClassName(Oauth2ScopeInterface::class);
427 15
        $scopeTableName = $scopeClass::tableName();
428
429
        $possibleScopesConditions = [
430
            // Default scopes defined for this client.
431 15
            ['AND',
432 15
                [$clientScopeTableName . '.client_id' => $this->getPrimaryKey()],
433 15
                [$clientScopeTableName . '.enabled' => 1],
434 15
                ['OR',
435 15
                    ...(!empty($requestedScopeIdentifiers)
436 12
                        ? [[$scopeTableName . '.identifier' => $requestedScopeIdentifiers]]
437
                        : []
438
                    ),
439 15
                    ['NOT', [$clientScopeTableName . '.applied_by_default' => Oauth2Scope::APPLIED_BY_DEFAULT_NO]],
440 15
                    ['AND',
441 15
                        [$clientScopeTableName . '.applied_by_default' => null],
442 15
                        ['NOT', [$scopeTableName . '.applied_by_default' => Oauth2Scope::APPLIED_BY_DEFAULT_NO]],
443
                    ],
444
                ],
445
            ],
446
        ];
447
448 15
        $scopeAccess = $this->getScopeAccess();
449 15
        if ($scopeAccess === Oauth2Client::SCOPE_ACCESS_PERMISSIVE) {
450
            // Default scopes defined by scope for all client.
451 4
            $possibleScopesConditions[] = ['AND',
452 4
                [$clientScopeTableName . '.client_id' => null],
453 4
                ['OR',
454 4
                    ...(!empty($requestedScopeIdentifiers)
455 3
                        ? [[$scopeTableName . '.identifier' => $requestedScopeIdentifiers]]
456
                        : []
457
                    ),
458 4
                    ['NOT', [$scopeTableName . '.applied_by_default' => Oauth2Scope::APPLIED_BY_DEFAULT_NO]],
459
                ],
460
            ];
461
        } elseif (
462 11
            ($scopeAccess !== Oauth2Client::SCOPE_ACCESS_STRICT)
463 11
            && ($scopeAccess !== Oauth2Client::SCOPE_ACCESS_STRICT_QUIET)
464
        ) {
465
            // safeguard against unknown types.
466 1
            throw new \LogicException('Unknown scope_access: "' . $scopeAccess . '".');
467
        }
468
469 14
        return $scopeClass::find()
470 14
            ->joinWith('clientScopes', true)
0 ignored issues
show
Bug introduced by
The method joinWith() does not exist on rhertogh\Yii2Oauth2Serve...uth2ScopeQueryInterface. Since it exists in all sub-types, consider adding an abstract or default implementation to rhertogh\Yii2Oauth2Serve...uth2ScopeQueryInterface. ( Ignorable by Annotation )

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

470
            ->/** @scrutinizer ignore-call */ joinWith('clientScopes', true)
Loading history...
471 14
            ->enabled()
472 14
            ->andWhere(['OR', ...$possibleScopesConditions])
473 14
            ->all();
474
    }
475
}
476