Skip to content

Commit

Permalink
Fix .env issues encountered earlier. This is only for tests
Browse files Browse the repository at this point in the history
Support for RSA keys

Add a reset option for the token, to lock out people
  • Loading branch information
Simon Erkelens committed Aug 13, 2017
1 parent 15bf576 commit 0eb96da
Show file tree
Hide file tree
Showing 15 changed files with 277 additions and 46 deletions.
2 changes: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
- SS_DATABASE_NAME=circle_test
- SS_ENVIRONMENT_TYPE=dev
- JWT_PREFIX=jwt_
- JWT_SIGNER_KEY=SilverStripe_jwt
- JWT_SIGNER_KEY=test_signer
- image: circleci/mysql:5.7
environment:
- MYSQL_USER=root
Expand Down
2 changes: 1 addition & 1 deletion _config/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,6 @@ SilverStripe\Control\Director:
name: graphqljwt
---
Firesphere\GraphQLJWT\JWTAuthenticator:
nbf_time: 60
nbf_time: 0
nbf_expiration: 3600
anonymous_allowed: false
20 changes: 18 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,26 @@ This module provides a JWT-interface for creating JSON Web Tokens for authentica

Currently, only dev-master is available, as it's in heavy development.

The default config is available in `_config\config.yml`. To set your signer key and UID prefix, add the following to your `.ENV`:
The default config is available in `_config\config.yml`.

To set your signer key and UID prefix, add the following to your `.ENV`:
```
JWT_PREFIX=mysupersecretprefix
JWT_SIGNER_KEY=mysupersecretkey
```

Or, if you want to use a public and private key, set the following in your `.ENV`:
```
JWT_SIGNER_KEY="path/to/your/private.key"
JWT_PUBLIC_KEY="path/to/your/public.key"
```

Currently, only RSA keys are accepted, ECDSA is not supported.

Please note, the keys in the test-folder are generated by an online RSA key generator.

# _*THE KEYS IN THE `TESTS/KEYS` FOLDER CAN NOT BE TRUSTED!*_

## Log in

