Issues (71)

libraries/REST.php (18 issues)

1
<?php
2
declare(strict_types=1);
3
defined('BASEPATH') OR exit('No direct script access allowed');
4
5
require_once('RESTAuth.php');
6
require_once('RESTResponse.php');
7
require_once('RESTExceptions.php');
8
9
class REST
10
{
11
  /**
12
   * [private description]
13
   * @var [type]
0 ignored issues
show
Documentation Bug introduced by
The doc comment [type] at position 0 could not be parsed: Unknown type name '[' at position 0 in [type].
Loading history...
14
   */
15
  private $ci;
16
17
  /**
18
   * [private description]
19
   * @var [type]
0 ignored issues
show
Documentation Bug introduced by
The doc comment [type] at position 0 could not be parsed: Unknown type name '[' at position 0 in [type].
Loading history...
20
   */
21
  private $api_key_limit_column;
22
23
  /**
24
   * [private description]
25
   * @var [type]
0 ignored issues
show
Documentation Bug introduced by
The doc comment [type] at position 0 could not be parsed: Unknown type name '[' at position 0 in [type].
Loading history...
26
   */
27
  private $api_key_column;
28
29
  /**
30
   * [private description]
31
   * @var [type]
0 ignored issues
show
Documentation Bug introduced by
The doc comment [type] at position 0 could not be parsed: Unknown type name '[' at position 0 in [type].
Loading history...
32
   */
33
  private $per_hour;
34
35
  /**
36
   * [private description]
37
   * @var [type]
0 ignored issues
show
Documentation Bug introduced by
The doc comment [type] at position 0 could not be parsed: Unknown type name '[' at position 0 in [type].
Loading history...
38
   */
39
  private $ip_per_hour;
40
41
  /**
42
   * [private description]
43
   * @var [type]
0 ignored issues
show
Documentation Bug introduced by
The doc comment [type] at position 0 could not be parsed: Unknown type name '[' at position 0 in [type].
Loading history...
44
   */
45
  private $show_header;
46
47
  /**
48
   * [private description]
49
   * @var [type]
0 ignored issues
show
Documentation Bug introduced by
The doc comment [type] at position 0 could not be parsed: Unknown type name '[' at position 0 in [type].
Loading history...
50
   */
51
  private $whitelist;
52
53
  /**
54
   * [private description]
55
   * @var [type]
0 ignored issues
show
Documentation Bug introduced by
The doc comment [type] at position 0 could not be parsed: Unknown type name '[' at position 0 in [type].
Loading history...
56
   */
57
  private $checked_rate_limit = false;
58
59
  /**
60
   * [private description]
61
   * @var [type]
0 ignored issues
show
Documentation Bug introduced by
The doc comment [type] at position 0 could not be parsed: Unknown type name '[' at position 0 in [type].
Loading history...
62
   */
63
  private $header_prefix;
64
65
  /**
66
   * [private description]
67
   * @var [type]
0 ignored issues
show
Documentation Bug introduced by
The doc comment [type] at position 0 could not be parsed: Unknown type name '[' at position 0 in [type].
Loading history...
68
   */
69
  private $limit_api;
70
71
  /**
72
   * [public description]
73
   * @var [type]
0 ignored issues
show
Documentation Bug introduced by
The doc comment [type] at position 0 could not be parsed: Unknown type name '[' at position 0 in [type].
Loading history...
74
   */
75
  public  $userId;
76
77
  /**
78
   * [public description]
79
   * @var [type]
0 ignored issues
show
Documentation Bug introduced by
The doc comment [type] at position 0 could not be parsed: Unknown type name '[' at position 0 in [type].
Loading history...
80
   */
81
  public $apiKey;
82
83
  /**
84
   * [public description]
85
   * @var [type]
0 ignored issues
show
Documentation Bug introduced by
The doc comment [type] at position 0 could not be parsed: Unknown type name '[' at position 0 in [type].
Loading history...
86
   */
87
  public  $apiKeyHeader;
88
89
  /**
90
   * [public description]
91
   * @var [type]
0 ignored issues
show
Documentation Bug introduced by
The doc comment [type] at position 0 could not be parsed: Unknown type name '[' at position 0 in [type].
Loading history...
92
   */
93
  public $token;
94
95
  /**
96
   * [public description]
97
   * @var [type]
0 ignored issues
show
Documentation Bug introduced by
The doc comment [type] at position 0 could not be parsed: Unknown type name '[' at position 0 in [type].
Loading history...
98
   */
99
  public $allowedIps;
100
101
  /**
102
   * [public description]
103
   * @var [type]
0 ignored issues
show
Documentation Bug introduced by
The doc comment [type] at position 0 could not be parsed: Unknown type name '[' at position 0 in [type].
Loading history...
104
   */
105
  public $config;
106
107
  /**
108
   * [public description]
109
   * @var [type]
0 ignored issues
show
Documentation Bug introduced by
The doc comment [type] at position 0 could not be parsed: Unknown type name '[' at position 0 in [type].
Loading history...
110
   */
111
  public $authPreempted = false;
112
113
  /**
114
   * [PACKAGE description]
115
   * @var string
116
   */
117
  const PACKAGE = "francis94c/ci-rest";
118
119
  /**
120
   * [RATE_LIMIT description]
121
   * @var string
122
   */
123
  const RATE_LIMIT = "RateLimit";
124
125
  /**
126
   * [AUTH_GRAVITY description]
127
   * @var integer
128
   */
129
  const AUTH_GRAVITY = 0b100;
130
  const AUTH_PASSIVE = 0b010;
131
  const AUTH_FINAL   = 0b001;
132
133
  /**
134
   * [__construct This is the part of the code that takes care of all
135
   * authentiations. allowing you to focus on building wonderful things at REST.
136
   * pun intended ;-)]
137
   * @param array|null $params Initialization parameters from the Slint system.
138
   *                           There's no use for this arg yet.
139
   */
140
  function __construct(?array $params=null)
141
  {
142
    $this->ci =& get_instance();
143
144
    if ($this->ci->input->is_cli_request()) return;
145
146
    // Load Config If Exists.
147
    //$this->ci->config->load('rest', true, true);
148
    if (is_file(APPPATH . 'config/rest.php')) {
149
      include APPPATH . 'config/rest.php';
150
    } else {
151
      $config = [];
152
    }
153
154
    $this->config = $config;
155
156
    // Load Database.
157
    $this->ci->load->database();
158
159
    // load URL Helper
160
    $this->ci->load->helper('url');
161
162
    // Load REST Helper.
163
    $this->ci->load->splint(self::PACKAGE, '%rest');
164
165
    // Load Model.
166
    $this->ci->load->splint(self::PACKAGE, '*RESTModel', 'rest_model');
167
    $this->rest_model =& $this->ci->rest_model;
168
169
    $this->rest_model->init([
170
      'users_table'           => $config['basic_auth']['users_table'] ?? null,
171
      'users_id_column'       => $config['basic_auth']['id_column'] ?? null,
172
      'users_username_column' => $config['basic_auth']['username_column'] ?? null,
173
      'users_email_column'    => $config['basic_auth']['email_column'] ?? null,
174
      'users_password_column' => $config['basic_auth']['password_column'] ?? null,
175
      'api_key_table'         => $config['api_key_auth']['api_key_table'] ?? null,
176
      'api_key_column'        => $config['api_key_auth']['api_key_column'] ?? null,
177
      'api_key_limit_column'  => $config['api_key_auth']['api_key_limit_column'] ?? null
178
    ]);
179
180
    // Load Variable(s) from Config.
181
    $this->allowedIps = $config['allowed_ips'] ?? ['127.0.0.1', '[::1]'];
182
    $this->apiKeyHeader = $config['api_key_header'] ?? 'X-API-KEY';
183
    $this->api_key_limit_column = $config['api_key_auth']['api_key_limit_column'] ?? null;
184
    $this->api_key_column = $config['api_key_auth']['api_key_column'] ?? null;
185
    $this->limit_api = $config['api_limiter']['api_limiter'] ?? false;
186
    $this->per_hour = $config['api_limiter']['per_hour'] ?? 100;
187
    $this->ip_per_hour = $config['api_limiter']['ip_per_hour'] ?? 50;
188
    $this->show_header = $config['api_limiter']['show_header'] ?? null;
189
    $this->whitelist = $config['api_limiter']['whitelist'] ?? [];
190
    $this->header_prefix = $config['api_limiter']['header_prefix'] ?? 'X-RateLimit-';
191
192
    // Limit Only?
193
    //if ($this->config['api_limiter']['api_limit_only'] ?? false) {
194
      //return;
195
    //}
196
197
    // Authenticate
198
    if ($this->ci->uri->total_segments() > 0) {
199
      $this->authenticate();
200
    }
201
202
    // Generic Rate Limiter.
203
    if ($this->limit_api && !$this->checked_rate_limit &&
204
    ($config['api_limiter']['limit_by_ip'] ?? false)) {
205
      $this->api_rest_limit_by_ip_address();
206
    }
207
208
    log_message('debug', 'REST Request Authenticated and REST Library Initialized.');
209
  }
210
211
  /**
212
   * [authenticate description]
213
   * @date 2020-01-30
214
   */
215
  private function authenticate():void
216
  {
217
    $auths = null;
218
    $auths = $this->config['auth'] ?? null;
219
    if ($auths) $auths = is_array($auths) ? $auths : [$auths];
220
221
    if (!$auths) return; // No authentication(s) to carry out.
222
223
    /**
224
     * $this->process_auth() terminates the script if authentication fails
225
     * It will call the callable in the rest.php config file under
226
     * response_callbacks which matches the necesarry RESTResponse constant
227
     * before exiting. Which callable is called in any situation is documented
228
     * in README.md
229
     */
230
231
    foreach ($auths as $key => $auth) {
232
      if ($this->authPreempted) break;
233
      if (is_numeric($key)) {
234
        $this->process_auth($auth, self::AUTH_GRAVITY);
235
      } else {
236
        $this->process_auth($key, $auth);
237
      }
238
    }
239
  }
240
241
  /**
242
   * [process_auth description]
243
   * @date  2020-04-07
244
   * @param string     $auth  [description]
245
   * @param int        $flags [description]
246
   */
247
  private function process_auth(string &$auth, int $flags):void
248
  {
249
    switch ($auth) {
250
      case RESTAuth::IP: $this->ip_auth($flags); break;
251
      case RESTAuth::BASIC: $this->basic_auth($flags); break;
252
      case RESTAuth::API_KEY: $this->api_key_auth($flags); break;
253
      case RESTAuth::OAUTH2: $this->bearer_auth(RESTAuth::OAUTH2, $flags); break;
254
      case RESTAuth::BEARER: $this->bearer_auth(RESTAuth::BEARER, $flags); break;
255
      case RESTAuth::SECRET: $this->bearer_auth(RESTAuth::SECRET, $flags); break;
256
      default: $this->custom_auth($auth, $flags);
257
    }
258
  }
259
260
  /**
261
   * [auth_proceed description]
262
   * @date   2020-04-07
263
   * @param  bool       $success [description]
264
   * @param  int        $flags   [description]
265
   * @return bool                [description]
266
   */
267
  private function auth_proceed(bool $success, int $flags):bool
268
  {
269
    if ($flags & self::AUTH_GRAVITY) return $success;
270
    if ($success) {
271
      if ($flags & self::AUTH_FINAL) {
272
        $this->authPreempted = true;
273
        return true;
274
      }
275
    } else {
276
      return $flags & self::AUTH_PASSIVE ? true : false;
277
    }
278
  }
279
280
  /**
281
   * [ip_auth description]
282
   * @date  2020-04-07
283
   * @param int        $flags [description]
284
   */
285
  private function ip_auth(int $flags):void
286
  {
287
    if (!$this->auth_proceed(in_array($this->ci->input->ip_address(), $this->allowedIps), $flags)) {
288
      $this->handle_response(RESTResponse::UN_AUTHORIZED, RESTAuth::IP); // Exits.
289
    }
290
  }
291
292
  /**
293
   * [bearer_auth description]
294
   * @date  2020-04-07
295
   * @param string     $auth  [description]
296
   * @param int        $flags [description]
297
   */
298
  private function bearer_auth(string $auth, int $flags):void
299
  {
300
    $authorization = $this->get_authorization_header();
301
    $shouldProceed = $this->auth_proceed(false, $flags);
302
    if ($authorization == null || substr_count($authorization, ' ') != 1) {
303
      if ($shouldProceed) return;
304
      $this->handle_response(RESTResponse::BAD_REQUEST, $auth, 'Bad Request'); // Exits.
305
    }
306
    $token = explode(" ", $authorization);
307
    if ($token[0] != $auth) {
308
      if ($shouldProceed) return;
309
      $this->handle_response(RESTResponse::BAD_REQUEST, $auth, 'Bad Request'); // Exits.
310
    }
311
    $this->token = $token[1];
312
    // Call Up Custom Implemented Bearer/Token Authorization.
313
    // Callback Check.
314
    if (!isset($this->config['auth_callbacks'][$auth])) {
315
      $this->handle_response(RESTResponse::NOT_IMPLEMENTED, $auth); // Exits.
316
    }
317
    // Authorization.
318
    if (!$this->auth_proceed($this->config['auth_callbacks'][$auth]($this, $this->token), $flags)) {
319
      $this->handle_response(RESTResponse::UN_AUTHORIZED, $auth); // Exits.
320
    }
321
  }
322
323
  /**
324
   * [basic_auth description]
325
   * @date  2020-04-07
326
   * @param int        $flags [description]
327
   */
328
  private function basic_auth(int $flags):void
329
  {
330
    $username = $_SERVER['PHP_AUTH_USER'] ?? null;
331
    $password = $_SERVER['PHP_AUTH_PW'] ?? null;
332
    if (!$this->auth_proceed(!$username || !$password, $flags)) $this->handle_response(RESTResponse::BAD_REQUEST, RESTAuth::BASIC); // Exits.
333
    if (!$this->auth_proceed($this->rest_model->basicAuth($this, $username, $password), $flags)) $this->handle_response(RESTResponse::UN_AUTHORIZED, RESTAuth::BASIC); // Exits.
334
  }
335
  /**
336
   * [api_key_auth description]
337
   */
338
  private function api_key_auth(int $flags=self::AUTH_GRAVITY):void
339
  {
340
    if (uri_string() == '')  return;
341
    $shouldProceed = $this->auth_proceed(false, $flags);
342
343
    if (!$this->ci->input->get_request_header($this->apiKeyHeader, true) && !$shouldProceed) {
344
    // if (!isset($_SERVER['HTTP_' . str_replace("-", "_", $this->apiKeyHeader)])) {
345
      $this->handle_response(RESTResponse::BAD_REQUEST, RESTAuth::API_KEY); // Exits.
346
    }
347
348
    $apiKey = $this->rest_model->getAPIKeyData(
349
      $this->ci->input->get_request_header($this->apiKeyHeader, true)
350
    );
351
352
    if ($apiKey == null && !$shouldProceed) {
353
      $this->handle_response(RESTResponse::UN_AUTHORIZED, RESTAuth::API_KEY); // Exits.
354
    }
355
356
    $this->apiKey = $apiKey;
357
358
    if (!$this->auth_proceed(true, $flags)) return;
359
360
    // ==== API KEY Auth Passed ==== //
361
362
    if ($this->limit_api && $this->api_key_limit_column != null && $apiKey->{$this->api_key_limit_column} == 1) {
363
      $this->limitAPIKey($apiKey->{$this->api_key_column});
364
    }
365
366
    $this->checked_rate_limit = true; // Ignore Limit By IP.
367
  }
368
369
  /**
370
   * [limitAPIKey description]
371
   * @date  2020-04-08
372
   * @param string     $apiKey [description]
373
   */
374
  public function limitAPIKey(string $apiKey):void
375
  {
376
    // Trunctate Rate Limit Data.
377
    $this->rest_model->truncateRatelimitData();
378
    // Check Whitelist.
379
    if (in_array($this->ci->input->ip_address(), $this->whitelist)) {
380
      $this->checked_rate_limit = true; // Ignore Limit By IP.
381
      return;
382
    }
383
    // Should we acyually Limit?
384
    if ($this->per_hour > 0) {
385
      $client = hash('md5', $this->ci->input->ip_address() . "%" . $apiKey);
386
      $limitData = $this->rest_model->getLimitData($client, '_api_keyed_user');
387
      if ($limitData == null) {
388
        $limitData = [];
389
        $limitData['count'] = 0;
390
        $limitData['reset_epoch'] = gmdate('d M Y H:i:s', time() + (60 * 60));
391
        $limitData['start'] = date('d M Y H:i:s');
392
      }
393
      if ($this->per_hour - $limitData['count'] > 0) {
394
        if (!$this->rest_model->insertLimitData($client, '_api_keyed_user')) {
395
          $this->handle_response(RESTResponse::INTERNAL_SERVER_ERROR, self::RATE_LIMIT); // Exits.
396
        }
397
        ++$limitData['count'];
398
        if ($this->show_header) {
399
          header($this->header_prefix.'Limit: '.$this->per_hour);
400
          header($this->header_prefix.'Remaining: '.($this->per_hour - $limitData['count']));
401
          header($this->header_prefix.'Reset: '.strtotime($limitData['reset_epoch']));
402
        }
403
      } else {
404
        header('Retry-After: '.(strtotime($limitData['reset_epoch']) - strtotime(gmdate('d M Y H:i:s'))));
405
        $this->handle_response(RESTResponse::TOO_MANY_REQUESTS, self::RATE_LIMIT); // Exits.
406
      }
407
    }
408
  }
409
410
  /**
411
   * [api_rest_limit_by_ip_address description]
412
   * TODO: Implement.
413
   */
414
  private function api_rest_limit_by_ip_address():void
415
  {
416
    // Trunctate Rate Limit Data.
417
    $this->rest_model->truncateRatelimitData();
418
    // Check Whitelist.
419
    if (in_array($this->ci->input->ip_address(), $this->whitelist)) return;
420
    // Should we acyually Limit?
421
    if ($this->ip_per_hour > 0) {
422
      $client = hash('md5', $this->ci->input->ip_address());
423
      $limitData = $this->rest_model->getLimitData($client, '_ip_address');
424
      if ($limitData == null) {
425
        $limitData = [];
426
        $limitData['count'] = 0;
427
        $limitData['reset_epoch'] = gmdate('d M Y H:i:s', time() + (60 * 60));
428
        $limitData['start'] = date('d M Y H:i:s');
429
      }
430
      if ($this->ip_per_hour - $limitData['count'] > 0) {
431
        if (!$this->rest_model->insertLimitData($client, '_ip_address')) {
432
          $this->handle_response(RESTResponse::INTERNAL_SERVER_ERROR, self::RATE_LIMIT); // Exits.
433
        }
434
        ++$limitData['count'];
435
        if ($this->show_header) {
436
          header($this->header_prefix.'Limit: '.$this->ip_per_hour);
437
          header($this->header_prefix.'Remaining: '.($this->ip_per_hour - $limitData['count']));
438
          header($this->header_prefix.'Reset: '.strtotime($limitData['reset_epoch']));
439
        }
440
      } else {
441
        header('Retry-After: '.(strtotime($limitData['reset_epoch']) - strtotime(gmdate('d M Y H:i:s'))));
442
        $this->handle_response(RESTResponse::TOO_MANY_REQUESTS, self::RATE_LIMIT); // Exits.
443
      }
444
    }
445
  }
446
  /**
447
   * [custom_auth description]
448
   * @param string $auth [description]
449
   */
450
  private function custom_auth(string &$auth):void
451
  {
452
    // Header Check.
453
    if (!isset($_SERVER[$auth])) {
454
      $this->handle_response(RESTResponse::BAD_REQUEST, $auth);
455
    }
456
    // Callback Check.
457
    if (!isset($this->config['auth_callbacks'][$auth])) {
458
      $this->handle_response(RESTResponse::NOT_IMPLEMENTED, $auth); // Exits.
459
    }
460
    // Authentication.
461
    if (!$this->config['auth_callbacks'][$auth]($this, $this->ci->security->xss_clean($_SERVER[$auth]))) {
462
      $this->handle_response(RESTResponse::UN_AUTHORIZED, $auth); // Exits.
463
    }
464
  }
465
  /**
466
   * [get_authorization_header description]
467
   * @return [type] [description]
0 ignored issues
show
Documentation Bug introduced by
The doc comment [type] at position 0 could not be parsed: Unknown type name '[' at position 0 in [type].
Loading history...
468
   */
469
  private function get_authorization_header():?string
470
  {
471
    if (isset($_SERVER['Authorization'])) {
472
      return trim($_SERVER["Authorization"]);
473
    } else if (isset($_SERVER['HTTP_AUTHORIZATION'])) { //Nginx or fast CGI
474
      return trim($_SERVER["HTTP_AUTHORIZATION"]);
475
    } elseif (function_exists('apache_request_headers')) {
476
      $requestHeaders = apache_request_headers();
477
478
      // Avoid Surprises.
479
      $requestHeaders = array_combine(array_map('ucwords', array_keys($requestHeaders)), array_values($requestHeaders));
480
481
      if (isset($requestHeaders['Authorization'])) {
482
        return trim($requestHeaders['Authorization']);
483
      }
484
    }
485
    return null;
486
  }
487
488
  /**
489
   * [handle_response description]
490
   * @param int $code [description]
491
   */
492
  private function handle_response(int $code, $auth=null, ?string $errorReason=null):void
493
  {
494
    http_response_code($code);
495
    header("Content-Type: application/json");
496
    if (isset($this->config['response_callbacks'][$code])) {
497
      $this->config['response_callbacks'][$code]($auth, $errorReason);
498
    }
499
    if (ENVIRONMENT != 'testing') exit($code);
500
    throw new Exception("Error $code in $auth", $code);
501
  }
502
}
503
?>
504