Completed
Push — master ( 9c2b09...5e7564 )
by Francis
01:33
created

libraries/REST.php (3 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]
14
   */
15
  private $ci;
16
17
  /**
18
   * [private description]
19
   * @var [type]
20
   */
21
  private $api_key_limit_column;
22
23
  /**
24
   * [private description]
25
   * @var [type]
26
   */
27
  private $api_key_column;
28
29
  /**
30
   * [private description]
31
   * @var [type]
32
   */
33
  private $per_hour;
34
35
  /**
36
   * [private description]
37
   * @var [type]
38
   */
39
  private $ip_per_hour;
40
41
  /**
42
   * [private description]
43
   * @var [type]
44
   */
45
  private $show_header;
46
47
  /**
48
   * [private description]
49
   * @var [type]
50
   */
51
  private $whitelist;
52
53
  /**
54
   * [private description]
55
   * @var [type]
56
   */
57
  private $checked_rate_limit = false;
58
59
  /**
60
   * [private description]
61
   * @var [type]
62
   */
63
  private $header_prefix;
64
65
  /**
66
   * [private description]
67
   * @var [type]
68
   */
69
  private $limit_api;
70
71
  /**
72
   * [public description]
73
   * @var [type]
74
   */
75
  public  $userId;
76
77
  /**
78
   * [public description]
79
   * @var [type]
80
   */
81
  public  $apiKeyHeader;
82
83
  /**
84
   * [public description]
85
   * @var [type]
86
   */
87
  public  $token;
88
89
  /**
90
   * [public description]
91
   * @var [type]
92
   */
93
  public $allowedIps;
94
95
  /**
96
   * [PACKAGE description]
97
   * @var string
98
   */
99
  const PACKAGE = "francis94c/ci-rest";
100
101
  /**
102
   * [RATE_LIMIT description]
103
   * @var string
104
   */
105
  const RATE_LIMIT = "RateLimit";
106
107
  /**
108
   * [__construct This is the part of the code that takes care of all
109
   * authentiations. allowing you to focus on building wonderful things at REST.
110
   * pun intended ;-)]
111
   * @param array|null $params Initialization parameters from the Slint system.
112
   *                           There's no use for this arg yet.
113
   */
114
  function __construct(?array $params=null)
115
  {
116
    $this->ci =& get_instance();
0 ignored issues
show
The function get_instance was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

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

116
    $this->ci =& /** @scrutinizer ignore-call */ get_instance();
Loading history...
117
118
    if ($this->ci->input->is_cli_request()) return;
119
120
    // Load Config If Exists.
121
    $this->ci->config->load('rest', true, true);
122
123
    // Load Database.
124
    $this->ci->load->database();
125
126
    // load URL Helper
127
    $this->ci->load->helper('url');
128
129
    // Load REST Helper.
130
    $this->ci->load->splint(self::PACKAGE, '%rest');
131
132
    // Load Model.
133
    $this->ci->load->splint(self::PACKAGE, '*RESTModel', 'rest_model');
134
    $this->rest_model =& $this->ci->rest_model;
135
136
    $config = [
137
      'users_table'           => $this->ci->config->item('rest')['basic_auth']['users_table'] ?? null,
138
      'users_id_column'       => $this->ci->config->item('rest')['basic_auth']['id_column'] ?? null,
139
      'users_username_column' => $this->ci->config->item('rest')['basic_auth']['username_column'] ?? null,
140
      'users_email_column'    => $this->ci->config->item('rest')['basic_auth']['email_column'] ?? null,
141
      'users_password_column' => $this->ci->config->item('rest')['basic_auth']['password_column'] ?? null,
142
      'api_key_table'         => $this->ci->config->item('rest')['api_key_auth']['api_key_table'] ?? null,
143
      'api_key_column'        => $this->ci->config->item('rest')['api_key_auth']['api_key_column'] ?? null,
144
      'api_key_limit_column'  => $this->ci->config->item('rest')['api_key_auth']['api_key_limit_column'] ?? null
145
    ];
146
147
    $this->rest_model->init($config);
148
149
    // Load Variable(s) from Config.
150
    $this->allowedIps = $this->ci->config->item('rest')['allowed_ips'] ?? ['127.0.0.1', '[::1]'];
151
    $this->apiKeyHeader = $this->ci->config->item('rest')['api_key_header'] ?? 'X-API-KEY';
152
    $this->api_key_limit_column = $this->ci->config->item('rest')['api_key_auth']['api_key_limit_column'] ?? null;
153
    $this->api_key_column = $this->ci->config->item('rest')['api_key_auth']['api_key_column'] ?? null;
154
    $this->limit_api = $this->ci->config->item('rest')['api_limiter']['api_limiter'] ?? false;
155
    $this->per_hour = $this->ci->config->item('rest')['api_limiter']['per_hour'] ?? 100;
156
    $this->ip_per_hour = $this->ci->config->item('rest')['api_limiter']['ip_per_hour'] ?? 50;
157
    $this->show_header = $this->ci->config->item('rest')['api_limiter']['show_header'] ?? null;
158
    $this->whitelist = $this->ci->config->item('rest')['api_limiter']['whitelist'] ?? null;
159
    $this->header_prefix = $this->ci->config->item('rest')['api_limiter']['header_prefix'] ?? 'X-RateLimit-';
160
161
    // Limit Only?
162
    //if ($this->ci->config->item('rest')['api_limiter']['api_limit_only'] ?? false) {
163
      //return;
164
    //}
165
166
    // Authenticate
167
    $this->authenticate();
168
169
    // Generic Rate Limiter.
170
    if ($this->limit_api && !$this->checked_rate_limit &&
171
    ($this->ci->config->item('rest')['api_limiter']['limit_by_ip'] ?? false)) {
172
      $this->api_rest_limit_by_ip_address();
173
    }
174
175
    log_message('debug', 'REST Request Authenticated and REST Library Initialized.');
176
  }
