Initial commit

This commit is contained in:
Chris 2021-02-11 13:22:51 +01:00
commit 3899d191a4
21 changed files with 712 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/composer.lock
/vendor
/.phpunit*

94
README.md Normal file
View File

@ -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
}

17
composer.json Normal file
View File

@ -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/"
}
}
}

22
phpunit.xml Normal file
View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/8.5/phpunit.xsd"
bootstrap="vendor/autoload.php"
executionOrder="depends,defects"
forceCoversAnnotation="true"
beStrictAboutCoversAnnotation="true"
beStrictAboutOutputDuringTests="true"
beStrictAboutTodoAnnotatedTests="true"
verbose="true">
<testsuites>
<testsuite name="default">
<directory suffix="Test.php">tests</directory>
</testsuite>
</testsuites>
<filter>
<whitelist processUncoveredFilesFromWhitelist="true">
<directory suffix=".php">src</directory>
</whitelist>
</filter>
</phpunit>

View File

@ -0,0 +1,125 @@
<?php
namespace NoccyLabs\SimpleJwt\Collection;
use ArrayAccess;
use Countable;
class PropertyBag
{
/**
* @var array
*/
private $props = [];
public function __construct(array $props=[])
{
$this->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;
}
}

View File

@ -0,0 +1,9 @@
<?php
namespace NoccyLabs\SimpleJwt\Collection;
class PropertyException extends \RuntimeException
{
}

146
src/JwtToken.php Normal file
View File

@ -0,0 +1,146 @@
<?php
namespace NoccyLabs\SimpleJwt;
use NoccyLabs\SimpleJwt\Collection\PropertyBag;
use NoccyLabs\SimpleJwt\Key\KeyInterface;
/**
*
*
*
*
* @property-read header PropertyBag
* @property-read claim PropertyBag
*/
class JwtToken
{
/** @var PropertyBag */
private $header;
/** @var PropertyBag */
private $claims;
/** @var KeyInterface */
private $key;
/** @var bool */
private $valid;
/** @var bool */
private $generated;
/**
* Constructor
*
*
* @param KeyInterface The key used to sign the token
*/
public function __construct(KeyInterface $key, ?string $token=null)
{
$this->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;
}
}

16
src/JwtUtil.php Normal file
View File

@ -0,0 +1,16 @@
<?php
namespace NoccyLabs\SimpleJwt;
class JwtUtil
{
public static function encode($data) {
return rtrim(str_replace(['+', '/'], ['-', '_'], base64_encode($data)), "=");
}
public static function decode($data) {
return base64_decode(str_replace(['-', '_'], ['+', '/'], $data));
}
}

19
src/Key/JwtDerivedKey.php Normal file
View File

@ -0,0 +1,19 @@
<?php
namespace NoccyLabs\SimpleJwt\Key;
class JwtDerivedKey implements KeyInterface
{
private $key;
public function __construct(string $password, string $salt, int $iter=100)
{
$this->key = hash_pbkdf2("sha256", $password, $salt, $iter, 0, true);
}
public function getBinaryKey(): string
{
return $this->key;
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace NoccyLabs\SimpleJwt\Key;
class JwtPlaintextKey implements KeyInterface
{
private $key;
public function __construct(string $key)
{
$this->key = $key;
}
public function getBinaryKey(): string
{
return $this->key;
}
}

9
src/Key/KeyInterface.php Normal file
View File

@ -0,0 +1,9 @@
<?php
namespace NoccyLabs\SimpleJwt\Key;
interface KeyInterface
{
public function getBinaryKey(): string;
}

View File

@ -0,0 +1,9 @@
<?php
namespace NoccyLabs\SimpleJwt\Validator;
class JwtClaimException extends JwtValidatorException
{
}

View File

@ -0,0 +1,8 @@
<?php
namespace NoccyLabs\SimpleJwt\Validator;
class JwtHeaderException extends JwtValidatorException
{
}

View File

@ -0,0 +1,8 @@
<?php
namespace NoccyLabs\SimpleJwt\Validator;
class JwtTokenException extends JwtValidatorException
{
}

View File

@ -0,0 +1,57 @@
<?php
namespace NoccyLabs\SimpleJwt\Validator;
use NoccyLabs\SimpleJwt\JwtToken;
use NoccyLabs\SimpleJwt\Key\KeyInterface;
class JwtValidator
{
private $requireHeaders = [];
private $requireClaims = [];
public function __construct()
{
$this->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;
}
}
}

View File

@ -0,0 +1,8 @@
<?php
namespace NoccyLabs\SimpleJwt\Validator;
class JwtValidatorException extends \RuntimeException
{
}

38
tests/JwtTokenTest.php Normal file
View File

@ -0,0 +1,38 @@
<?php
namespace NoccyLabs\SimpleJwt;
use NoccyLabs\SimpleJwt\Key\JwtPlaintextKey;
class JwtTokenTest extends \PhpUnit\Framework\TestCase
{
public function testGeneratingTokens()
{
$key = new JwtPlaintextKey("test");
$tok = new JwtToken($key);
$tok->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());
}
}

18
tests/JwtUtilTest.php Normal file
View File

@ -0,0 +1,18 @@
<?php
namespace NoccyLabs\SimpleJwt;
class JwtUtilTest extends \PhpUnit\Framework\TestCase
{
public function testTheEncodingShouldBeSymmetric()
{
$v1a = "HelloWorld";
$v1b = JwtUtil::encode($v1a);
$v1c = JwtUtil::decode($v1b);
$this->assertEquals($v1a, $v1c);
$this->assertNotEquals($v1a, $v1b);
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace NoccyLabs\SimpleJwt\Key;
class JwtDerivedKeyTest extends \PhpUnit\Framework\TestCase
{
public function testTheDerivedKeysShouldBeConsistent()
{
$key1a = new JwtDerivedKey("foo", "foosalt");
$key1b = new JwtDerivedKey("foo", "foosalt");
$this->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));
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace NoccyLabs\SimpleJwt\Key;
class JwtPlaintextKeyTest extends \PhpUnit\Framework\TestCase
{
public function testThePlaintextKeyShouldBeReturned()
{
$key = new JwtPlaintextKey("foo");
$this->assertEquals("foo", $key->getBinaryKey());
$key = new JwtPlaintextKey("bar");
$this->assertEquals("bar", $key->getBinaryKey());
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace NoccyLabs\SimpleJwt\Validator;
use NoccyLabs\SimpleJwt\JwtToken;
use NoccyLabs\SimpleJwt\Key\JwtPlaintextKey;
class JwtValidatorTest extends \PhpUnit\Framework\TestCase
{
public function testValidKeysShouldPassWithDefaultConfiguration()
{
$key = new JwtPlaintextKey("key");
$token = new JwtToken($key);
$validator = new JwtValidator();
$valid = $validator->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);
}
}