From 3899d191a437967af0cf9c845680975823845bbe Mon Sep 17 00:00:00 2001 From: Christopher Vagnetoft Date: Thu, 11 Feb 2021 13:22:51 +0100 Subject: [PATCH] Initial commit --- .gitignore | 3 + README.md | 94 +++++++++++++++ composer.json | 17 +++ phpunit.xml | 22 ++++ src/Collection/PropertyBag.php | 125 ++++++++++++++++++++ src/Collection/PropertyException.php | 9 ++ src/JwtToken.php | 146 ++++++++++++++++++++++++ src/JwtUtil.php | 16 +++ src/Key/JwtDerivedKey.php | 19 +++ src/Key/JwtPlaintextKey.php | 19 +++ src/Key/KeyInterface.php | 9 ++ src/Validator/JwtClaimException.php | 9 ++ src/Validator/JwtHeaderException.php | 8 ++ src/Validator/JwtTokenException.php | 8 ++ src/Validator/JwtValidator.php | 57 +++++++++ src/Validator/JwtValidatorException.php | 8 ++ tests/JwtTokenTest.php | 38 ++++++ tests/JwtUtilTest.php | 18 +++ tests/Key/JwtDerivedKeyTest.php | 37 ++++++ tests/Key/JwtPlaintextKeyTest.php | 16 +++ tests/Validator/JwtValidatorTest.php | 34 ++++++ 21 files changed, 712 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 composer.json create mode 100644 phpunit.xml create mode 100644 src/Collection/PropertyBag.php create mode 100644 src/Collection/PropertyException.php create mode 100644 src/JwtToken.php create mode 100644 src/JwtUtil.php create mode 100644 src/Key/JwtDerivedKey.php create mode 100644 src/Key/JwtPlaintextKey.php create mode 100644 src/Key/KeyInterface.php create mode 100644 src/Validator/JwtClaimException.php create mode 100644 src/Validator/JwtHeaderException.php create mode 100644 src/Validator/JwtTokenException.php create mode 100644 src/Validator/JwtValidator.php create mode 100644 src/Validator/JwtValidatorException.php create mode 100644 tests/JwtTokenTest.php create mode 100644 tests/JwtUtilTest.php create mode 100644 tests/Key/JwtDerivedKeyTest.php create mode 100644 tests/Key/JwtPlaintextKeyTest.php create mode 100644 tests/Validator/JwtValidatorTest.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0454d5b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/composer.lock +/vendor +/.phpunit* diff --git a/README.md b/README.md new file mode 100644 index 0000000..aaf6100 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# SimpleJwt + +This is a simple library for generating (signing) and verifying JWT tokens. It +is by no means an advanced library. If you just need to sign and refresh tokens +for users of your site or intranet, this will work great. If you need all the +glorious features of the JWT spec you should look elsewhere. + +* Only handles HMAC-SHA256. +* Only handles expiry ('exp') natively +* Doesn't use any X.509 stuff. + + +## Use Cases + +Use this to avoid having to rewrite the wheel when implementing authorization +internally within a system where OAuth may be overkill. + +* Make good use of the expiry. JWTs aren't armored in any way, so make sure + they can't be used longer than they have to. (An hour is a good idea) +* Make sure you understand the security aspects of JWTs. + +## Installation + +Install using composer: + + $ composer require noccylabs/simple-jwt:@dev + +## Usage + +You need a key for both generating and parsing tokens. Create a `JwtDerivedKey` +or a `JwtPlaintextKey` and pass it to the `JwtToken` constructor: + + use NoccyLabs\SimpleJwt\Key\{JwtDerivedKey,JwtPlaintextKey} + + // Derive a key using secret and salt... + $key = new JwtDerivedKey("secret", "salt"); + // ...or use a prepared plaintext key + $key = new JwtPlaintextKey("This Should Be Binary Data.."); + +### Generating tokens + + + + use NoccyLabs\SimpleJwt\JwtToken; + + $tok = new JwtToken($key); + $tok->setExpiry("1h"); + $tok->claims->add("some/claim/MaxItems", 8); + + $str = $tok->getSignedToken(); + + +### Parsing tokens + +Parsing is done by passing the raw token as the 2nd parameter + + use NoccyLabs\SimpleJwt\JwtToken; + + $str = "...received token..."; + + $tok = new JwtToken($key, $str); + + if (!$tok->isValid()) { + // This check works, but using the validator might be better + } + + // Using ->has() follwed by ->get() is one way + if ($tok->claims->has("some/claim/MaxItems")) { + // The claim exists, we can get the value (if any) + $val = $tok->claims->get("some/claim/MaxItems"); + } + + // You can also use valueOf() to return a default value if needed + $val = $tok->claims->valueOf("some/claim/MaxItems", 64); + +### Validating tokens + + use NoccyLabs\SimpleJwt\Validator\JwtValidator; + + $validator = new JwtValidator(); + // Require that the claim exists + $validator->addRequiredClaim("some/required/Claim"); + // Require that the claim exists and has a value of true + $validator->addRequiredClaimWithValue("some/required/OtherClaim", true); + + try { + // Pass a JwtToken to validateToken()... + $valid = $validator->validateToken($tok); + // ...or pass a JwtKeyInterface and the raw string to validate() + $valid = $validator->validate($key, $tokenstr); + } + catch (JwtValidatorException $e) { + // validation failed + } diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..a0b6a75 --- /dev/null +++ b/composer.json @@ -0,0 +1,17 @@ +{ + "name": "noccylabs/simple-jwt", + "description": "Simple library for generating and verifying JWT tokens", + "type": "library", + "license": "GPL-3.0-or-later", + "authors": [ + { + "name": "Christopher Vagnetoft", + "email": "cvagnetoft@gmail.com" + } + ], + "autoload": { + "psr-4": { + "NoccyLabs\\SimpleJwt\\": "src/" + } + } +} \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..206ad44 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,22 @@ + + + + + tests + + + + + + src + + + diff --git a/src/Collection/PropertyBag.php b/src/Collection/PropertyBag.php new file mode 100644 index 0000000..e0fe978 --- /dev/null +++ b/src/Collection/PropertyBag.php @@ -0,0 +1,125 @@ +props = $props; + } + + /** + * Add a property value, fails if the property exists + * + * @param string Property name + * @param mixed Value + * @throws PropertyException if the property already exists + */ + public function add(string $prop, $value) + { + if (array_key_exists($prop, $this->props)) { + throw new PropertyException("Property already exists"); + } + $this->props[$prop] = $value; + } + + /** + * Set a property value, create the property if it doesn't + * exist. + * + * @param string Property name + * @param mixed Value + */ + public function set(string $prop, $value) + { + $this->props[$prop] = $value; + } + + /** + * Get the value of a property, fails if the property does not exist. + * Use the value() method to get with a default value + * + * @param string Property name + * @return mixed + * @throws PropertyException if the property does not exist + */ + public function get(string $prop) + { + if (!array_key_exists($prop, $this->props)) { + throw new PropertyException("No such property"); + } + return $this->props[$prop]; + } + + /** + * Retrieve all properties. + * + * @return array + */ + public function getAll(): array + { + return $this->props; + } + + /** + * Retrieve all properties as a json string + * + * @return string + */ + public function getJson(): string + { + return json_encode($this->props, JSON_UNESCAPED_SLASHES); + } + + /** + * Get the value of the property, or use the provided default value. + * + * @param string Property name + * @param mixed Default value + * @return mixed + */ + public function value(string $prop, $default=null) + { + return array_key_exists($prop, $this->props) + ? $this->props[$prop] + : $default; + } + + /** + * Remove a property + */ + public function delete(string $prop) + { + unset($this->props[$prop]); + } + + /** + * Check if a property is present + */ + public function has(string $prop): bool + { + return array_key_exists($prop, $this->props); + } + + /** + * Check if all the provided properties are present + */ + public function hasAll(array $props) + { + foreach ($props as $prop) { + if (!$this->has($prop)) return false; + } + return true; + } + +} \ No newline at end of file diff --git a/src/Collection/PropertyException.php b/src/Collection/PropertyException.php new file mode 100644 index 0000000..f295b95 --- /dev/null +++ b/src/Collection/PropertyException.php @@ -0,0 +1,9 @@ +key = $key; + + if ($token) { + $this->parseToken($token); + } else { + $this->header = new PropertyBag([ + 'alg' => "HS256", + 'typ' => "JWT", + ]); + $this->claims = new PropertyBag(); + $this->valid = true; + $this->generated = true; + } + } + + private function parseToken(string $token) + { + $this->generated = false; + + [ $header, $payload, $signature ] = explode(".", trim($token), 3); + $hash = JwtUtil::encode(hash_hmac("sha256", $header.".".$payload, $this->key->getBinaryKey(), true)); + if ($signature == $hash) { + $this->valid = true; + } + + $this->header = new PropertyBag(json_decode(JwtUtil::decode($header), true)); + $this->claims = new PropertyBag(json_decode(JwtUtil::decode($payload), true)); + + if ($this->header->has('exp')) { + $exp = intval($this->header->get('exp')); + if ($exp <= time()) { + // Invalid if expired + $this->valid = false; + } + } + } + + public function isValid(): bool + { + return $this->valid; + } + + public function isGenerated(): bool + { + return $this->generated; + } + + public function __get(string $key) + { + switch ($key) { + case 'header': return $this->header; + case 'claims': return $this->claims; + } + } + + public function addClaim(string $name, $value) + { + $this->claims->add($name, $value); + } + + public function setClaim(string $name, $value) + { + $this->claims->set($name, $value); + } + + public function setExpiry($expiry) + { + if ($expiry instanceof \DateTime) { + $this->header->set('exp', $expiry->format("U")); + } elseif ($expiry === null) { + $this->header->delete('exp'); + } elseif (is_numeric($expiry)) { + if ($expiry < time()) { + $this->header->set('exp', time() + $expiry); + } else { + $this->header->set('exp', intval($expiry)); + } + } elseif ($t = strtotime($expiry)) { + $this->header->set('exp', $t); + } elseif (preg_match('/([0-9]+)([wdhm])/', $expiry, $match)) { + switch ($match[2]) { + case 'w': + $fact = 60 * 60 * 24 * 7; + break; + case 'd': + $fact = 60 * 60 * 24; + break; + case 'h': + $fact = 60 * 60; + break; + case 'm': + $fact = 60; + break; + } + $this->header->set('exp', time() + (intval($match[1]) * $fact)); + } else { + throw new \InvalidArgumentException(); + } + } + + public function getSignedToken(): string + { + $header = JwtUtil::encode($this->header->getJson()); + $payload = JwtUtil::encode($this->claims->getJson()); + $hash = JwtUtil::encode(hash_hmac("sha256", $header.".".$payload, $this->key->getBinaryKey(), true)); + return $header.".".$payload.".".$hash; + } + +} + diff --git a/src/JwtUtil.php b/src/JwtUtil.php new file mode 100644 index 0000000..d51faf6 --- /dev/null +++ b/src/JwtUtil.php @@ -0,0 +1,16 @@ +key = hash_pbkdf2("sha256", $password, $salt, $iter, 0, true); + } + + public function getBinaryKey(): string + { + return $this->key; + } +} + diff --git a/src/Key/JwtPlaintextKey.php b/src/Key/JwtPlaintextKey.php new file mode 100644 index 0000000..160f9d6 --- /dev/null +++ b/src/Key/JwtPlaintextKey.php @@ -0,0 +1,19 @@ +key = $key; + } + + public function getBinaryKey(): string + { + return $this->key; + } +} + diff --git a/src/Key/KeyInterface.php b/src/Key/KeyInterface.php new file mode 100644 index 0000000..ec5ba94 --- /dev/null +++ b/src/Key/KeyInterface.php @@ -0,0 +1,9 @@ +requireHeaders = [ + 'alg', + 'typ', + ]; + } + + public function addRequiredClaim(string $name) + { + $this->requireClaims[$name] = true; + } + + public function addRequiredClaimWithValue(string $name, $value) + { + $this->requireClaims[$name] = [ $value ]; + } + + public function validateToken(JwtToken $token) + { + if (!$token->isValid()) { + throw new JwtTokenException("The token is not valid"); + } + + if (!$token->header->hasAll($this->requireHeaders)) { + throw new JwtHeaderException("The token is missing one or more required headers"); + } + + if (!$token->claims->hasAll($this->requireClaims)) { + throw new JwtHeaderException("The token is missing one or more required claims"); + } + + return true; + } + + public function validate(KeyInterface $key, string $raw) + { + $token = new JwtToken($key, $raw); + if ($this->validateToken($token)) { + return $token; + } + } +} + diff --git a/src/Validator/JwtValidatorException.php b/src/Validator/JwtValidatorException.php new file mode 100644 index 0000000..73c37dc --- /dev/null +++ b/src/Validator/JwtValidatorException.php @@ -0,0 +1,8 @@ +addClaim("foo", true); + + $token = $tok->getSignedToken(); + + $this->assertNotNull($token); + $this->assertTrue($tok->isGenerated()); + } + + public function testParsingTokens() + { + $key = new JwtPlaintextKey("test"); + + $tok = new JwtToken($key); + $tok->addClaim("foo", true); + + $token = $tok->getSignedToken(); + + $parsed = new JwtToken($key, $token); + + $this->assertTrue($parsed->isValid()); + $this->assertFalse($parsed->isGenerated()); + + } +} \ No newline at end of file diff --git a/tests/JwtUtilTest.php b/tests/JwtUtilTest.php new file mode 100644 index 0000000..ce5c554 --- /dev/null +++ b/tests/JwtUtilTest.php @@ -0,0 +1,18 @@ +assertEquals($v1a, $v1c); + $this->assertNotEquals($v1a, $v1b); + } +} \ No newline at end of file diff --git a/tests/Key/JwtDerivedKeyTest.php b/tests/Key/JwtDerivedKeyTest.php new file mode 100644 index 0000000..e0c99ad --- /dev/null +++ b/tests/Key/JwtDerivedKeyTest.php @@ -0,0 +1,37 @@ +assertNotNull($key1a); + $this->assertEquals($key1a->getBinaryKey(), $key1b->getBinaryKey()); + + $key2a = new JwtDerivedKey("bar", "foosalt"); + $key2b = new JwtDerivedKey("bar", "barsalt"); + $key2c = new JwtDerivedKey("bar", "barsalt"); + $this->assertNotNull($key2a); + $this->assertNotEquals($key2a->getBinaryKey(), $key2b->getBinaryKey()); + $this->assertEquals($key2b->getBinaryKey(), $key2c->getBinaryKey()); + } + + public function testTheDerivedKeysShouldBeUnique() + { + $keys = []; + $keys[] = (new JwtDerivedKey("foo", "foosalt"))->getBinaryKey(); + $keys[] = (new JwtDerivedKey("foo", "barsalt"))->getBinaryKey(); + $keys[] = (new JwtDerivedKey("foo", "bazsalt"))->getBinaryKey(); + $keys[] = (new JwtDerivedKey("bar", "foosalt"))->getBinaryKey(); + $keys[] = (new JwtDerivedKey("bar", "barsalt"))->getBinaryKey(); + $keys[] = (new JwtDerivedKey("bar", "bazsalt"))->getBinaryKey(); + + $unique = array_unique($keys); + $this->assertEquals(count($keys), count($unique)); + } + +} \ No newline at end of file diff --git a/tests/Key/JwtPlaintextKeyTest.php b/tests/Key/JwtPlaintextKeyTest.php new file mode 100644 index 0000000..0157c48 --- /dev/null +++ b/tests/Key/JwtPlaintextKeyTest.php @@ -0,0 +1,16 @@ +assertEquals("foo", $key->getBinaryKey()); + + $key = new JwtPlaintextKey("bar"); + $this->assertEquals("bar", $key->getBinaryKey()); + } +} \ No newline at end of file diff --git a/tests/Validator/JwtValidatorTest.php b/tests/Validator/JwtValidatorTest.php new file mode 100644 index 0000000..1dffb8a --- /dev/null +++ b/tests/Validator/JwtValidatorTest.php @@ -0,0 +1,34 @@ +validateToken($token); + $this->assertEquals(true, $valid); + } + + public function testExpiredKeysShouldFailWithException() + { + $key = new JwtPlaintextKey("key"); + $token = new JwtToken($key); + $token->header->set("exp", 0); + + $token = new JwtToken($key, $token->getSignedToken()); + + $validator = new JwtValidator(); + $this->expectException(JwtTokenException::class); + $valid = $validator->validateToken($token); + } + +} \ No newline at end of file