Completed
Push — dev-master ( cdb56c...dc1664 )
by Vijay
12:32
created

API   D

Complexity

Total Complexity 53

Size/Duplication

Total Lines 484
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 17

Importance

Changes 0
Metric Value
dl 0
loc 484
rs 4.6158
c 0
b 0
f 0
wmc 53
lcom 1
cbo 17

10 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 16 2
B afterRoute() 0 23 5
A failure() 0 8 2
A getOAuthErrorType() 0 4 2
C setOAuthError() 0 32 8
A basicAuthenticateLoginPassword() 0 13 1
A authenticateClientIdSecret() 0 15 3
A basicAuthenticateClientIdSecret() 0 5 1
F validateAccess() 0 131 28
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 boolean|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']['Version'] = $f3->get('api.version');
194
195
        // if an OAuthError is set, return that too
196
        $data = [];
197
        if (!empty($this->OAuthError)) {
198
            $data['error'] = $this->OAuthError;
199
        }
200
201
        if (count($this->errors)) {
202
            foreach ($this->errors as $code => $message) {
203
                $data['error']['errors'][] = [
204
                    'code' => $code,
205
                    'message' => $message
206
                ];
207
            }
208
            ksort($this->errors);
209
        }
210
211
        $this->oResponse->json(array_merge($data, $this->data), $this->params);
212
    }
213
214
    /**
215
     * add to the list of errors that occured during this request.
216
     *
217
     * @param string $code        the error code
218
     * @param string $message     the error message
219
     * @param int    $http_status the http status code
220
     * @return void
221
     */
222
    public function failure(string $code, string $message, int $http_status = null)
223
    {
224
        $this->errors[$code] = $message;
225
226
        if (!empty($http_status)) {
227
            $this->params['http_status'] = $http_status;
228
        }
229
    }
230
231
    /**
232
     * Get OAuth Error Type.
233
     *
234
     * @param string $type
235
     *
236
     * @return array|bool error type or boolean false
237
     */
238
    protected function getOAuthErrorType(string $type)
239
    {
240
        return array_key_exists($type, $this->OAuthErrorTypes) ? $this->OAuthErrorTypes[$type] : false;
241
    }
242
243
    /**
244
     * Set the RFC-compliant OAuth Error to return.
245
     *
246
     * @param string $code  of error code from RFC
247
     * @return string the OAuth error array
248
     */
249
    public function setOAuthError(string $code)
250
    {
251
        $this->OAuthError = $this->getOAuthErrorType($code);
252
253
        // only set https status if not set anywhere else
254
        if (!empty($this->params['http_status']) && $this->params['http_status'] !== 200) {
255
            return $this->OAuthError;
256
        }
257
258
        switch ($code) {
259
260
            case 'invalid_client': // as per-spec
261
            case 'invalid_grant':
262
            case 'unauthorized_client':
263
                $this->params['http_status'] = 401;
264
                break;
265
266
            case 'server_error':
267
                $this->params['http_status'] = 500;
268
                break;
269
270
            case 'invalid_credentials':
271
                $this->params['http_status'] = 403;
272
                break;
273
274
            default:
275
                $this->params['http_status'] = 400;
276
                break;
277
        }
278
279
        return $this->OAuthError;
280
    }
281
282
    /**
283
     * Basic Authentication for email:password
284
     *
285
     * Check that the credentials match the database
286
     * Cache result for 30 seconds.
287
     *
288
     * @return bool success/failure
289
     */
290
    public function basicAuthenticateLoginPassword(): bool
291
    {
292
        $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...
293
294
        $auth = new \Auth(new \DB\SQL\Mapper(\Registry::get('db'), 'users', ['email', 'password'], 30), [
295
            'id' => 'email',
296
            'pw' => 'password',
297
        ]);
298
299
        return (bool) $auth->basic(function ($pw) {
300
            return Helpers\Str::password($pw);
301
        });
302
    }
303
304
    /**
305
     * Authentication for client_id and client_secret
306
     *
307
     * Check that the credentials match a registered app
308
     * @param string $clientId the client id to check
309
     * @param string $clientSecret the client secret to check
310
     * @return bool success/failure
311
     */
312
    public function authenticateClientIdSecret(string $clientId, string $clientSecret): bool
313
    {
314
        if (empty($clientId) || empty($clientSecret)) {
315
            return false;
316
        }
317
        $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...
318
        $oAuth2Model = Models\OAuth2::instance();
319
        $appsMapper = $oAuth2Model->getAppsMapper();
320
        $appsMapper->load(['client_id = ? AND client_secret = ?',
321
            $clientId,
322
            $clientSecret
323
        ]);
324
325
        return !empty($appsMapper->client_id);
326
    }
327
328
    /**
329
     * Basic Authentication for client_id:client_secret
330
     *
331
     * Check that the credentials match a registered app
332
     *
333
     * @return bool success/failure
334
     */
335
    public function basicAuthenticateClientIdSecret(): bool
336
    {
337
        $f3 = \Base::instance();
338
        return $this->authenticateClientIdSecret($f3->get('REQUEST.PHP_AUTH_USER'), $f3->get('REQUEST.PHP_AUTH_PW'));
339
    }
340
341
    /**
342
     * Validate the provided access token or get the bearer token from the incoming http request
343
     * do $f3->set('access_token') if OK.
344
     *
345
     * Or login using app token with HTTP Auth using one of
346
     *
347
     * email:password
348
     * email:access_token
349
     *
350
     * Or by URL query string param - ?access_token=$access_token
351
     *
352
     * Sets hive vars: user[] (mandatory), api_app[] (optional) and user_scopes[], user_groups[]
353
     *
354
     * @return null|boolean true/false on valid access credentials
355
     */