177
178
  /**
179
   * [authenticate description]
180
   * @date 2020-01-30
181
   */
182
  private function authenticate():void
183
  {
184
    $auths = null;
185
186
    $globalAuths = $this->ci->config->item('rest')['global_auth'] ?? null;
187
188
    if ($globalAuths != null) {
189
      if (is_array($globalAuths)) {
190
        $auths = $globalAuths;
191
      } else {
192
        $auths = [$globalAuths];
193
      }
194
    }
195
196
    $uri_auths = $this->ci->config->item('rest')['uri_auth'] ?? null;
197
198
    // Match Auth Routes.
199
    // The below algorithm is similar to the one Code Igniter uses in its
200
    // Routing Class.
201
    if ($uri_auths != null || is_array($uri_auths)) {
202
      foreach ($uri_auths as $uri => $auth_array) {
203
        // Convert wildcards to RegEx.
204
  			$uri = str_replace(array(':any', ':num'), array('[^/]+', '[0-9]+'), $uri);
205
        if (preg_match('#^'.$uri.'$#', uri_string())) {
0 ignored issues
show
The function uri_string was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

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

205
        if (preg_match('#^'.$uri.'$#', /** @scrutinizer ignore-call */ uri_string())) {
Loading history...
206
          // Assign Authentication Steps.
207
          if (is_array($auth_array)) {
208
            foreach ($auth_array as $auth) {
209
              $auths[] = $auth;
210
            }
211
          } else {
212
            $auths[] = $auth_array;
213
          }
214
        }
215
        break;
216
      }
217
    }
218
219
    //$auths = $this->ci->config->item('rest')['uri_auth'][uri_string()] ?? null;
220
    if ($auths == null) return; // No authentication(s) to carry out.
221
222
    // $this->process_auth() terminates the script if authentication fails
223
    // It will call the callable in the rest.php config file under
224
    // response_callbacks which matches the necesarry RESTResponse constant
225
    // before exiting. Which callable is called in any situation is documented
226
    // in README.md
227
    //if (is_scalar($auths)) {
228
      //$this->process_auth($auths);
229
      //return;
230
    //}
231
232
    foreach ($auths as $auth) $this->process_auth($auth);
233
  }
234
  /**
235
   * [process_auth description]
236
   * @param  string $auth [description]
237
   * @return bool         [description]
238
   */
239
  private function process_auth(string &$auth):void {
240
    switch ($auth) {
241
      case RESTAuth::IP: $this->ip_auth(); break;
242
      case RESTAuth::BASIC: $this->basic_auth(); break;
243
      case RESTAuth::API_KEY: $this->api_key_auth(); break;
244
      case RESTAuth::OAUTH2: $this->bearer_auth(RESTAuth::OAUTH2); break;
245
      case RESTAuth::BEARER: $this->bearer_auth(); break;
246
      default: $this->custom_auth($auth);
247
    }
248
  }
249
  /**
250
   * [ip_auth description]
251
   */
252
  private function ip_auth():void {
253
    if (!in_array($this->ci->input->ip_address(), $this->allowedIps)) {
254
      $this->handle_response(RESTResponse::UN_AUTHORIZED, RESTAuth::IP); // Exits.
255
    }
256
  }
257
  /**
258
   * [bearer_auth description]
259
   */
260
  private function bearer_auth($auth=RESTAuth::BEARER):void {
261
    $authorization = $this->get_authorization_header();
262
    if ($authorization == null || substr_count($authorization, " ") != 1) {
263
      $this->handle_response(RESTResponse::BAD_REQUEST, $auth); // Exits.
264
    }
265
    $token = explode(" ", $authorization);
266
    if ($token[0] != "Bearer") {
267
      $this->handle_response(RESTResponse::BAD_REQUEST, $auth); // Exits.
268
    }
269
    $this->token = $token[1];
270
    // Call Up Custom Implemented Bearer/Token Authorization.
271
    // Callback Check.
272
    if (!isset($this->ci->config->item('rest')['auth_callbacks'][$auth])) {
273
      $this->handle_response(RESTResponse::NOT_IMPLEMENTED, $auth); // Exits.
274
    }
275
    // Authorization.
276
    if (!$this->ci->config->item('rest')['auth_callbacks'][$auth]($this, $this->token)) {
277
      $this->handle_response(RESTResponse::UN_AUTHORIZED, $auth); // Exits.
278
    }
279
  }
280
  /**
281
   * [basic_auth description]
282
   */
283
  private function basic_auth():void {
284
    $username = $_SERVER['PHP_AUTH_USER'] ?? null;
285
    $password = $_SERVER['PHP_AUTH_PW'] ?? null;
286
    if (!$username || !$password) $this->handle_response(RESTResponse::BAD_REQUEST, RESTAuth::BASIC); // Exits.
287
    if (!$this->rest_model->basicAuth($this, $username, $password)) $this->handle_response(RESTResponse::UN_AUTHORIZED, RESTAuth::BASIC); // Exits.
288
  }
289
  /**
290
   * [api_key_auth description]
291
   */
292
  private function api_key_auth():void
293
  {
294
    if (uri_string() == '')  return;
0 ignored issues
show
The function uri_string was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

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

294
    if (/** @scrutinizer ignore-call */ uri_string() == '')  return;
Loading history...
295
296
    if (!$this->ci->input->get_request_header($this->apiKeyHeader, true)) {
297
    // if (!isset($_SERVER['HTTP_' . str_replace("-", "_", $this->apiKeyHeader)])) {
298
      $this->handle_response(RESTResponse::BAD_REQUEST, RESTAuth::API_KEY); // Exits.
299
    }
300
301
    $apiKey = $this->rest_model->getAPIKeyData(
302
      $this->ci->input->get_request_header($this->apiKeyHeader, true)
303
    );
304
305
    if ($apiKey == null) {
306
      $this->handle_response(RESTResponse::UN_AUTHORIZED, RESTAuth::API_KEY); // Exits.
307
    }
308
309
    // API KEY Auth Passed Above.
310
    if ($this->limit_api && $this->api_key_limit_column != null && $apiKey[$this->api_key_limit_column] == 1) {
311
      // Trunctate Rate Limit Data.
312
      $this->rest_model->truncateRatelimitData();
313
      // Check Whitelist.
314
      if (in_array($this->ci->input->ip_address(), $this->whitelist)) {
315
        $this->checked_rate_limit = true; // Ignore Limit By IP.
316
        return;
317
      }
318
      // Should we acyually Limit?
319
      if ($this->per_hour > 0) {
320
        $client = hash('md5', $this->ci->input->ip_address() . "%" . $apiKey[$this->api_key_column]);
321
        $limitData = $this->rest_model->getLimitData($client, '_api_keyed_user');
322
        if ($limitData == null) {
323
          $limitData = [];
324
          $limitData['count'] = 0;
325
          $limitData['reset_epoch'] = gmdate('d M Y H:i:s', time() + (60 * 60));
326
          $limitData['start'] = date('d M Y H:i:s');
327
        }
328
        if ($this->per_hour - $limitData['count'] > 0) {
329
          if (!$this->rest_model->insertLimitData($client, '_api_keyed_user')) {
330
            $this->handle_response(RESTResponse::INTERNAL_SERVER_ERROR, self::RATE_LIMIT); // Exits.
331
          }
332
          ++$limitData['count'];
333
          if ($this->show_header) {
334
            header($this->header_prefix.'Limit: '.$this->per_hour);
335
            header($this->header_prefix.'Remaining: '.($this->per_hour - $limitData['count']));
336
            header($this->header_prefix.'Reset: '.strtotime($limitData['reset_epoch']));
337
          }
338
        } else {
339
          header('Retry-After: '.(strtotime($limitData['reset_epoch']) - strtotime(gmdate('d M Y H:i:s'))));
340
          $this->handle_response(RESTResponse::TOO_MANY_REQUESTS, self::RATE_LIMIT); // Exits.
341
        }
342
      }
343
    }
344
    $this->checked_rate_limit = true; // Ignore Limit By IP.
345
  }
346
  /**
347
   * [api_rest_limit_by_ip_address description]
348
   * TODO: Implement.
349
   */
350
  private function api_rest_limit_by_ip_address():void {
351
    // Trunctate Rate Limit Data.
352
    $this->rest_model->truncateRatelimitData();
353
    // Check Whitelist.
354
    if (in_array($this->ci->input->ip_address(), $this->whitelist)) return;
355
    // Should we acyually Limit?
356
    if ($this->ip_per_hour > 0) {
357
      $client = hash('md5', $this->ci->input->ip_address());
358
      $limitData = $this->rest_model->getLimitData($client, '_ip_address');
359
      if ($limitData == null) {
360
        $limitData = [];
361
        $limitData['count'] = 0;
362
        $limitData['reset_epoch'] = gmdate('d M Y H:i:s', time() + (60 * 60));
363
        $limitData['start'] = date('d M Y H:i:s');
364
      }
365
      if ($this->ip_per_hour - $limitData['count'] > 0) {
366
        if (!$this->rest_model->insertLimitData($client, '_ip_address')) {
367
          $this->handle_response(RESTResponse::INTERNAL_SERVER_ERROR, self::RATE_LIMIT); // Exits.
368
        }
369
        ++$limitData['count'];
370
        if ($this->show_header) {
371
          header($this->header_prefix.'Limit: '.$this->ip_per_hour);
372
          header($this->header_prefix.'Remaining: '.($this->ip_per_hour - $limitData['count']));
373
          header($this->header_prefix.'Reset: '.strtotime($limitData['reset_epoch']));
374
        }
375
      } else {
376
        header('Retry-After: '.(strtotime($limitData['reset_epoch']) - strtotime(gmdate('d M Y H:i:s'))));
377
        $this->handle_response(RESTResponse::TOO_MANY_REQUESTS, self::RATE_LIMIT); // Exits.
378
      }
379
    }
380
  }
381
  /**
382
   * [custom_auth description]
383
   * @param string $auth [description]
384
   */
385
  private function custom_auth(string &$auth):void
386
  {
387
    // Header Check.
388
    if (!isset($_SERVER[$auth])) {
389
      $this->handle_response(RESTResponse::BAD_REQUEST, $auth);
390
    }
391
    // Callback Check.
392
    if (!isset($this->ci->config->item('rest')['auth_callbacks'][$auth])) {
393
      $this->handle_response(RESTResponse::NOT_IMPLEMENTED, $auth); // Exits.
394
    }
395
    // Authentication.
396
    if (!$this->ci->config->item('rest')['auth_callbacks'][$auth]($this, $this->ci->security->xss_clean($_SERVER[$auth]))) {
397
      $this->handle_response(RESTResponse::UN_AUTHORIZED, $auth); // Exits.
398
    }
399
  }
400
  /**
401
   * [get_authorization_header description]
402
   * @return [type] [description]
403
   */
404
  private function get_authorization_header():?string
405
  {
406
    if (isset($_SERVER['Authorization'])) {
407
      return trim($_SERVER["Authorization"]);
408
    } else if (isset($_SERVER['HTTP_AUTHORIZATION'])) { //Nginx or fast CGI
409
      return trim($_SERVER["HTTP_AUTHORIZATION"]);
410
    } elseif (function_exists('apache_request_headers')) {
411
      $requestHeaders = apache_request_headers();
412
413
      // Avoid Surprises.
414
      $requestHeaders = array_combine(array_map('ucwords', array_keys($requestHeaders)), array_values($requestHeaders));
415
416
      if (isset($requestHeaders['Authorization'])) {
417
        return trim($requestHeaders['Authorization']);
418
      }
419
    }
420
    return null;
421
  }
422
423
  /**
424
   * [handle_response description]
425
   * @param int $code [description]
426
   */
427
  private function handle_response(int $code, $auth=null):void
428
  {
429
    http_response_code($code);
430
    header("Content-Type: application/json");
431
    if (isset($this->ci->config->item('rest')['response_callbacks'][$code])) {
432
      $this->ci->config->item('rest')['response_callbacks'][$code]($auth);
433
    }
434
    if (ENVIRONMENT != 'testing') exit($code);
435
    throw new Exception("Error $code in $auth", $code);
436
  }
437
}
438
?>
439