From 04152ad66159f82b4f389c6d8fa7be58578e0379 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Fri, 14 Apr 2017 17:35:23 +0700 Subject: [PATCH] Add more awesomeness --- readme.md | 21 +++++ src/JWT.php | 209 ++++++++++++++++++++++++++++++++++++++----- tests/JWTTest.php | 60 +++++++++++-- tests/stubs/priv.key | 27 ++++++ 4 files changed, 287 insertions(+), 30 deletions(-) create mode 100644 tests/stubs/priv.key diff --git a/readme.md b/readme.md index 1131d62..e975f90 100644 --- a/readme.md +++ b/readme.md @@ -14,6 +14,17 @@ use Ahc\Jwt\JWT; // Instantiate with key, algo, maxAge and leeway. $jwt = new JWT('secret', 'HS256', 3600, 10); +// Only the key is required. Defaults will be used for the rest: +// algo = HS256, maxAge = 3600, leeway = 0 +$jwt = new JWT('secret'); + +// For RS* algo, the key should be either a resource like below: +$key = openssl_pkey_new(['digest_alg' => 'sha256', 'private_key_bits' => 1024, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); +// OR, a string with full path to the RSA private key like below: +$key = '/path/to/rsa.key'; +// Then, instantiate JWT with this key and RS* as algo: +$jwt = new JWT($key, 'RS384'); + // Generate JWT token from payload array. $token = $jwt->generate([ 'uid' => 1, @@ -45,3 +56,13 @@ $token = $jwt->encode($payload); $jwt->decode($token); ``` + +## Features + +- Six algorithms supported: +``` +'HS256', 'HS384', 'HS512', 'RS256', 'RS384', 'RS512' +``` +- Leeway support 0-120 seconds. +- Timestamp spoofing for tests. +- Passphrase support for `RS*` algos. diff --git a/src/JWT.php b/src/JWT.php index fd7f219..8a7beea 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -12,20 +12,29 @@ */ class JWT { - const ERROR_KEY_EMPTY = 1; - const ERROR_ALGO_UNSUPPORTED = 2; - const ERROR_ALGO_MISSING = 3; - const ERROR_INVALID_MAXAGE = 4; - const ERROR_JSON_FAILED = 5; - const ERROR_TOKEN_INVALID = 6; - const ERROR_TOKEN_EXPIRED = 7; - const ERROR_TOKEN_NOT_NOW = 8; - const ERROR_SIGNATURE_FAILED = 9; + const ERROR_KEY_EMPTY = 10; + const ERROR_KEY_INVALID = 12; + const ERROR_ALGO_UNSUPPORTED = 20; + const ERROR_ALGO_MISSING = 22; + const ERROR_INVALID_MAXAGE = 30; + const ERROR_JSON_FAILED = 40; + const ERROR_TOKEN_INVALID = 50; + const ERROR_TOKEN_EXPIRED = 52; + const ERROR_TOKEN_NOT_NOW = 54; + const ERROR_SIGNATURE_FAILED = 60; + /** + * Supported Signing algorithms. + * + * @var array + */ protected $algos = [ 'HS256' => 'sha256', 'HS384' => 'sha384', 'HS512' => 'sha512', + 'RS256' => OPENSSL_ALGO_SHA256, + 'RS384' => OPENSSL_ALGO_SHA384, + 'RS512' => OPENSSL_ALGO_SHA512, ]; /** @@ -63,7 +72,24 @@ class JWT */ protected $leeway = 0; - public function __construct(string $key, string $algo = 'HS256', int $maxAge = 3600, int $leeway = 0) + /** + * The passphrase for RSA signing (optional). + * + * @var string|null + */ + protected $passphrase; + + /** + * Constructor. + * + * @param string|resource $key The signature key. For RS* it should be file path or resource of private key. + * @param string $algo The algorithm to sign/verify the token. + * @param integer $maxAge The TTL of token to be used to determine expiry if `iat` claim is present. + * This is also used to provide default `exp` claim in case it is missing. + * @param integer $leeway Leeway for clock skew. Shouldnot be more than 2 minutes (120s). + * @param string $pass The passphrase (only for RS* algos). + */ + public function __construct($key, string $algo = 'HS256', int $maxAge = 3600, int $leeway = 0, string $pass = null) { // @codeCoverageIgnoreStart if (empty($key)) { @@ -83,24 +109,47 @@ public function __construct(string $key, string $algo = 'HS256', int $maxAge = 3 } // @codeCoverageIgnoreEnd - $this->key = $key; - $this->algo = $algo; - $this->maxAge = $maxAge; - $this->leeway = $leeway; + $this->key = $key; + $this->algo = $algo; + $this->maxAge = $maxAge; + $this->leeway = $leeway; + $this->passphrase = $pass; } - // @codeCoverageIgnoreStart + /** + * Encode payload as JWT token. + * + * This method is alias of self::generate(). + * + * @param array $payload + * @param array $header Extra header (if any) to append. + * + * @return string URL safe JWT token. + */ public function encode(array $payload, array $header = []) : string { return $this->generate($payload, $header); } + /** + * Decode JWT token and return original payload. + * + * This method is alias of self::parse(). + * + * @param string $token + * + * @return array + */ public function decode(string $token) : array { return $this->parse($token); } - // @codeCoverageIgnoreEnd + /** + * Spoof current timestamp for testing. + * + * @param integer|null $timestamp + */ public function setTestTimestamp(int $timestamp = null) : JWT { $this->timestamp = $timestamp; @@ -108,6 +157,14 @@ public function setTestTimestamp(int $timestamp = null) : JWT return $this; } + /** + * Generate JWT token. + * + * @param array $payload + * @param array $header Extra header (if any) to append. + * + * @return string URL safe JWT token. + */ public function generate(array $payload, array $header = []) : string { $header = ['typ' => 'JWT', 'alg' => $this->algo] + $header; @@ -116,15 +173,22 @@ public function generate(array $payload, array $header = []) : string $payload['exp'] = ($this->timestamp ?? time()) + $this->maxAge; } - $header = $this->urlSafeEncode($header); - $payload = $this->urlSafeEncode($payload); - - $signature = hash_hmac($this->algos[$this->algo], $header . '.' . $payload, $this->key, true); - $signature = $this->urlSafeEncode($signature); + $header = $this->urlSafeEncode($header); + $payload = $this->urlSafeEncode($payload); + $signature = $this->urlSafeEncode($this->sign($header . '.' . $payload)); return $header . '.' . $payload . '.' . $signature; } + /** + * Parse JWT token and return original payload. + * + * @param string $token + * + * @return array + * + * @throws \InvalidArgumentException When JWT token is invalid or expired or signature can't be verified. + */ public function parse(string $token) : array { if (substr_count($token, '.') < 2) { @@ -143,8 +207,7 @@ public function parse(string $token) : array } // Validate signature. - $signature = hash_hmac($this->algos[$header->alg], $token[0] . '.' . $token[1], $this->key, true); - if (!hash_equals($this->urlSafeEncode($signature), $token[2])) { + if (!$this->verify($token[0] . '.' . $token[1], $token[2])) { throw new \InvalidArgumentException('Invalid token: Signature failed', static::ERROR_SIGNATURE_FAILED); } @@ -160,6 +223,7 @@ public function parse(string $token) : array throw new \InvalidArgumentException('Invalid token: Expired', static::ERROR_TOKEN_EXPIRED); } + // Validate nbf claim. if (isset($payload->nbf) && $timestamp <= ($payload->nbf - $this->leeway)) { throw new \InvalidArgumentException('Invalid token: Cannot accept now', static::ERROR_TOKEN_NOT_NOW); } @@ -167,6 +231,84 @@ public function parse(string $token) : array return (array) $payload; } + /** + * Sign the input with configured key and return the signature. + * + * @param string $input + * + * @return string + */ + protected function sign(string $input) : string + { + // HMAC SHA. + if (substr($this->algo, 0, 2) === 'HS') { + return hash_hmac($this->algos[$this->algo], $input, $this->key, true); + } + + $this->throwIfKeyInvalid(); + + openssl_sign($input, $signature, $this->key, $this->algos[$this->algo]); + + return $signature; + } + + /** + * Verify the signature of given input. + * + * @param string $input + * @param string $signature + * + * @return bool + * + * @throws \InvalidArgumentException When key is invalid. + */ + protected function verify(string $input, string $signature) : bool + { + $algo = $this->algos[$this->algo]; + + // HMAC SHA. + if (substr($this->algo, 0, 2) === 'HS') { + return hash_equals($this->urlSafeEncode(hash_hmac($algo, $input, $this->key, true)), $signature); + } + + $this->throwIfKeyInvalid(); + + $pubKey = openssl_pkey_get_details($this->key)['key']; + + return openssl_verify($input, $this->urlSafeDecode($signature, false), $pubKey, $algo) === 1; + } + + /** + * Throw up if key is not resource or file path to private key. + * + * @throws \InvalidArgumentException + */ + protected function throwIfKeyInvalid() + { + if (is_string($this->key)) { + if (!is_file($this->key)) { + throw new \InvalidArgumentException('Invalid key: Should be file path of private key', static::ERROR_KEY_INVALID); + } + + $this->key = openssl_get_privatekey('file://' . $this->key, $this->passphrase ?? ''); + } + + if (!is_resource($this->key)) { + throw new \InvalidArgumentException('Invalid key: Should be resource of private key', static::ERROR_KEY_INVALID); + } + } + + /** + * URL safe base64 encode. + * + * First serialized the payload as json if it is an array. + * + * @param array|string $data + * + * @return string + * + * @throws \InvalidArgumentException When JSON encode fails. + */ protected function urlSafeEncode($data) : string { if (is_array($data)) { @@ -177,14 +319,33 @@ protected function urlSafeEncode($data) : string return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); } - protected function urlSafeDecode(string $data) + /** + * URL safe base64 decode. + * + * @param array|string $data + * @param bool $asJson Whether to parse as JSON (defaults to true). + * + * @return array|\stdClass + * + * @throws \InvalidArgumentException When JSON encode fails. + */ + protected function urlSafeDecode(string $data, bool $asJson = true) { + if (!$asJson) { + return base64_decode(strtr($data, '-_', '+/')); + } + $data = json_decode(base64_decode(strtr($data, '-_', '+/'))); $this->throwIfJsonError(); return $data; } + /** + * Throw up if last json_encode/decode was a failure. + * + * @return void + */ protected function throwIfJsonError() { if (JSON_ERROR_NONE === $error = json_last_error()) { diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 783bd82..437f663 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -7,12 +7,15 @@ /** @coversDefaultClass \Ahc\Jwt\JWT */ class JWTTest extends \PHPUnit_Framework_TestCase { - /** @dataProvider data */ + /** @dataProvider data1 */ public function test_parse_generated_token(string $key, string $algo, int $age, int $leeway, array $payload, array $header = []) { - $jwt = new JWT($key, $algo, $age, $leeway); - $token = $jwt->generate($payload, $header); + $jwt = new JWT($key, $algo, $age, $leeway); + $token = $jwt->generate($payload, $header); + + $this->assertTrue(is_string($token)); $parsed = $jwt->parse($token); + $this->assertTrue(is_array($parsed)); // Normalize. if (!isset($payload['exp'])) { @@ -31,7 +34,7 @@ public function test_json_fail() try { $jwt->generate([random_bytes(10)]); } catch (\Exception $e) { - $this->assertSame($e->getCode(), JWT::ERROR_JSON_FAILED); + $this->assertSame($e->getCode(), JWT::ERROR_JSON_FAILED, $e->getMessage()); throw $e; } @@ -52,13 +55,48 @@ public function test_parse_fail(string $key, string $algo, int $age, int $leeway try { $jwt->parse($token); } catch (\Exception $e) { - $this->assertSame($e->getCode(), $error); + $this->assertSame($e->getCode(), $error, $e->getMessage()); throw $e; } } - public function data() : array + /** @dataProvider data1 */ + public function test_rs_parse_generated(string $key, string $algo, int $age, int $leeway, array $payload, array $header = []) + { + $key = __DIR__ . '/stubs/priv.key'; + $jwt = new JWT($key, str_replace('HS', 'RS', $algo), $age, $leeway); + $token = $jwt->encode($payload, $header); + + $this->assertTrue(is_string($token)); + $parsed = $jwt->decode($token); + $this->assertTrue(is_array($parsed)); + + // Normalize. + if (!isset($payload['exp'])) { + unset($parsed['exp']); + } + + $this->assertSame($payload, $parsed); + } + + /** @dataProvider data3 */ + public function test_rs_invalid_key(string $method, string $key, $arg) + { + $this->setExpectedException(\InvalidArgumentException::class); + + $jwt = new JWT($key, 'RS256'); + + try { + $jwt->{$method}($arg); + } catch (\Exception $e) { + $this->assertSame($e->getCode(), JWT::ERROR_KEY_INVALID, $e->getMessage()); + + throw $e; + } + } + + public function data1() : array { return [ ['secret', 'HS256', rand(10, 1000), rand(1, 10), [ @@ -136,4 +174,14 @@ public function data2() : array ]], ]; } + + public function data3() + { + return [ + ['encode', 'not a file', ['uid' => rand()]], + ['generate', __FILE__, ['uid' => rand()]], + ['decode', 'not a file', 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJuYmYiOjE0OTIwODkxODksImV4cCI6MTQ5MjA4OTE4OX0.fakesignature'], + ['parse', __FILE__, 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJuYmYiOjE0OTIwODkxODksImV4cCI6MTQ5MjA4OTE4OX0.fakesignature'], + ]; + } } diff --git a/tests/stubs/priv.key b/tests/stubs/priv.key new file mode 100644 index 0000000..e02a955 --- /dev/null +++ b/tests/stubs/priv.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA3tlzMNyGF67M6ZU1J+6WcFOpOpVsnPuGTyDbJp0rTFFzBGwg +BKv9pj4hbe3F0bgIa2w6TgegH0I9jIU7k9+LmWVLUfTQHr53FWOHGXK1u0K0jaYL +wZ6dezkEHgU3om17xV4pjFxF1bx7dEXSFzhVs5ZuHqPfguWkNOvu/JkNhAYaNBYB +8G6EVxlzvhNoBt0mupCJMLXIBV7IeGk5eRS+FWBN9IhFYIneDcdHbuYzTV/SsmV3 +ISg8RPnaRNFnCRXV7TaT/COA21b2mX0rabPbjWuAGHBIpqK2BZ8rBXV1RzVCzB3Q +rjAg2Fg1JIBssb+ZxiTNkMKikO0Zn1P+7NxrvQIDAQABAoIBABz1HKbIwRPqvu3L +WbDqq9nExUS7jfWIKZe2qUOHQM0V+GAmvLJetwMXJ7jlIMNzqoRq99iT3RaywNS1 +q1w3vCgepX7s1O5pyYeZ+0AvS7YdoOCXJwft2IDek85i1yvgFik7ZyK6CskPSOt+ +9yHVTC2d6olIug6EUKYf0lo4eS0ib7pAXRPXJ9rg8rCO6OV/b1JG6LUCEnjM6vZ3 +tWe1TGYFlVGc6mzXj8Rg7vCoDaO8KBsTwwim+x1cmFMrbR+QzPrJsW5lVmW9XXab +anE9Tm2j9v36NAMtBKDWu/HB2t7yaFhEjAWlGY3YmWWLYhaLYqN2PqC473qWOi+A +9DYPJfECgYEA+c3+f282js6pWuJKl+B3gyCQ11ljeKs/fM5DNcvY84fOAZFVxTet +99vzKVk4ZfxJPMo1EVNTNqUPi8Aepejev7hd+2TtbYvjSuO0I5MXBt1DB9P/dHKi +NW5ZzG78yVVYhWfX/L5ltirnjFOxOo2kkspM2w01UQ72uQmcGZPIom8CgYEA5GBR +P0g3Py54llkmrhvOOhiMh/+j2ehE8GrwoTbU+qvQPnacEuzMIP7xt9RL2quUAU7h +sckzyAyf8e/2rv/Px9HOAgrNjzCOY5B7rpjvVA71d5uU9m5o4VTqUjAW5Oqb8w95 +SKDBqbeYmnExFfIo8KcqQHaUKpAZlevWzqbGOpMCgYEAmaXgYZWQIypt9F63rs77 +84V2UV7D1hbOx/8+S7qESNZBGanA7bsfoBKDb+1WyTPyABgHqA5uYnAILdcPgtDH +IXlPJS+g8f5W4VtJE5CHW0uAzTHSMFfJ+b9UMHAbv+JkvjlvGiAqA3BEV4Wqvu8c +SMVxnFJ1dtQTYSDOCNVjVLUCgYBmwpQsZmE9k7p85FaMR0SvTXaGh1gB6AqFJ4lo +8RQ6Su8j/BjURyq+uhinv+X12fh58jWJ2t/q5wtdQL1+Fus2nUgWEShXguC/Gjcc +5AHkj+qRzDbl/94/bgcVvj++93X+k3reXD9oD42iCMauek1Do+RWJ0UaNcbdd8Yr +LE3L8wKBgQCA5xG1OnZG4eimN9PQjYNdQnhZMZxaIqZkvRRXaaOIBBzU6VaPxFP1 +TDue3UkvgxvGw+kE8KEHVjjC+X5+oupvNcQG5PjWGUZfXiidZNbQ6zk7S6oqIDna +mwpyfo9XGnyBdMI+wj95lOf6BA6y/Ad14pkXhXiKNlgN92TqeEHY6Q== +-----END RSA PRIVATE KEY-----