356
    protected function validateAccess()
357
    {
358
        $this->dnsbl(); // always check if dns blacklisted
359
360
        $f3 = \Base::instance();
361
362
        // return if forcing access to https and not https
363
        if ('http' == $f3->get('SCHEME') && !empty($f3->get('api.https'))) {
364
            $this->failure('api_connection_error', "Connection only allowed via HTTPS!", 400);
365
            $this->setOAuthError('unauthorized_client');
366
            return;
367
        }
368
369
        $oAuth2Model = Models\OAuth2::instance();
370
        $tokensMapper = $oAuth2Model->getTokensMapper();
371
372
        // get token from request to set the user and app
373
        // override if anything in basic auth or client_id/secret after
374
        $appLogin = false;
0 ignored issues
show
Unused Code introduced by
$appLogin 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...
375
        $token = $f3->get('REQUEST.access_token');
376
        if (!empty($token)) {
377
            $tokensMapper->load(['token = ?', $token]);
378
            // token does not exist!
379
            if (null == $tokensMapper->uuid) {
380
                $this->failure('authentication_error', "The token does not exist!", 401);
381
                $this->setOAuthError('invalid_grant');
382
                return false;
383
            }
384
            // check token is not out-of-date
385
            if (time() > strtotime($tokensMapper->expires)) {
386
                $this->failure('authentication_error', "The token expired!", 401);
387
                $this->setOAuthError('invalid_grant');
388
                return false;
389
            }
390
        }
391
392
        // if token found load the user for the token
393
        $usersModel = Models\Users::instance();
394
        if (null !== $tokensMapper->users_uuid) {
395
            $usersModel->getUserByUUID($tokensMapper->users_uuid);
396
        }
397
398
        // login with client_id and client_secret in request
399
        $clientId = $f3->get('REQUEST.client_id');
400
        $clientSecret = $f3->get('REQUEST.client_secret');
401
402
        $appLogin = (!empty($clientId) && !empty($clientSecret)
403
                && $this->authenticateClientIdSecret($clientId, $clientSecret));
404
405
        // check if login via http basic auth
406
        if (!empty($f3->get('REQUEST.PHP_AUTH_USER'))) {
407
            // try to login as email:password
408
            if ($this->basicAuthenticateLoginPassword()) {
409
                $email = $f3->get('REQUEST.PHP_AUTH_USER');
410
                $usersModel->getUserByEmail($email);
411
            } elseif ($this->basicAuthenticateClientIdSecret()) {
412
                $appLogin = true; // client_id:client_secret
413
            }
414
        }
415
416
        // login with app credentials: client_id/client_secret?
417
        // if so fetch app and user information
418
        $usersMapper = $usersModel->getMapper();
419
        $appsMapper = $oAuth2Model->getAppsMapper();
420
        if (!empty($appLogin)) {
421
            // set app in f3
422
            $data = $appsMapper->cast();
423
            $f3->set('api_app', $data);
424
            $usersMapper->load(['uuid = ?', $appsMapper->users_uuid]);
425
        }
426
427
        // check user has api access enabled
428
        // has to have 'api' in group
429
        $groups = empty($usersMapper->groups) ? [] : preg_split("/[\s,]+/", $usersMapper->groups);
430
        $f3->set('is_admin', 0);
431
        if (empty($token)) {
432
            if (!in_array('api', $groups)) {
433
                // clear authorized app as user doesn't have access
434
                $usersMapper->reset();
435
                $f3->clear('api_app');
436
            }
437
            if (in_array('admin', $groups)) {
438
                $f3->set('is_admin', 1);
439
            }
440
        }
441
442
        // fetch user information if available
443
        if (null !== $usersMapper->uuid) {
444
            $data = $usersMapper->cast();
445
            $f3->set('user', $data);
446
            $f3->set('uuid', $f3->set('uuid', $usersMapper->uuid));
447
        }
448
449
        $app = $f3->get('api_app'); // authenticated as a client app
450
        $user = $f3->get('user');   // authenticated as a user
451
452
        // fetch scope if available
453
        if (!empty($app) && !empty($user)) {
454
            $tokensMapper->load(['client_id = ? AND users_uuid = ?', $app['client_id'], $user['uuid']]);
455
        }
456
457
        // get the scopes, this might have come from the token auth
458
        $scope = $f3->get('REQUEST.scope');
459
        $scopes = empty($scope) ? [] : preg_split("/[\s,]+/", $scope);
460
        if (!empty($tokensMapper->users_uuid)) {
461
            $f3->set('user_scopes', $scopes);
462
            // also check the token is valid
463
            if (!$appLogin && time() > strtotime($tokensMapper->expires)) {
464
                $this->failure('authentication_error', "The token expired!", 401);
465
                $this->setOAuthError('invalid_grant');
466
                return false;
467
            }
468
        }
469
470
        // set user groups
471
        $f3->set('is_admin', in_array('admin', $groups));
472
        $groups = empty($usersMapper->groups) ? [] : preg_split("/[\s,]+/", $usersMapper->groups);
473
        if (!empty($groups)) {
474
            $f3->set('user_groups', $groups);
475
        }
476
477
        $userAuthenticated = (is_array($user) || is_array($app));
478
        if (!$userAuthenticated) {
479
            $this->failure('authentication_error', "Not possible to authenticate the request.", 400);
480
            $this->setOAuthError('invalid_credentials');
481
482
            return false;
483
        }
484
485
        return true;
486
    }
487
488
    /**
489
     * catch-all
490
     *
491
     * @return void
492
     */
493
    public function unknown()
494
    {
495
        $this->setOAuthError('invalid_request');
496
        $this->failure('api_connection_error', 'Unknown API Request', 400);
497
    }
498
499
}
500