PluginProbe
Advanced Access Manager – Access Governance for WordPress / 7.1.2
Advanced Access Manager – Access Governance for WordPress v7.1.2
7.1.4 7.1.2 7.1.3 6.8.4 6.8.5 6.9.0 6.9.1 6.9.10 6.9.11 6.9.12 6.9.13 6.9.14 6.9.15 6.9.16 6.9.17 6.9.18 6.9.19 6.9.2 6.9.20 6.9.21 6.9.22 6.9.23 6.9.24 6.9.25 6.9.26 All 210 releases
advanced-access-manager / application / Framework / Utility / Jwt.php
Jwt.php
767 lines 20.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * ======================================================================
5 * LICENSE: This file is subject to the terms and conditions defined in *
6 * file 'license.txt', which is part of this source code package. *
7 * ======================================================================
8 */
9
10 /**
11 * AAM framework utilities
12 *
13 * @package AAM
14 *
15 * @version 7.0.0
16 */
17 class AAM_Framework_Utility_Jwt implements AAM_Framework_Utility_Interface
18 {
19
20 use AAM_Framework_Utility_BaseTrait;
21
22 /**
23 * When checking nbf, iat or expiration times,
24 * we want to provide some extra leeway time to
25 * account for clock skew.
26 *
27 * @var int
28 * @access private
29 *
30 * @version 7.0.0
31 */
32 private $_leeway = 0;
33
34 /**
35 * Collection of supported signing algorithms
36 *
37 * @var array
38 * @access private
39 *
40 * @version 7.0.0
41 */
42 private $_supported_algs = array(
43 'ES384' => array('openssl', 'SHA384'),
44 'ES256' => array('openssl', 'SHA256'),
45 'HS256' => array('hash_hmac', 'SHA256'),
46 'HS384' => array('hash_hmac', 'SHA384'),
47 'HS512' => array('hash_hmac', 'SHA512'),
48 'RS256' => array('openssl', 'SHA256'),
49 'RS384' => array('openssl', 'SHA384'),
50 'RS512' => array('openssl', 'SHA512'),
51 'EdDSA' => array('sodium_crypto', 'EdDSA'),
52 );
53
54 /**
55 * Create new token
56 *
57 * @param int $user_id
58 * @param array $claims [Optional]
59 * @param int|string $ttl [Optional]
60 *
61 * @return array
62 * @access public
63 *
64 * @version 7.0.0
65 */
66 public function issue($user_id, array $claims = [], $ttl = null)
67 {
68 if (is_null($ttl)) {
69 $ttl = '+24 hours';
70 } elseif (is_numeric($ttl)) {
71 $ttl = "+{$ttl} seconds";
72 } elseif (!is_string($ttl)) {
73 throw new InvalidArgumentException('Invalid token ttl');
74 }
75
76 $time = new DateTime($ttl, new DateTimeZone('UTC'));
77 $claims = array_merge(
78 [ 'jti' => $this->_generate_uuid() ],
79 $claims,
80 [
81 'iat' => time(),
82 'iss' => get_site_url(),
83 'exp' => $time->getTimestamp(),
84 'user_id' => $user_id
85 ]
86 );
87
88 // Generating token & return result
89 return [
90 'token' => $this->_encode($claims),
91 'claims' => $claims
92 ];
93 }
94
95 /**
96 * Validate token and return claims if valid
97 *
98 * @param string $token
99 *
100 * @return bool|WP_Error
101 * @access private
102 *
103 * @version 7.0.0
104 */
105 public function validate($token)
106 {
107 $result = true;
108
109 try {
110 // Validating header segment. Make sure that all necessary properties
111 // are defined correctly
112 $headers = $this->_validate_header($token);
113
114 // Get signing attributes
115 $attrs = $this->_get_signing_attributes(false, $headers['alg']);
116
117 // Verify the signature
118 $this->_validate_signature($token, $attrs->key);
119
120 $tms = (new DateTime('now', new DateTimeZone('UTC')))->getTimestamp();
121 $claims = $this->_decode_segment($token, 1);
122
123 // Check the nbf if it is defined. This is the time that the
124 // token can actually be used. If it's not yet that time, abort.
125 if (isset($claims['nbf']) && $claims['nbf'] > ($tms + $this->_leeway)) {
126 throw new RuntimeException(
127 'Cannot take token prior to ' . date(DateTime::ATOM, $claims['nbf'])
128 );
129 }
130
131 // Check that this token has been created before 'now'. This prevents
132 // using tokens that have been created for later use (and haven't
133 // correctly used the nbf claim).
134 if (isset($claims['iat']) && $claims['iat'] > ($tms + $this->_leeway)) {
135 throw new RuntimeException(
136 'Cannot take token prior to ' . date(DateTime::ATOM, $claims['iat'])
137 );
138 }
139
140 // Check if this token has expired.
141 if (isset($claims['exp']) && ($tms - $this->_leeway) >= $claims['exp']) {
142 throw new RuntimeException('Expired token');
143 }
144 } catch (Exception $e) {
145 $result = new WP_Error('invalid_token', $e->getMessage());
146 }
147
148 return $result;
149 }
150
151 /**
152 * Determine if token is valid
153 *
154 * @param string $token
155 *
156 * @return bool
157 * @access public
158 *
159 * @version 7.0.0
160 */
161 public function is_valid($token)
162 {
163 $result = $this->validate($token);
164
165 return is_bool($result) ? $result : false;
166 }
167
168 /**
169 * Decode a token and return claims
170 *
171 * @param string $token
172 *
173 * @return array|WP_Error
174 * @access public
175 *
176 * @version 7.0.0
177 */
178 public function decode($token)
179 {
180 try {
181 $result = $this->_decode_segment($token, 1);
182 } catch (Exception $e) {
183 $result = new WP_Error('invalid_token', $e->getMessage());
184 }
185
186 return $result;
187 }
188
189 /**
190 * Encode payload
191 *
192 * @param array $claims
193 *
194 * @return string
195 * @access private
196 *
197 * @version 7.0.0
198 */
199 private function _encode(array $claims)
200 {
201 $attrs = $this->_get_signing_attributes(true);
202
203 // Encode the JWT headers first
204 $segments = array($this->_url_safe_b64_encode($this->_json_encode(array(
205 'typ' => 'JWT',
206 'alg' => $attrs->alg
207 ))));
208
209 // Next, let's encode the payload
210 array_push($segments, $this->_url_safe_b64_encode(
211 $this->_json_encode($claims)
212 ));
213
214 // Adding signature to as the last segment
215 array_push($segments, $this->_url_safe_b64_encode($this->_sign(
216 implode('.', $segments),
217 $attrs->key,
218 $attrs->alg
219 )));
220
221 return implode('.', $segments);
222 }
223
224 /**
225 * Validate token header
226 *
227 * @param string $token
228 *
229 * @return stdClass
230 *
231 * @access protected
232 * @version 7.0.0
233 */
234 private function _validate_header($token)
235 {
236 $headers = $this->_decode_segment($token);
237
238 if (empty($headers['alg'])) {
239 throw new UnexpectedValueException('Empty algorithm');
240 }
241
242 if (empty($this->_supported_algs[$headers['alg']])) {
243 throw new UnexpectedValueException('Algorithm not supported');
244 }
245
246 return $headers;
247 }
248
249 /**
250 * Generate random uuid
251 *
252 * @return string
253 * @access private
254 *
255 * @version 7.0.0
256 */
257 private function _generate_uuid()
258 {
259 return sprintf(
260 '%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
261 // 32 bits for "time_low"
262 mt_rand(0, 0xffff),
263 mt_rand(0, 0xffff),
264
265 // 16 bits for "time_mid"
266 mt_rand(0, 0xffff),
267
268 // 16 bits for "time_hi_and_version",
269 // four most significant bits holds version number 4
270 mt_rand(0, 0x0fff) | 0x4000,
271
272 // 16 bits, 8 bits for "clk_seq_hi_res",
273 // 8 bits for "clk_seq_low",
274 // two most significant bits holds zero and one for variant DCE1.1
275 mt_rand(0, 0x3fff) | 0x8000,
276
277 // 48 bits for "node"
278 mt_rand(0, 0xffff),
279 mt_rand(0, 0xffff),
280 mt_rand(0, 0xffff)
281 );
282 }
283
284 /**
285 * Sign a string with a given key and algorithm.
286 *
287 * @param string $msg The message to sign
288 * @param mixed $key The secret key.
289 * @param string $alg Supported algorithms are 'ES384','ES256', 'HS256', 'HS384',
290 * 'HS512', 'RS256', 'RS384', and 'RS512'
291 *
292 * @return string An encrypted message
293 * @access private
294 *
295 * @version 7.0.0
296 */
297 private function _sign($msg, $key, $alg)
298 {
299 $signature = null;
300
301 if (empty($this->_supported_algs[$alg])) {
302 throw new RuntimeException('Algorithm not supported');
303 } elseif (empty($key)) {
304 throw new InvalidArgumentException('The signing key cannot be empty');
305 }
306
307 list($function, $algorithm) = $this->_supported_algs[$alg];
308
309 if ($function === 'hash_hmac') {
310 $signature = hash_hmac($algorithm, $msg, $key, true);
311 } elseif ($function === 'openssl') {
312 $success = openssl_sign($msg, $signature, $key, $algorithm);
313
314 if (!$success) {
315 throw new RuntimeException('OpenSSL unable to sign data');
316 }
317
318 if ($alg === 'ES256') {
319 $signature = $this->_signature_from_der($signature, 256);
320 } elseif ($alg === 'ES384') {
321 $signature = $this->_signature_from_der($signature, 384);
322 }
323 } elseif ($function === 'sodium_crypto') {
324 if (!function_exists('sodium_crypto_sign_detached')) {
325 throw new RuntimeException('libsodium is not available');
326 }
327
328 // The last non-empty line is used as the key.
329 $lines = array_filter(explode("\n", $key));
330 $key = base64_decode((string) end($lines));
331 $signature = sodium_crypto_sign_detached($msg, $key);
332 } else {
333 throw new RuntimeException('Algorithm not supported');
334 }
335
336 return $signature;
337 }
338
339 /**
340 * Get token signing attributes like algorithm and key
341 *
342 * @param bool $to_sign
343 * @param string $alg
344 *
345 * @return stdClass
346 *
347 * @access private
348 * @version 7.0.0
349 */
350 private function _get_signing_attributes($to_sign = false, $alg = null)
351 {
352 if (empty($alg)) {
353 $alg = AAM_Framework_Manager::_()->config->get(
354 'service.jwt.signing_algorithm', 'HS256'
355 );
356 }
357
358 $alg_upper = strtoupper($alg);
359
360 if (strpos($alg_upper, 'RS') === 0) {
361 $key = $this->_get_signing_key_from_cert($to_sign);
362 } else {
363 $key = AAM_Framework_Manager::_()->config->get(
364 'service.jwt.signing_secret', SECURE_AUTH_KEY
365 );
366 }
367
368 return (object) array(
369 'alg' => $alg_upper,
370 'key' => $key
371 );
372 }
373
374 /**
375 * Validate token's signature
376 *
377 * @param string $token
378 * @param string $key
379 *
380 * @return void
381 *
382 * @access protected
383 * @version 7.0.0
384 */
385 private function _validate_signature($token, $key)
386 {
387 $headers = $this->_decode_segment($token);
388 $signature = $this->_decode_segment($token, 2, true);
389
390 if ($headers['alg'] === 'ES256' || $headers['alg'] === 'ES384') {
391 // OpenSSL expects an ASN.1 DER sequence for ES256/ES384 signatures
392 $signature = $this->_signature_to_der($signature);
393 }
394
395 // Now verifying the token
396 list($a, $b) = explode('.', $token);
397 list($function, $algorithm) = $this->_supported_algs[$headers['alg']];
398 $msg = "{$a}.{$b}";
399
400 if ($function === 'openssl') {
401 $success = openssl_verify($msg, $signature, $key, $algorithm) === 1;
402 } else if ($function === 'sodium_crypto') {
403 if (!function_exists('sodium_crypto_sign_verify_detached')) {
404 throw new RuntimeException('libsodium is not available');
405 }
406
407 // The last non-empty line is used as the key.
408 $lines = array_filter(explode("\n", $key));
409 $key = base64_decode((string) end($lines));
410 $success = sodium_crypto_sign_verify_detached($signature, $msg, $key);
411 } else {
412 $hash = hash_hmac($algorithm, $msg, $key, true);
413 $success = $this->_constant_time_equals($hash, $signature);
414 }
415
416 if ($success !== true) {
417 throw new RuntimeException('Invalid token signature');
418 }
419 }
420
421 /**
422 * Comparing hashes
423 *
424 * @param string $left The string of known length to compare against
425 * @param string $right The user-supplied string
426 *
427 * @return bool
428 * @access private
429 *
430 * @version 7.0.0
431 */
432 private function _constant_time_equals($left, $right)
433 {
434 $response = false;
435
436 if (function_exists('hash_equals')) {
437 $response = hash_equals($left, $right);
438 } else {
439 $len = min($this->_safe_strlen($left), $this->_safe_strlen($right));
440
441 $status = 0;
442 for ($i = 0; $i < $len; $i++) {
443 $status |= (ord($left[$i]) ^ ord($right[$i]));
444 }
445
446 $status |= ($this->_safe_strlen($left) ^ $this->_safe_strlen($right));
447
448 $response = $status === 0;
449 }
450
451 return $response;
452 }
453
454 /**
455 * Get the number of bytes in cryptographic strings.
456 *
457 * @param string $str
458 *
459 * @return int
460 * @access private
461 *
462 * @version 7.0.0
463 */
464 private function _safe_strlen($str)
465 {
466 $response = 0;
467
468 if (function_exists('mb_strlen')) {
469 $response = mb_strlen($str, '8bit');
470 } else {
471 $response = strlen($str);
472 }
473
474 return $response;
475 }
476
477 /**
478 * Convert an ECDSA signature to an ASN.1 DER sequence
479 *
480 * @param string $sig The ECDSA signature to convert
481 *
482 * @return string The encoded DER object
483 * @access private
484 *
485 * @version 7.0.0
486 */
487 private function _signature_to_der($sig)
488 {
489 // Separate the signature into r-value and s-value
490 $length = max(1, (int) (strlen($sig) / 2));
491 list($r, $s) = str_split($sig, $length);
492
493 // Trim leading zeros
494 $r = ltrim($r, "\x00");
495 $s = ltrim($s, "\x00");
496
497 // Convert r-value and s-value from unsigned big-endian integers to
498 // signed two's complement
499 if (ord($r[0]) > 0x7f) {
500 $r = "\x00" . $r;
501 }
502
503 if (ord($s[0]) > 0x7f) {
504 $s = "\x00" . $s;
505 }
506
507 return $this->_encode_der(
508 0x10, $this->_encode_der(0x02, $r) . $this->_encode_der(0x02, $s)
509 );
510 }
511
512 /**
513 * Encodes signature from a DER object.
514 *
515 * @param string $der binary signature in DER format
516 * @param int $keySize the number of bits in the key
517 *
518 * @return string the signature
519 * @access private
520 *
521 * @version 7.0.0
522 */
523 private function _signature_from_der($der, $keySize)
524 {
525 // OpenSSL returns the ECDSA signatures as a binary ASN.1 DER SEQUENCE
526 list($offset, $_) = $this->_read_der($der);
527 list($offset, $r) = $this->_read_der($der, $offset);
528 list($offset, $s) = $this->_read_der($der, $offset);
529
530 // Convert r-value and s-value from signed two's compliment to unsigned
531 // big-endian integers
532 $r = ltrim($r, "\x00");
533 $s = ltrim($s, "\x00");
534
535 // Pad out r and s so that they are $keySize bits long
536 $r = str_pad($r, $keySize / 8, "\x00", STR_PAD_LEFT);
537 $s = str_pad($s, $keySize / 8, "\x00", STR_PAD_LEFT);
538
539 return $r . $s;
540 }
541
542 /**
543 * Reads binary DER-encoded data and decodes into a single object
544 *
545 * @param string $der the binary data in DER format
546 * @param int $offset the offset of the data stream containing the object
547 * to decode
548 *
549 * @return array{int, string|null} the new offset and the decoded object
550 * @access private
551 *
552 * @version 7.0.0
553 */
554 private function _read_der($der, $offset = 0)
555 {
556 $pos = $offset;
557 $size = strlen($der);
558 $constructed = (ord($der[$pos]) >> 5) & 0x01;
559 $type = ord($der[$pos++]) & 0x1f;
560
561 // Length
562 $len = ord($der[$pos++]);
563 if ($len & 0x80) {
564 $n = $len & 0x1f;
565 $len = 0;
566 while ($n-- && $pos < $size) {
567 $len = ($len << 8) | ord($der[$pos++]);
568 }
569 }
570
571 // Value
572 if ($type === 0x03) {
573 $pos++; // Skip the first contents octet (padding indicator)
574 $data = substr($der, $pos, $len - 1);
575 $pos += $len - 1;
576 } elseif (!$constructed) {
577 $data = substr($der, $pos, $len);
578 $pos += $len;
579 } else {
580 $data = null;
581 }
582
583 return array($pos, $data);
584 }
585
586 /**
587 * Encodes a value into a DER object.
588 *
589 * @param int $type DER tag
590 * @param string $value the value to encode
591 *
592 * @return string the encoded object
593 * @access private
594 *
595 * @version 7.0.0
596 */
597 private function _encode_der($type, $value)
598 {
599 $tag_header = 0;
600
601 if ($type === 0x10) {
602 $tag_header |= 0x20;
603 }
604
605 // Type
606 $der = chr($tag_header | $type);
607
608 // Length
609 $der .= chr(strlen($value));
610
611 return $der . $value;
612 }
613
614 /**
615 * Extract key from certificate
616 *
617 * @param bool $to_sign
618 *
619 * @return string
620 * @access private
621 *
622 * @version 7.0.0
623 */
624 private function _get_signing_key_from_cert($to_sign)
625 {
626 $response = null;
627
628 if (extension_loaded('openssl')) {
629 $config = AAM_Framework_Manager::_()->config;
630
631 if ($to_sign) {
632 $path = str_replace('{ABSPATH}', ABSPATH, $config->get(
633 'service.jwt.private_cert_path'
634 ));
635
636 $key = (is_readable($path) ? file_get_contents($path) : null);
637 $passphrase = $config->get('service.jwt.private_cert_passphrase');
638 $response = openssl_pkey_get_private($key, $passphrase);
639 } else {
640 $path = str_replace('{ABSPATH}', ABSPATH, $config->get(
641 'service.jwt.public_cert_path'
642 ));
643
644 $key = (is_readable($path) ? file_get_contents($path) : null);
645 $response = openssl_pkey_get_public($key);
646 }
647 }
648
649 return $response;
650 }
651
652 /**
653 * Decode a token's segment
654 *
655 * @param string $token
656 * @param integer $segment
657 * @param boolean $returnRaw
658 *
659 * @return mixed
660 *
661 * @access protected
662 * @version 7.0.0
663 */
664 private function _decode_segment($token, $segment = 0, $return_raw = false)
665 {
666 $segments = explode('.', $token);
667
668 if (count($segments) !== 3) {
669 throw new UnexpectedValueException('Wrong number of segments');
670 }
671
672 // Base64 decode the value
673 $decoded = $this->_url_safe_b64_decode($segments[$segment]);
674
675 return $return_raw ? $decoded : $this->_json_decode($decoded);
676 }
677
678 /**
679 * Decode a string with URL-safe Base64.
680 *
681 * @param string $input A Base64 encoded string
682 *
683 * @return string A decoded string
684 * @access private
685 *
686 * @version 7.0.0
687 */
688 private function _url_safe_b64_decode($input)
689 {
690 $remainder = strlen($input) % 4;
691
692 if ($remainder) {
693 $input .= str_repeat('=', 4 - $remainder);
694 }
695
696 return base64_decode(strtr($input, '-_', '+/'));
697 }
698
699 /**
700 * Encode a string with URL-safe Base64.
701 *
702 * @param string $input The string you want encoded
703 *
704 * @return string The base64 encode of what you passed in
705 * @access private
706 *
707 * @version 7.0.0
708 */
709 private function _url_safe_b64_encode($input)
710 {
711 return str_replace('=', '', strtr(base64_encode($input), '+/', '-_'));
712 }
713
714 /**
715 * Decode a JSON string into a PHP object.
716 *
717 * @param string $input JSON string
718 *
719 * @return array
720 * @access private
721 *
722 * @version 7.0.0
723 */
724 private function _json_decode($input)
725 {
726 $result = json_decode($input, true, 512, JSON_BIGINT_AS_STRING);
727
728 if ($errno = json_last_error()) {
729 throw new RuntimeException('Failed to decode JSON: ' . esc_js($errno));
730 } elseif (!is_array($result)) {
731 throw new UnexpectedValueException('Unexpected segment value');
732 }
733
734 return $result;
735 }
736
737 /**
738 * Encode a PHP array into a JSON string.
739 *
740 * @param array<mixed> $input A PHP array
741 *
742 * @return string JSON representation of the PHP array
743 * @access private
744 *
745 * @version 7.0.0
746 */
747 private function _json_encode(array $input): string
748 {
749 if (PHP_VERSION_ID >= 50400) {
750 $json = json_encode($input, JSON_UNESCAPED_SLASHES);
751 } else {
752 // PHP 5.3 only
753 $json = json_encode($input);
754 }
755
756 if ($errno = json_last_error()) {
757 throw new RuntimeException('Failed to encode JSON: ' . esc_js($errno));
758 } elseif ($json === false) {
759 throw new RuntimeException(
760 'Provided object could not be encoded to valid JSON'
761 );
762 }
763
764 return $json;
765 }
766
767 }