To generate a JWT token, send a login request to the `createToken` mutator. E.g.:
Expand Down Expand Up @@ -63,7 +77,7 @@ If the token is valid, you'll get a response like this:
}
```

And obviously the response is false when the token is invalid.
And obviously the `response['data']['validateToken']['Valid']` is false when the token is invalid.

## Anonymous tokens

Expand Down Expand Up @@ -102,6 +116,8 @@ Currently, the default method for encrypting the JWT is with Sha256.
JWT is signed with multiple factors, including the host, audience (app/remote user), a secret key and a timeframe within which the token is valid.
Only one device can be logged in at the time.
## Supported services
By default, JWT only supports login. As it's tokens can not be disabled, nor used for password changes or resets.
Expand Down
18 changes: 14 additions & 4 deletions src/Authentication/JWTAuthenticationHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Firesphere\GraphQLJWT;

use SilverStripe\Control\HTTPRequest;
use SilverStripe\ORM\ValidationException;
use SilverStripe\Security\AuthenticationHandler;
use SilverStripe\Security\Member;
use SilverStripe\Security\Security;
Expand Down Expand Up @@ -50,6 +51,7 @@ public function authenticateRequest(HTTPRequest $request)
$member = Security::getCurrentUser();

if (!empty($matches[1])) {
// Validate the token. This is critical for security
$member = $this->authenticator->authenticate(['token' => $matches[1]], $request);
}

Expand All @@ -75,13 +77,21 @@ public function logIn(Member $member, $persistent = false, HTTPRequest $request

/**
* @param HTTPRequest|null $request
* @throws ValidationException
*/
public function logOut(HTTPRequest $request = null)
{
// A token can actually not be invalidated, only blacklisted
if ($request !== null) {
$request->getSession()->clear('jwt');
// A token can actually not be invalidated, but let's invalidate it's unique ID
// A member actually can be null though!
if ($request !== null) { // If we don't have a request, we're most probably in test mode
$member = Security::getCurrentUser();
if ($member) {
// Set the unique ID to 0, as it can't be nullified due to indexes.
$member->JWTUniqueID = 0;
$member->write();
}
}
Security::setCurrentUser(null);
// Empty the current user and pray to god it's not valid anywhere else anymore :)
Security::setCurrentUser();
}
}
126 changes: 104 additions & 22 deletions src/Authentication/JWTAuthenticator.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@
namespace Firesphere\GraphQLJWT;

use BadMethodCallException;
use JWTException;
use Lcobucci\JWT\Builder;
use Lcobucci\JWT\Parser;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Rsa\Sha256 as RsaSha256;
use Lcobucci\JWT\Signer\Key;
use Lcobucci\JWT\Token;
use Lcobucci\JWT\ValidationData;
use OutOfBoundsException;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Core\Config\Configurable;
Expand All @@ -21,6 +26,57 @@ class JWTAuthenticator extends MemberAuthenticator
{
use Configurable;

/**
* @var Sha256|RsaSha256
*/
private $signer;

/**
* @var string|Key;
*/
private $privateKey;

/**
* @var string|Key;
*/
private $publicKey;

/**
* JWTAuthenticator constructor.
* @throws JWTException
*/
public function __construct()
{
$key = getenv('JWT_SIGNER_KEY');
if (empty($key)) {
throw new JWTException('No key defined!', 1);
}
$publicKeyLocation = getenv('JWT_PUBLIC_KEY');
if (file_exists($key) && !file_exists($publicKeyLocation)) {
throw new JWTException('No public key found!', 1);
}
}

/**
* Setup the keys this has to be done on the spot for if the signer changes between validation cycles
*/
private function setKeys()
{
$signerKey = getenv('JWT_SIGNER_KEY');
// If it's a private key, we also need a public key for validation!
if (file_exists($signerKey)) {
$this->signer = new RsaSha256();
$password = getenv('JWT_KEY_PASSWORD');
$this->privateKey = new Key('file://' . $signerKey, $password ?: null);
// We're having an RSA signed key instead of a string
$this->publicKey = new Key('file://' . getenv('JWT_PUBLIC_KEY'));
} else {
$this->signer = new Sha256();
$this->privateKey = $signerKey;
$this->publicKey = $signerKey;
}
}

/**
* JWT is stateless, therefore, we don't support anything but login
*
Expand All @@ -36,8 +92,8 @@ public function supportedServices()
* @param HTTPRequest $request
* @param ValidationResult|null $result
* @return Member|null
* @throws \OutOfBoundsException
* @throws \BadMethodCallException
* @throws OutOfBoundsException
* @throws BadMethodCallException
*/
public function authenticate(array $data, HTTPRequest $request, ValidationResult &$result = null)
{
Expand All @@ -46,7 +102,7 @@ public function authenticate(array $data, HTTPRequest $request, ValidationResult
}
$token = $data['token'];

return $this->validateToken($token, $result);
return $this->validateToken($token, $request, $result);
}

/**
Expand All @@ -57,13 +113,12 @@ public function authenticate(array $data, HTTPRequest $request, ValidationResult
*/
public function generateToken(Member $member)
{
$this->setKeys();
$config = static::config();
$signer = new Sha256();
$uniqueID = uniqid(getenv('JWT_PREFIX'), true);

$request = Controller::curr()->getRequest();
$audience = $request->getHeader('Origin');
$signerKey = getenv('JWT_SIGNER_KEY');

$builder = new Builder();
$token = $builder
Expand All @@ -81,8 +136,8 @@ public function generateToken(Member $member)
->setExpiration(time() + $config->get('nbf_expiration'))
// Configures a new claim, called "uid"
->set('uid', $member->ID)
// Sign the key with the Signer's key @todo: support certificates
->sign($signer, $signerKey);
// Sign the key with the Signer's key
->sign($this->signer, $this->privateKey);

// Save the member if it's not anonymous
if ($member->ID > 0) {
Expand All @@ -96,34 +151,34 @@ public function generateToken(Member $member)

/**
* @param string $token
* @param HTTPRequest $request
* @param ValidationResult $result
* @return null|Member
* @throws \OutOfBoundsException
* @throws \BadMethodCallException
* @throws BadMethodCallException
*/
private function validateToken($token, &$result)
private function validateToken($token, $request, &$result)
{
$this->setKeys();
$parser = new Parser();
$parsedToken = $parser->parse((string)$token);
$signer = new Sha256();
$signerKey = getenv('JWT_SIGNER_KEY');
$member = null;

// Get a validator and the Member for this token
list($validator, $member) = $this->getValidator($request, $parsedToken);

$verified = $parsedToken->verify($this->signer, $this->publicKey);
$valid = $parsedToken->validate($validator);

// If the token is not verified, just give up
if (!$parsedToken->verify($signer, $signerKey)) {
if (!$verified || !$valid) {
$result->addError('Invalid token');
}
// An expired token can be renewed
elseif ($parsedToken->isExpired()) {
if (
$verified &&
$parsedToken->isExpired()
) {
$result->addError('Token is expired, please renew your token with a refreshToken query');
}
// Everything seems fine, let's find a user
elseif ($parsedToken->getClaim('uid') > 0 && $parsedToken->getClaim('jti')) {
/** @var Member $member */
$member = Member::get()
->filter(['JWTUniqueID' => $parsedToken->getClaim('jti')])
->byID($parsedToken->getClaim('uid'));
}
// Not entirely fine, do we allow anonymous users?
// Then, if the token is valid, return an anonymous user
if (
Expand All @@ -135,6 +190,33 @@ private function validateToken($token, &$result)
}

return $result->isValid() ? $member : null;
}

/**
* @param HTTPRequest $request
* @param Token $parsedToken
* @return array Contains a ValidationData and Member object
* @throws OutOfBoundsException
*/
private function getValidator($request, $parsedToken)
{
$audience = $request->getHeader('Origin');

$member = null;
$id = null;
$validator = new ValidationData();
$validator->setIssuer(Director::absoluteBaseURL());
$validator->setAudience($audience);

if ($parsedToken->getClaim('uid') === 0 && static::config()->get('anonymous_allowed')) {
$id = $request->getSession()->get('jwt_uid');
} elseif ($parsedToken->getClaim('uid') > 0) {
$member = Member::get()->byID($parsedToken->getClaim('uid'));
$id = $member->JWTUniqueID;
}

$validator->setId($id);

return [$validator, $member];
}
}
9 changes: 9 additions & 0 deletions src/Exceptions/JWTException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

class JWTException extends Exception
{
public function __construct($message = 'JWT Exception', $code = 1, Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}
21 changes: 18 additions & 3 deletions src/Extensions/MemberExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace Firesphere\GraphQLJWT;


use SilverStripe\Forms\CheckboxField;
use SilverStripe\Forms\FieldList;
use SilverStripe\ORM\DataExtension;

Expand All @@ -12,7 +12,6 @@
*/
class MemberExtension extends DataExtension
{

private static $db = [
'JWTUniqueID' => 'Varchar(255)',
];
Expand All @@ -25,5 +24,21 @@ public function updateCMSFields(FieldList $fields)
{
parent::updateCMSFields($fields);
$fields->removeByName(['JWTUniqueID']);
if ($this->owner->JWTUniqueID) {
$fields->addFieldsToTab(
'Root.Main',
[
CheckboxField::create('reset', 'Reset the Token ID to disable this user\'s remote login')
]
);
}
}

public function onBeforeWrite()
{
parent::onBeforeWrite();
if ($this->owner->reset) {
$this->owner->JWTUniqueID = null;
}
}
}
}
6 changes: 3 additions & 3 deletions src/Queries/ValidateTokenQueryCreator.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,16 @@ public function type()
*/
public function resolve($object, array $args, $context, ResolveInfo $info)
{
/** @var JWTAuthenticator $validator */
$validator = Injector::inst()->get(JWTAuthenticator::class);
/** @var JWTAuthenticator $authenticator */
$authenticator = Injector::inst()->get(JWTAuthenticator::class);
$msg = [];
$request = Controller::curr()->getRequest();
$matches = HeaderExtractor::getAuthorizationHeader($request);
$result = new ValidationResult();
$code = 401;

if (!empty($matches[1])) {
$validator->authenticate(['token' => $matches[1]], $request, $result);
$authenticator->authenticate(['token' => $matches[1]], $request, $result);
if ($result->isValid()) {
$code = 200;
}
Expand Down
15 changes: 15 additions & 0 deletions tests/keys/private.key
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-----BEGIN RSA PRIVATE KEY-----
MIICWgIBAAKBgHMT6iL1pA9RGVK/GDy56zY1T+oWDHzKOjfl4UEfCHRwOqwWwcDD
JwuIVP9Mr+SbDifjR6W5QRqZY3s5Re0nSCCTHpnvZecdK1Iof/s9yrSSfUhN/lYc
oCyqXCxbuBFIgLoX1j03SSEL10A6xUnLie9fIVGSirzI24B286j8ria7AgMBAAEC
gYBpsUC6GyIzbyjy9tAr9hYyE4TyWo3dj18pN9lLFlWNnAZHSB9sC9EwpmZqlOR3
8nFt8TE85IkHBRp5coDm780t307JO9L0cRkY6Pc2D533ojC/gQjpOMA3jXbJzQoH
v/Kp20VediEvMvwDaKrSSXy2sTgLZIM/1OW55ruM1vXCAQJBAMaM2whLqqJRXPYb
pO0bksvFeB90R2Qfqu2T5NDophv741VQetoGqUieDXErl5L4LINPMsa2FnrYt7Y6
a0vNoWMCQQCUYAifZSgvH5jp61mwRpP2LjYLHjlyx53Z+31I2sMvzxtLPG3d36Nd
PQzL2oAM9EuT5gFHErEXAPO07Y4untDJAkAm+cWRdlETtgcapMiWZwBFEgmHmyrc
g77pDkwvmkvpWGQC/l5vaDlY8PXQjm8dwavzRtu/2ETHbr15fzRK2B3jAkBnZXzc
xvt3y0ceS7nWk3hsp8tVeByElgK0cwLdkEVQvbzBcz0EzuHjCbYvIPb3EA7S5Aej
5ayu4STzwk/AwT+xAkBFDMBKlEAETwEPBQ3aVkNTFu7UHZpmWSdGuBwrwaa/x1SD
4Aes4v0/Smfdv8trK2krhmU861mCV6SPED3hohoe
-----END RSA PRIVATE KEY-----
Loading

0 comments on commit 0eb96da

Please sign in to comment.