Completed
Push — dev-master ( 7a680d...cdb56c )
by Vijay
33:12
created

API   F

Complexity

Total Complexity 56

Size/Duplication

Total Lines 498
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 17

Importance

Changes 0
Metric Value
dl 0
loc 498
rs 3.5483
c 0
b 0
f 0
wmc 56
lcom 1
cbo 17

10 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 16 2
B afterRoute() 0 30 6
A failure() 0 8 2
A getOAuthErrorType() 0 4 2
C setOAuthError() 0 34 8
A basicAuthenticateLoginPassword() 0 13 1
A authenticateClientIdSecret() 0 15 3
A basicAuthenticateClientIdSecret() 0 5 1
F validateAccess() 0 136 30
A unknown() 0 5 1

How to fix   Complexity   

Complex Class

Complex classes like API 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. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

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 API, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace FFCMS\Controllers\API;
4
5
use FFMVC\Helpers;
6
use FFCMS\{Traits, Models, Mappers};
7
8
9
/**
10
 * Api Controller Class.
11
 *
12
 * @author Vijay Mahrra <[email protected]>
13
 * @copyright Vijay Mahrra
14
 * @license GPLv3 (http://www.gnu.org/licenses/gpl-3.0.html)
15
 */
16
class API
17
{
18
    use Traits\Logger,
19
        Traits\Audit,
20
        Traits\UrlHelper,
21
        Traits\Validation,
22
        Traits\ControllerSecurity;
23
24
    /**
25
     * version.
26
     *
27
     * @var version
28
     */
29
    protected $version;
30
31
    /**
32
     * response errors
33
     * 1xx: Informational - Transfer Protocol Information
34
     * 2xx: Success - Client's request successfully accepted
35
     *     - 200 OK, 201 - Created, 202 - Accepted, 204 - No Content (purposefully)
36
     * 3xx: Redirection - Client needs additional action to complete request
37
     *     - 301 - new location for resource
38
     *     - 304 - not modified
39
     * 4xx: Client Error - Client caused the problem
40
     *     - 400 - Bad request - nonspecific failure
41
     *     - 401 - unauthorised
42
     *     - 403 - forbidden
43
     *     - 404 - not found
44
     *     - 405 - method not allowed
45
     *     - 406 - not acceptable (e.g. not in correct format like json)
46
     * 5xx: Server Error - The server was responsible.
47
     *
48
     * @var array errors
49
     */
50
    protected $errors = [];
51
52
    /**
53
     * response data.
54
     *
55
     * @var array data
56
     */
57
    protected $data = [];
58
59
    /**
60
     * response params.
61
     *
62
     * @var array params
63
     */
64
    protected $params = [];
65
66
    /**
67
     * response helper object.
68
     *
69
     * @var \FFMVC\Helpers\Response response
70
     */
71
    protected $oResponse;
72
73
    /**
74
     * database instance
75
     *
76
     * @var \DB\SQL db
77
     */
78
    protected $db;
79
80
    /**
81
     * Error format required by RFC6794.
82
     *
83
     * @var type
84
     * @link https://tools.ietf.org/html/rfc6749
85
     */
86
    protected $OAuthErrorTypes = [
87
        'invalid_request' => [
88
            'code' => 'invalid_request',
89
            'description' => 'The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed.',
90
            'uri' => '',
91
            'state' => '',
92
        ],
93
        'invalid_credentials' => [
94
            'code' => 'invalid_credentials',
95
            'description' => 'Credentials for authentication were invalid.',
96
            'uri' => '',
97
            'state' => '',
98
        ],
99
        'invalid_client' => [
100
            'code' => 'invalid_client',
101
            'description' => 'Client authentication failed (e.g., unknown client, no client authentication included, or unsupported authentication method).',
102
            'uri' => '',
103
            'state' => '',
104
        ],
105
        'invalid_grant' => [
106
            'code' => 'invalid_grant',
107
            'description' => 'The provided authorization grant (e.g., authorization code, resource owner credentials) or refresh token is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.',
108
            'uri' => '',
109
            'state' => '',
110
        ],
111
        'unsupported_grant_type' => [
112
            'code' => 'unsupported_grant_type',
113
            'description' => 'The authorization grant type is not supported by the authorization server.',
114
            'uri' => '',
115
            'state' => '',
116
        ],
117
        'unauthorized_client' => [
118
            'code' => 'unauthorized_client',
119
            'description' => 'The client is not authorized to request an authorization code using this method.',
120
            'uri' => '',
121
            'state' => '',
122
        ],
123
        'access_denied' => [
124
            'code' => 'access_denied',
125
            'description' => 'The resource owner or authorization server denied the request.',
126
            'uri' => '',
127
            'state' => '',
128
        ],
129
        'unsupported_response_type' => [
130
            'code' => 'unsupported_response_type',
131
            'description' => 'The authorization server does not support obtaining an authorization code using this method.',
132
            'uri' => '',
133
            'state' => '',
134
        ],
135
        'invalid_scope' => [
136
            'code' => 'invalid_scope',
137
            'description' => 'The requested scope is invalid, unknown, or malformed.',
138
            'uri' => '',
139
            'state' => '',
140
        ],
141
        'server_error' => [
142
            'code' => 'server_error',
143
            'description' => 'The authorization server encountered an unexpected condition that prevented it from fulfilling the request.',
144
            'uri' => '',
145
            'state' => '',
146
        ],
147
        'temporarily_unavailable' => [
148
            'code' => 'temporarily_unavailable',
149
            'description' => 'The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server.',
150
            'uri' => '',
151
            'state' => '',
152
        ],
153
    ];
154
155
    /**
156
     * The OAuth Error to return if an OAuthError occurs.
157
     *
158
     * @var array OAuthError
159
     */
160
    protected $OAuthError = null;
161
162
    /**
163
     * initialize
164
     *
165
     * @param \Base $f3
166
     */
167
    public function __construct(\Base $f3)
168
    {
169
        $f3 = \Base::instance();
170
171
        $this->oLog = \Registry::get('logger');
172
        $this->db = \Registry::get('db');
173
        $this->version = $f3->get('api.version');
174
        $this->oResponse = Helpers\Response::instance();
175
        $this->oAudit = Models\Audit::instance();
0 ignored issues
show
Documentation Bug introduced by
It seems like \FFCMS\Models\Audit::instance() of type object<FFCMS\Models\Audit> is incompatible with the declared type object<FFCMS\Mappers\Audit> of property $oAudit.

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...
176
        $this->oUrlHelper = Helpers\Url::instance();
177
178
        // finally execute init method if exists
179
        if (method_exists($this, 'init')) {
180
            $this->init($f3);
0 ignored issues
show
Bug introduced by
It seems like you code against a specific sub-type and not the parent class FFCMS\Controllers\API\API as the method init() does only exist in the following sub-classes of FFCMS\Controllers\API\API: FFCMS\Controllers\API\APIMapper, FFCMS\Controllers\API\Audit, FFCMS\Controllers\API\ConfigData, FFCMS\Controllers\API\OAuth2Apps, FFCMS\Controllers\API\OAuth2Tokens, FFCMS\Controllers\API\Reports, FFCMS\Controllers\API\Users, FFCMS\Controllers\API\UsersData. Maybe you want to instanceof check for one of these explicitly?

Let’s take a look at an example:

abstract class User
{
    /** @return string */
    abstract public function getPassword();
}

class MyUser extends User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different sub-classes of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the parent class:

    abstract class User
    {
        /** @return string */
        abstract public function getPassword();
    
        /** @return string */
        abstract public function getDisplayName();
    }
    
Loading history...
181
        }
182
    }
183
184
    /**
185
     * compile and send the json response.
186
     *
187
     * @param \Base $f3
188
     * @return void
189
     */
190
    public function afterRoute(\Base $f3)
191
    {
192
        $this->params['headers'] = empty($this->params['headers']) ? [] : $this->params['headers'];
193
        $this->params['headers'] = [
194
            'Version' => $f3->get('api.version'),
195
        ];
196
197
        $data = [];
198
199
        // if an OAuthError is set, return that too
200
        if (!empty($this->OAuthError)) {
201
            $data['error'] = $this->OAuthError;
202
        }
203
204
        if (count($this->errors)) {
205
            ksort($this->errors);
206
            foreach ($this->errors as $code => $message) {
207
                $data['error']['errors'][] = [
208
                    'code' => $code,
209
                    'message' => $message
210
                ];
211
            }
212
        }
213
214
        if (is_array($this->data)) {
215
            $data = array_merge($data, $this->data);
216
        }
217
218
        $this->oResponse->json($data, $this->params);
219
    }
220
221
    /**
222
     * add to the list of errors that occured during this request.
223
     *
224
     * @param string $code        the error code
225
     * @param string $message     the error message
226
     * @param int    $http_status the http status code
227
     * @return void
228
     */
229
    public function failure(string $code, string $message, int $http_status = null)
230
    {
231
        $this->errors[$code] = $message;
232
233
        if (!empty($http_status)) {
234
            $this->params['http_status'] = $http_status;
235
        }
236
    }
237
238
    /**
239
     * Get OAuth Error Type.
240
     *
241
     * @param string $type
242
     *
243
     * @return array|bool error type or boolean false
244
     */
245
    protected function getOAuthErrorType(string $type)
246
    {
247
        return array_key_exists($type, $this->OAuthErrorTypes) ? $this->OAuthErrorTypes[$type] : false;
248
    }
249
250
    /**
251
     * Set the RFC-compliant OAuth Error to return.
252
     *
253
     * @param string $code  of error code from RFC
254
     * @return string the OAuth error array
255
     */
256
    public function setOAuthError(string $code)
257
    {
258
        $error = $this->getOAuthErrorType($code);
259
260
        $this->OAuthError = $error;
0 ignored issues
show
Documentation Bug introduced by
It seems like $error can also be of type boolean. However, the property $OAuthError is declared as type array. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
261
262
        // only set https status if not set anywhere else
263
        if (!empty($this->params['http_status']) && $this->params['http_status'] !== 200) {
264
            return $error;
265
        }
266
267
        switch ($code) {
268
269
            case 'invalid_client': // as per-spec
270
            case 'invalid_grant':
271
            case 'unauthorized_client':
272
                $this->params['http_status'] = 401;
273
                break;
274
275
            case 'server_error':
276
                $this->params['http_status'] = 500;
277
                break;
278
279
            case 'invalid_credentials':
280
                $this->params['http_status'] = 403;
281
                break;
282
283
            default:
284
                $this->params['http_status'] = 400;
285
                break;
286
        }
287
288
        return $error;
289
    }
290
291
    /**
292
     * Basic Authentication for email:password
293
     *
294
     * Check that the credentials match the database
295
     * Cache result for 30 seconds.
296
     *
297
     * @return bool success/failure
298
     */
299
    public function basicAuthenticateLoginPassword(): bool
300
    {
301
        $f3 = \Base::instance();
0 ignored issues
show
Unused Code introduced by
$f3 is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
302
303
        $auth = new \Auth(new \DB\SQL\Mapper(\Registry::get('db'), 'users', ['email', 'password'], 30), [
304
            'id' => 'email',
305
            'pw' => 'password',
306
        ]);
307
308
        return (bool) $auth->basic(function ($pw) {
309
            return Helpers\Str::password($pw);
310
        });
311
    }
312
313
    /**
314
     * Authentication for client_id and client_secret
315
     *
316
     * Check that the credentials match a registered app
317
     * @param string $clientId the client id to check
318
     * @param string $clientSecret the client secret to check
319
     * @return bool success/failure
320
     */
321
    public function authenticateClientIdSecret(string $clientId, string $clientSecret): bool
322
    {
323
        if (empty($clientId) || empty($clientSecret)) {
324
            return false;
325
        }
326
        $f3 = \Base::instance();
0 ignored issues
show
Unused Code introduced by
$f3 is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
327
        $oAuth2Model = Models\OAuth2::instance();
328
        $appsMapper = $oAuth2Model->getAppsMapper();
329
        $appsMapper->load(['client_id = ? AND client_secret = ?',
330
            $clientId,
331
            $clientSecret
332
        ]);
333
334
        return !empty($appsMapper->client_id);
335
    }
336
337
    /**
338
     * Basic Authentication for client_id:client_secret
339
     *
340
     * Check that the credentials match a registered app
341
     *
342
     * @return bool success/failure
343
     */
344
    public function basicAuthenticateClientIdSecret(): bool
345
    {
346
        $f3 = \Base::instance();
347
        return $this->authenticateClientIdSecret($f3->get('REQUEST.PHP_AUTH_USER'), $f3->get('REQUEST.PHP_AUTH_PW'));
348
    }
349
350
    /**
351
     * Validate the provided access token or get the bearer token from the incoming http request
352
     * do $f3->set('access_token') if OK.
353
     *
354
     * Or login using app token with HTTP Auth using one of
355
     *
356
     * email:password
357
     * email:access_token
358
     *
359
     * Or by URL query string param - ?access_token=$access_token
360
     *
361
     * Sets hive vars: user[] (mandatory), api_app[] (optional) and user_scopes[], user_groups[]
362
     *
363
     * @return null|boolean true/false on valid access credentials
364
     */
365
    protected function validateAccess()
366
    {
367
        $this->dnsbl();
368
369
        $f3 = \Base::instance();
370
371
        // if forcing access to https die
372
        if ('http' == $f3->get('SCHEME') && !empty($f3->get('api.https'))) {
373
            $this->failure('api_connection_error', "Connection only allowed via HTTPS!", 400);
374
            $this->setOAuthError('unauthorized_client');
375
            return;
376
        }
377
378
        $usersModel = Models\Users::instance();
379
        $usersMapper = $usersModel->getMapper();
380
381
        $oAuth2Model = Models\OAuth2::instance();
382
        $appsMapper = $oAuth2Model->getAppsMapper();
383
        $tokensMapper = $oAuth2Model->getTokensMapper();
384
385
        // get token from request to set the user and app
386
        // override if anything in basic auth or client_id/secret after
387
        $appLogin = false;
388
        $token = $f3->get('REQUEST.access_token');
389
        if (!empty($token)) {
390
            $tokensMapper->load(['token = ?', $token]);
391
            // check token is not out-of-date
392
            if (null == $tokensMapper->uuid) {
393
                $this->failure('authentication_error', "The token does not exist!", 401);
394
                $this->setOAuthError('invalid_grant');
395
                return false;
396
            }
397
            if (time() > strtotime($tokensMapper->expires)) {
398
                $this->failure('authentication_error', "The token expired!", 401);
399
                $this->setOAuthError('invalid_grant');
400
                return false;
401
            }
402
            if (null !== $tokensMapper->users_uuid) {
403
                $usersModel->getUserByUUID($tokensMapper->users_uuid);
404
            }
405
        }
406
407
        // login with client_id and client_secret in request
408
        $clientId = $f3->get('REQUEST.client_id');
409
        $clientSecret = $f3->get('REQUEST.client_secret');
410
        if (!empty($clientId) && !empty($clientSecret)
411
                && $this->authenticateClientIdSecret($clientId, $clientSecret)) {
412
            $appLogin = true;
413
        }
414
415
        // check if login via http basic auth
416
        $phpAuthUser = $f3->get('REQUEST.PHP_AUTH_USER');
417
        if (!empty($phpAuthUser)) {
418
            // try to login as email:password
419
            if ($this->basicAuthenticateLoginPassword()) {
420
                $email = $f3->get('REQUEST.PHP_AUTH_USER');
421
                $usersModel->getUserByEmail($email);
422
            } elseif ($this->basicAuthenticateClientIdSecret()) {
423
                $appLogin = true; // client_id:client_secret
424
            }
425
        }
426
427
        // login with app credentials?  client_id/client_secret?
428
        // if so fetch app information
429
        if (!empty($appLogin)) {
430
            // set app in f3
431
            $data = $appsMapper->cast();
432
            unset($data['id']);
433
            $f3->set('api_app', $data);
434
            // load the user by app user uuid
435
            $usersMapper->load(['uuid = ?', $appsMapper->users_uuid]);
436
        }
437
438
        // check user has api access enabled
439
        // has to have 'api' in group
440
        $f3->set('is_admin', 0);
441
        if (empty($token)) {
442
            $groups = empty($usersMapper->groups) ? [] : preg_split("/[\s,]+/", $usersMapper->groups);
443
            if (!in_array('api', $groups)) {
444
                $usersMapper->reset();
445
                $f3->clear('api_app'); // clear authorized app as user doesn't have access
446
            }
447
            if (in_array('admin', $groups)) {
448
                $f3->set('is_admin', 1);
449
            }
450
        }
451
452
        // fetch user information if available
453
        if (null !== $usersMapper->uuid) {
454
            $data = $usersMapper->cast();
455
            unset($data['id']);
456
            unset($data['password']);
457
            $f3->set('user', $data);
458
            $f3->set('uuid', $f3->set('uuid', $usersMapper->uuid));
459
        }
460
461
        $app = $f3->get('api_app'); // authenticated as a client app
462
        $user = $f3->get('user');   // authenticated as a user
463
464
        // fetch scope if available
465
        if (!empty($app) && !empty($user)) {
466
            $tokensMapper->load(['client_id = ? AND users_uuid = ?', $app['client_id'], $user['uuid']]);
467
        }
468
469
        // get the scopes, this might have come from the token auth
470
        $scopes = [];
0 ignored issues
show
Unused Code introduced by
$scopes is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
471
        if (!empty($tokensMapper->users_uuid)) {
472
            $scopes = empty($request['scope']) ? [] : preg_split("/[\s,]+/", $request['scope']);
0 ignored issues
show
Bug introduced by
The variable $request seems to never exist, and therefore empty should always return true. Did you maybe rename this variable?

This check looks for calls to isset(...) or empty() on variables that are yet undefined. These calls will always produce the same result and can be removed.

This is most likely caused by the renaming of a variable or the removal of a function/method parameter.

Loading history...
473
            $f3->set('user_scopes', $scopes);
474
            // also check the token is valid
475
            if (!$appLogin && time() > strtotime($tokensMapper->expires)) {
476
                $this->failure('authentication_error', "The token expired!", 401);
477
                $this->setOAuthError('invalid_grant');
478
                return false;
479
            }
480
        }
481
482
        // set user groups
483
        $groups = empty($usersMapper->groups) ? [] : preg_split("/[\s,]+/", $usersMapper->groups);
484
        if (!empty($groups)) {
485
            $f3->set('user_groups', $groups);
486
        }
487
        if (in_array('admin', $groups)) {
488
            $f3->set('is_admin', 1);
489
        }
490
491
        $userAuthenticated = (is_array($user) || is_array($app));
492
        if (!$userAuthenticated) {
493
            $this->failure('authentication_error', "Not possible to authenticate the request.", 400);
494
            $this->setOAuthError('invalid_credentials');
495
496
            return false;
497
        }
498
499
        return true;
500
    }
501
502
    /**
503
     * catch-all
504
     *
505
     * @return void
506
     */
507
    public function unknown()
508
    {
509
        $this->setOAuthError('invalid_request');
510
        $this->failure('api_connection_error', 'Unknown API Request', 400);
511
    }
512
513
}
514