From 621d312473ffcf568beaac41f1fb962dbd7f1194 Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Fri, 12 Apr 2019 17:38:55 +1200 Subject: [PATCH 01/11] Commit refactored work --- .editorconfig | 26 ++ _config/graphql.yml | 5 +- composer.json | 72 ++--- .../JWTAuthenticationHandler.php | 60 ++-- src/Authentication/JWTAuthenticator.php | 295 +++++++++++------- src/Extensions/MemberExtension.php | 44 ++- src/Helpers/GeneratesTokenOutput.php | 82 +++++ src/Helpers/HeaderExtractor.php | 19 +- src/Helpers/PathResolver.php | 22 ++ src/Helpers/RequiresAuthenticator.php | 36 +++ src/Helpers/RequiresConfig.php | 29 ++ src/Model/JWTRecord.php | 25 ++ .../CreateAnonymousTokenMutationCreator.php | 71 +++++ src/Mutations/CreateTokenMutationCreator.php | 85 +++-- src/Mutations/RefreshTokenMutationCreator.php | 97 +++--- src/Queries/ValidateTokenQueryCreator.php | 60 ++-- src/Types/MemberTokenTypeCreator.php | 17 +- src/Types/MemberTypeCreator.php | 28 ++ src/Types/TokenStatusEnum.php | 80 +++++ src/Types/ValidateTokenTypeCreator.php | 25 -- 20 files changed, 828 insertions(+), 350 deletions(-) create mode 100644 .editorconfig create mode 100644 src/Helpers/GeneratesTokenOutput.php create mode 100644 src/Helpers/PathResolver.php create mode 100644 src/Helpers/RequiresAuthenticator.php create mode 100644 src/Helpers/RequiresConfig.php create mode 100644 src/Model/JWTRecord.php create mode 100644 src/Mutations/CreateAnonymousTokenMutationCreator.php create mode 100644 src/Types/MemberTypeCreator.php create mode 100644 src/Types/TokenStatusEnum.php delete mode 100644 src/Types/ValidateTokenTypeCreator.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e63d8fb --- /dev/null +++ b/.editorconfig @@ -0,0 +1,26 @@ +# For more information about the properties used in +# this file, please see the EditorConfig documentation: +# http://editorconfig.org/ + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.yml] +indent_size = 2 +indent_style = space + +[*.{yml,json}] +indent_size = 2 + +[composer.json] +indent_size = 4 diff --git a/_config/graphql.yml b/_config/graphql.yml index 0e030bc..7864f78 100644 --- a/_config/graphql.yml +++ b/_config/graphql.yml @@ -2,9 +2,10 @@ SilverStripe\GraphQL\Controller: schema: types: MemberToken: 'Firesphere\GraphQLJWT\Types\MemberTokenTypeCreator' - ValidateToken: 'Firesphere\GraphQLJWT\Types\ValidateTokenTypeCreator' + Member: 'Firesphere\GraphQLJWT\Types\MemberTypeCreator' mutations: + createAnonymousToken: 'Firesphere\GraphQLJWT\Mutations\CreateAnonymousTokenMutationCreator' createToken: 'Firesphere\GraphQLJWT\Mutations\CreateTokenMutationCreator' refreshToken: 'Firesphere\GraphQLJWT\Mutations\RefreshTokenMutationCreator' queries: - validateToken: 'Firesphere\GraphQLJWT\Queries\ValidateTokenQueryCreator' \ No newline at end of file + validateToken: 'Firesphere\GraphQLJWT\Queries\ValidateTokenQueryCreator' diff --git a/composer.json b/composer.json index affb71e..c603d7f 100644 --- a/composer.json +++ b/composer.json @@ -1,39 +1,39 @@ { - "name": "firesphere/graphql-jwt", - "description": "JWT Authentication for GraphQL", - "type": "silverstripe-vendormodule", - "license": "bsd-3-clause", - "require": { - "php": ">=5.6", - "silverstripe/recipe-core": "^4.0", - "silverstripe/versioned": "^1.0", - "silverstripe/graphql": "^3.0", - "lcobucci/jwt": "^3.2" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^2.4", - "phpunit/PHPUnit": "^5.7", - "scriptfusion/phpunit-immediate-exception-printer": "^1" - }, - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" + "name": "firesphere/graphql-jwt", + "description": "JWT Authentication for GraphQL", + "type": "silverstripe-vendormodule", + "license": "bsd-3-clause", + "require": { + "php": ">=5.6", + "silverstripe/framework": "^4.3", + "silverstripe/graphql": "^3.0", + "lcobucci/jwt": "^3.2", + "ext-json": "*" }, - "installer-name": "graphql-jwt" - }, - "config": { - "process-timeout": 600 - }, - "autoload": { - "psr-4": { - "Firesphere\\GraphQLJWT\\Authentication\\": "src/Authentication", - "Firesphere\\GraphQLJWT\\Extensions\\": "src/Extensions", - "Firesphere\\GraphQLJWT\\Helpers\\": "src/Helpers", - "Firesphere\\GraphQLJWT\\Mutations\\": "src/Mutations", - "Firesphere\\GraphQLJWT\\Queries\\": "src/Queries", - "Firesphere\\GraphQLJWT\\Types\\": "src/Types" - } - }, - "prefer-stable": true, - "minimum-stability": "dev" + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.4", + "phpunit/phpunit": "^5.7", + "scriptfusion/phpunit-immediate-exception-printer": "^1" + }, + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + }, + "installer-name": "graphql-jwt" + }, + "config": { + "process-timeout": 600 + }, + "autoload": { + "psr-4": { + "Firesphere\\GraphQLJWT\\Authentication\\": "src/Authentication", + "Firesphere\\GraphQLJWT\\Extensions\\": "src/Extensions", + "Firesphere\\GraphQLJWT\\Helpers\\": "src/Helpers", + "Firesphere\\GraphQLJWT\\Mutations\\": "src/Mutations", + "Firesphere\\GraphQLJWT\\Queries\\": "src/Queries", + "Firesphere\\GraphQLJWT\\Types\\": "src/Types" + } + }, + "prefer-stable": true, + "minimum-stability": "dev" } diff --git a/src/Authentication/JWTAuthenticationHandler.php b/src/Authentication/JWTAuthenticationHandler.php index 3277060..819a6d1 100644 --- a/src/Authentication/JWTAuthenticationHandler.php +++ b/src/Authentication/JWTAuthenticationHandler.php @@ -2,9 +2,12 @@ namespace Firesphere\GraphQLJWT\Authentication; +use BadMethodCallException; +use Exception; +use Firesphere\GraphQLJWT\Extensions\MemberExtension; use Firesphere\GraphQLJWT\Helpers\HeaderExtractor; +use OutOfBoundsException; use SilverStripe\Control\HTTPRequest; -use SilverStripe\ORM\ValidationException; use SilverStripe\Security\AuthenticationHandler; use SilverStripe\Security\Member; use SilverStripe\Security\Security; @@ -12,11 +15,11 @@ /** * Class JWTAuthenticationHandler * - * * @package Firesphere\GraphQLJWT */ class JWTAuthenticationHandler implements AuthenticationHandler { + use HeaderExtractor; /** * @var JWTAuthenticator @@ -24,7 +27,7 @@ class JWTAuthenticationHandler implements AuthenticationHandler protected $authenticator; /** - * @return mixed + * @return JWTAuthenticator */ public function getAuthenticator() { @@ -32,30 +35,35 @@ public function getAuthenticator() } /** - * @param mixed $authenticator + * @param JWTAuthenticator $authenticator + * @return $this */ - public function setAuthenticator($authenticator) + public function setAuthenticator(JWTAuthenticator $authenticator) { $this->authenticator = $authenticator; + return $this; } /** * @param HTTPRequest $request * @return null|Member - * @throws \OutOfBoundsException - * @throws \BadMethodCallException + * @throws OutOfBoundsException + * @throws BadMethodCallException + * @throws Exception */ public function authenticateRequest(HTTPRequest $request) { - $matches = HeaderExtractor::getAuthorizationHeader($request); - // Get the default user currently logged in via a different way, could be BasicAuth/normal login - $member = Security::getCurrentUser(); - - if (!empty($matches[1])) { - // Validate the token. This is critical for security - $member = $this->authenticator->authenticate(['token' => $matches[1]], $request); + // Check token + $token = $this->getAuthorizationHeader($request); + if (!$token) { + return null; } + // Validate the token. This is critical for security + $member = $this + ->getAuthenticator() + ->authenticate(['token' => $token], $request); + if ($member) { $this->logIn($member); } @@ -67,8 +75,8 @@ public function authenticateRequest(HTTPRequest $request) * Authenticate on every run, based on the header, not relying on sessions or cookies * JSON Web Tokens are stateless * - * @param Member $member - * @param bool $persistent + * @param Member $member + * @param bool $persistent * @param HTTPRequest|null $request */ public function logIn(Member $member, $persistent = false, HTTPRequest $request = null) @@ -78,21 +86,17 @@ 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, 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(); - } + // A token can actually not be invalidated, but let's flush all valid tokens from the DB. + // Note that log-out acts as a global logout (all devices) + /** @var Member|MemberExtension $member */ + $member = Security::getCurrentUser(); + if ($member) { + $member->AuthTokens()->removeAll(); } - // Empty the current user and pray to god it's not valid anywhere else anymore :) - Security::setCurrentUser(); + + Security::setCurrentUser(null); } } diff --git a/src/Authentication/JWTAuthenticator.php b/src/Authentication/JWTAuthenticator.php index 3a0cdac..88bb79b 100644 --- a/src/Authentication/JWTAuthenticator.php +++ b/src/Authentication/JWTAuthenticator.php @@ -2,21 +2,30 @@ namespace Firesphere\GraphQLJWT\Authentication; +use App\Users\GraphQL\Types\TokenStatusEnum; use BadMethodCallException; -use JWTException; +use Exception; +use Firesphere\GraphQLJWT\Extensions\MemberExtension; +use Firesphere\GraphQLJWT\Helpers\GeneratesTokenOutput; +use Firesphere\GraphQLJWT\Helpers\PathResolver; +use Firesphere\GraphQLJWT\Helpers\RequiresConfig; +use Firesphere\GraphQLJWT\Model\JWTRecord; +use InvalidArgumentException; 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; +use Lcobucci\JWT\Signer\Hmac; use Lcobucci\JWT\Signer\Key; +use Lcobucci\JWT\Signer\Rsa; use Lcobucci\JWT\Token; use Lcobucci\JWT\ValidationData; +use LogicException; use OutOfBoundsException; use SilverStripe\Control\Director; use SilverStripe\Control\HTTPRequest; use SilverStripe\Core\Config\Configurable; use SilverStripe\Core\Environment; -use SilverStripe\GraphQL\Controller; +use SilverStripe\ORM\FieldType\DBDatetime; use SilverStripe\ORM\ValidationException; use SilverStripe\ORM\ValidationResult; use SilverStripe\Security\Authenticator; @@ -26,56 +35,103 @@ class JWTAuthenticator extends MemberAuthenticator { use Configurable; + use RequiresConfig; + use GeneratesTokenOutput; /** - * @var Sha256|RsaSha256 + * Set to true if HTTP error responses are preferred instead of 200 for verification. + * If set to false, verification will return status error. + * + * Some front-end libraries require non-200 error codes to trigger error handling. + * + * Note: This only handles 400 errors, not 500 server errors such as mis-configuration, or bad code, + * which will still throw exceptions as normal. + * + * @var bool + */ + private static $prefer_http_errors = true; + + /** + * Set to true to allow anonymous JWT tokens (no member record / email / password) + * + * @config + * @var bool */ - private $signer; + private static $anonymous_allowed = false; /** - * @var string|Key; + * @config + * @var int */ - private $privateKey; + private static $nbf_time = 0; /** - * @var string|Key; + * Expires after 1 hour + * + * @config + * @var int */ - private $publicKey; + private static $nbf_expiration = 3600; /** - * JWTAuthenticator constructor. - * @throws JWTException + * Token can be refreshed within 7 days + * + * @config + * @var int */ - public function __construct() + private static $nbf_refresh_expiration = 604800; + + /** + * @return Signer + */ + protected function getSigner() { - $key = Environment::getEnv('JWT_SIGNER_KEY'); - if (empty($key)) { - throw new JWTException('No key defined!', 1); + $signerKey = $this->getEnv('JWT_SIGNER_KEY'); + if (PathResolver::resolve($signerKey)) { + return new Rsa\Sha256(); + } else { + return new Hmac\Sha256(); } - $publicKeyLocation = Environment::getEnv('JWT_PUBLIC_KEY'); - if (file_exists($key) && !file_exists($publicKeyLocation)) { - throw new JWTException('No public key found!', 1); + } + + /** + * Get private key + * + * @return Key + */ + protected function getPrivateKey() + { + $signerKey = $this->getEnv('JWT_SIGNER_KEY'); + $signerPath = PathResolver::resolve($signerKey); + if ($signerPath) { + $password = $this->getEnv('JWT_KEY_PASSWORD', null); + return new Key('file://' . $signerPath, $password); } + return new Key($signerKey); } /** - * Setup the keys this has to be done on the spot for if the signer changes between validation cycles + * Get public key + * + * @return Key + * @throws LogicException */ - private function setKeys() + private function getPublicKey() { $signerKey = Environment::getEnv('JWT_SIGNER_KEY'); + $signerPath = PathResolver::resolve($signerKey); // If it's a private key, we also need a public key for validation! - if (file_exists($signerKey)) { - $this->signer = new RsaSha256(); - $password = Environment::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://' . Environment::getEnv('JWT_PUBLIC_KEY')); - } else { - $this->signer = new Sha256(); - $this->privateKey = $signerKey; - $this->publicKey = $signerKey; + if (empty($signerPath)) { + return new Key($signerKey); } + + // Ensure public key exists + $publicKey = Environment::getEnv('JWT_PUBLIC_KEY'); + $publicPath = PathResolver::resolve($publicKey); + if (empty($publicPath)) { + throw new LogicException('JWT_PUBLIC_KEY path does not exist'); + } + return new Key('file://' . $publicPath); } /** @@ -85,16 +141,17 @@ private function setKeys() */ public function supportedServices() { - return Authenticator::LOGIN | Authenticator::CMS_LOGIN; + return Authenticator::LOGIN; } /** - * @param array $data - * @param HTTPRequest $request + * @param array $data + * @param HTTPRequest $request * @param ValidationResult|null $result * @return Member|null * @throws OutOfBoundsException * @throws BadMethodCallException + * @throws Exception */ public function authenticate(array $data, HTTPRequest $request, ValidationResult &$result = null) { @@ -103,123 +160,145 @@ public function authenticate(array $data, HTTPRequest $request, ValidationResult } $token = $data['token']; - return $this->validateToken($token, $request, $result); + /** @var JWTRecord $token */ + list($record, $status) = $this->validateToken($token, $request); + + // Report success! + if ($status === TokenStatusEnum::STATUS_OK) { + return $record->Member(); + } + + // Add errors to result + $result->addError( + $this->getErrorMessage($status), + ValidationResult::TYPE_ERROR, + $status + ); + return null; } /** - * @param Member $member + * Generate a new JWT token for a given request, and optional (if anonymous_allowed) user + * + * @param HTTPRequest $request + * @param string $subject Subject component to add to JWT token (additional data string) + * @param Member|MemberExtension $member If anonymous_allowed is true, this may be left blank for anonymous logins * @return Token * @throws ValidationException - * @throws BadMethodCallException */ - public function generateToken(Member $member) + public function generateToken(HTTPRequest $request, string $subject, Member $member = null) { - $this->setKeys(); $config = static::config(); + // Verify anonymous tokens are allowed + if (!$config->get('anonymous_allowed') && empty($member)) { + throw new InvalidArgumentException("Member is mandatory if anonymous_allowed is false"); + } $uniqueID = uniqid(Environment::getEnv('JWT_PREFIX'), true); - $request = Controller::curr()->getRequest(); - $audience = $request->getHeader('Origin'); + // Create new record + $record = new JWTRecord(); + $record->UID = $uniqueID; + $record->UserAgent = $request->getHeader('User-Agent'); + if ($member) { + $member->AuthTokens()->add($record); + } + if (!$record->isInDB()) { + $record->write(); + } + // Create builder for this record $builder = new Builder(); + $now = DBDatetime::now()->getTimestamp(); $token = $builder // Configures the issuer (iss claim) - ->setIssuer($audience) + ->setIssuer($request->getHeader('Origin')) // Configures the audience (aud claim) ->setAudience(Director::absoluteBaseURL()) // Configures the id (jti claim), replicating as a header item ->setId($uniqueID, true) // Configures the time that the token was issue (iat claim) - ->setIssuedAt(time()) + ->setIssuedAt($now) // Configures the time that the token can be used (nbf claim) - ->setNotBefore(time() + $config->get('nbf_time')) + ->setNotBefore($now + $config->get('nbf_time')) // Configures the expiration time of the token (nbf claim) - ->setExpiration(time() + $config->get('nbf_expiration')) - // Configures a new claim, called "uid" - ->set('uid', $member->ID) + ->setExpiration($now + $config->get('nbf_expiration')) + // Set renew expiration + ->set('rexp', $now + $config->get('nbf_refresh_expiration')) + // Configures a new claim, called "rid" + ->set('rid', $record->ID) // Set the subject, which is the member - ->setSubject($member->getJWTData()) + ->setSubject($subject) // 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) { - $member->JWTUniqueID = $uniqueID; - $member->write(); - } + ->sign($this->getSigner(), $this->getPrivateKey()); // Return the token return $token->getToken(); } /** - * @param string $token + * @param string $token * @param HTTPRequest $request - * @param ValidationResult $result - * @return null|Member + * @return array Array with JWTRecord and int status (STATUS_*) * @throws BadMethodCallException */ - private function validateToken($token, $request, &$result) + public function validateToken($token, $request) { - $this->setKeys(); + // Ensure token given at all + if (!$token) { + return [null, TokenStatusEnum::STATUS_INVALID]; + } + + // Parse token $parser = new Parser(); - $parsedToken = $parser->parse((string)$token); + try { + $parsedToken = $parser->parse((string)$token); + } catch (Exception $ex) { + // Un-parsable tokens are invalid + return [null, TokenStatusEnum::STATUS_INVALID]; + } - // Get a validator and the Member for this token - list($validator, $member) = $this->getValidator($request, $parsedToken); + // Validate token against Id and user-agent + $userAgent = $request->getHeader('User-Agent'); + /** @var JWTRecord $record */ + $record = JWTRecord::get() + ->filter(['UserAgent' => $userAgent]) + ->byID($parsedToken->getClaim('rid')); + if (!$record) { + return [null, TokenStatusEnum::STATUS_INVALID]; + } - $verified = $parsedToken->verify($this->signer, $this->publicKey); + // Get validator for this token + $now = DBDatetime::now()->getTimestamp(); + $validator = new ValidationData(); + $validator->setIssuer($request->getHeader('Origin')); + $validator->setAudience(Director::absoluteBaseURL()); + $validator->setId($record->UID); + $validator->setCurrentTime($now); + $verified = $parsedToken->verify($this->getSigner(), $this->getPublicKey()); $valid = $parsedToken->validate($validator); - // If the token is not verified, just give up - if (!$verified || !$valid) { - $result->addError('Invalid token'); - } - // An expired token can be renewed - if ( - $verified && - $parsedToken->isExpired() - ) { - $result->addError('Token is expired, please renew your token with a refreshToken query'); + // If unverified, break + if (!$verified) { + return [$record, TokenStatusEnum::STATUS_INVALID]; } - // Not entirely fine, do we allow anonymous users? - // Then, if the token is valid, return an anonymous user - if ( - $result->isValid() && - $parsedToken->getClaim('uid') === 0 && - static::config()->get('anonymous_allowed') - ) { - $member = Member::create(['ID' => 0, 'FirstName' => 'Anonymous']); - } - - 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($audience); - $validator->setAudience(Director::absoluteBaseURL()); + // Verified and valid = ok! + if ($valid) { + return [$record, TokenStatusEnum::STATUS_OK]; + } - 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; + // If the token is invalid, but not because it has expired, fail + if (!$parsedToken->isExpired()) { + return [$record, TokenStatusEnum::STATUS_INVALID]; } - $validator->setId($id); + // If expired, check if it can be renewed + $renewBefore = $parsedToken->getClaim('rexp'); + if ($renewBefore > $now) { + return [$record, TokenStatusEnum::STATUS_EXPIRED]; + } - return [$validator, $member]; + // If expired and cannot be renewed, it's dead + return [$record, TokenStatusEnum::STATUS_DEAD]; } } diff --git a/src/Extensions/MemberExtension.php b/src/Extensions/MemberExtension.php index 04d46ae..7baddb9 100644 --- a/src/Extensions/MemberExtension.php +++ b/src/Extensions/MemberExtension.php @@ -2,47 +2,42 @@ namespace Firesphere\GraphQLJWT\Extensions; +use Firesphere\GraphQLJWT\Model\JWTRecord; use SilverStripe\Core\Convert; -use SilverStripe\Forms\CheckboxField; use SilverStripe\Forms\FieldList; use SilverStripe\ORM\DataExtension; +use SilverStripe\ORM\HasManyList; use SilverStripe\Security\Member; use stdClass; /** * Class MemberExtension * Add a unique token to the Member for extra validation + * + * @property $owner Member|self + * @method HasManyList|JWTRecord[] AuthTokens() */ class MemberExtension extends DataExtension { - private static $db = [ - 'JWTUniqueID' => 'Varchar(255)', - ]; + /** + * List of names of extra subject fields to add to JWT token + * + * @config + * @var array + */ + private static $jwt_subject_fields = []; - private static $indexes = [ - 'JWTUniqueID' => 'unique' + /** + * @config + * @var array + */ + private static $has_many = [ + 'AuthTokens' => JWTRecord::class, ]; 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; - } + $fields->removeByName('AuthTokens'); } /** @@ -56,6 +51,7 @@ public function getJWTData() $identifier = Member::config()->get('unique_identifier_field'); $extraFields = Member::config()->get('jwt_subject_fields'); + $data->type = 'member'; $data->id = $this->owner->ID; $data->userName = $this->owner->$identifier; diff --git a/src/Helpers/GeneratesTokenOutput.php b/src/Helpers/GeneratesTokenOutput.php new file mode 100644 index 0000000..241120c --- /dev/null +++ b/src/Helpers/GeneratesTokenOutput.php @@ -0,0 +1,82 @@ + true, + 'Member' => $member && $member->exists() ? $member : null, + 'Token' => (string)$token, + 'Status' => $status, + 'Code' => 200, + ]; + } + + // Note: Use 426 to denote "please renew me" as a response code + $code = $status === TokenStatusEnum::STATUS_EXPIRED ? 426 : 401; + + // Check if errors should use http errors + if (JWTAuthenticator::config()->get('prefer_http_errors')) { + $message = $this->getErrorMessage($status); + throw new HTTPResponse_Exception($message, $code); + } + + // JSON error instead + return [ + 'Valid' => false, + 'Member' => null, + 'Token' => $token ? (string)$token : null, + 'Status' => $status, + 'Code' => $code, + ]; + } +} diff --git a/src/Helpers/HeaderExtractor.php b/src/Helpers/HeaderExtractor.php index baa93a9..dbde249 100644 --- a/src/Helpers/HeaderExtractor.php +++ b/src/Helpers/HeaderExtractor.php @@ -4,20 +4,23 @@ use SilverStripe\Control\HTTPRequest; -class HeaderExtractor +/** + * Parent class can detect JWT tokens in a request + */ +trait HeaderExtractor { - /** + * Get JWT from request, or null if not present + * * @param HTTPRequest $request - * @return array + * @return string|null */ - public static function getAuthorizationHeader(HTTPRequest $request) + protected function getAuthorizationHeader(HTTPRequest $request) { $authHeader = $request->getHeader('Authorization'); - if ($authHeader && preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) { - return $matches; + if ($authHeader && preg_match('/Bearer\s+(?.*)$/i', $authHeader, $matches)) { + return $matches['token']; } - - return [0, null]; + return null; } } diff --git a/src/Helpers/PathResolver.php b/src/Helpers/PathResolver.php new file mode 100644 index 0000000..abec798 --- /dev/null +++ b/src/Helpers/PathResolver.php @@ -0,0 +1,22 @@ +jwtAuthenticator; + } + + /** + * Inject authenticator this mutation should use + * + * @param JWTAuthenticator $authenticator + * @return $this + */ + public function setJWTAuthenticator(JWTAuthenticator $authenticator) + { + $this->jwtAuthenticator = $authenticator; + return $this; + } +} diff --git a/src/Helpers/RequiresConfig.php b/src/Helpers/RequiresConfig.php new file mode 100644 index 0000000..0bc6f3f --- /dev/null +++ b/src/Helpers/RequiresConfig.php @@ -0,0 +1,29 @@ + 'Varchar(255)', + 'UserAgent' => 'Text', + ]; + + private static $has_one = [ + 'Member' => Member::class, + ]; +} diff --git a/src/Mutations/CreateAnonymousTokenMutationCreator.php b/src/Mutations/CreateAnonymousTokenMutationCreator.php new file mode 100644 index 0000000..921d2c8 --- /dev/null +++ b/src/Mutations/CreateAnonymousTokenMutationCreator.php @@ -0,0 +1,71 @@ + 'createAnonymousToken', + 'description' => 'Creates a JWT token for an anonymous user. No email / password is required.' + ]; + } + + public function type() + { + return Type::string(); + } + + public function args() + { + return []; + } + + /** + * @param mixed $object + * @param array $args + * @param mixed $context + * @param ResolveInfo $info + * @return string The anonymous JWT token + * @throws NotFoundExceptionInterface + * @throws ValidationException + */ + public function resolve($object, array $args, $context, ResolveInfo $info) + { + // Verify anonymous tokens are allowed + if (JWTAuthenticator::config()->get('anonymous_allowed')) { + throw new BadMethodCallException('Anonymous JWT authentication is forbidden'); + } + + $request = Controller::curr()->getRequest(); + + // Create new token with anonymous payload + $authenticator = $this->getJWTAuthenticator(); + $token = $authenticator->generateToken($request, $this->getJWTData()); + return $token->__toString(); + } + + /** + * Get JWT subject data for anonymous user + * + * @return false|string + */ + protected function getJWTData() + { + return json_encode(['type' => 'anonymous']); + } +} diff --git a/src/Mutations/CreateTokenMutationCreator.php b/src/Mutations/CreateTokenMutationCreator.php index e992a80..5df10f9 100644 --- a/src/Mutations/CreateTokenMutationCreator.php +++ b/src/Mutations/CreateTokenMutationCreator.php @@ -2,20 +2,31 @@ namespace Firesphere\GraphQLJWT\Mutations; +use App\Users\GraphQL\Types\TokenStatusEnum; use Firesphere\GraphQLJWT\Authentication\JWTAuthenticator; +use Firesphere\GraphQLJWT\Extensions\MemberExtension; +use Firesphere\GraphQLJWT\Helpers\GeneratesTokenOutput; +use Firesphere\GraphQLJWT\Helpers\RequiresAuthenticator; use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; +use Psr\Container\NotFoundExceptionInterface; use SilverStripe\Control\Controller; +use SilverStripe\Control\HTTPRequest; +use SilverStripe\Control\HTTPResponse_Exception; use SilverStripe\Core\Injector\Injector; use SilverStripe\GraphQL\MutationCreator; use SilverStripe\GraphQL\OperationResolver; +use SilverStripe\ORM\ValidationException; +use SilverStripe\ORM\ValidationResult; use SilverStripe\Security\Authenticator; -use SilverStripe\Security\IdentityStore; use SilverStripe\Security\Member; use SilverStripe\Security\Security; class CreateTokenMutationCreator extends MutationCreator implements OperationResolver { + use RequiresAuthenticator; + use GeneratesTokenOutput; + public function attributes() { return [ @@ -38,46 +49,60 @@ public function args() } /** - * @param mixed $object - * @param array $args - * @param mixed $context + * @param mixed $object + * @param array $args + * @param mixed $context * @param ResolveInfo $info - * @return null|Member|static - * @throws \Psr\Container\NotFoundExceptionInterface + * @return array + * @throws NotFoundExceptionInterface + * @throws HTTPResponse_Exception + * @throws ValidationException */ public function resolve($object, array $args, $context, ResolveInfo $info) + { + // Authenticate this member + $request = Controller::curr()->getRequest(); + $member = $this->getAuthenticatedMember($args, $request); + + // Handle unauthenticated + if (!$member) { + return $this->generateResponse(TokenStatusEnum::STATUS_BAD_LOGIN); + } + + // Create new token from this member + $authenticator = $this->getJWTAuthenticator(); + $token = $authenticator->generateToken($request, $member->getJWTData(), $member); + return $this->generateResponse(TokenStatusEnum::STATUS_OK, $member, $token); + } + + /** + * Get an authenticated member from the given request + * + * @param array $args + * @param HTTPRequest $request + * @return Member|MemberExtension + */ + protected function getAuthenticatedMember(array $args, HTTPRequest $request): Member { /** @var Security $security */ $security = Injector::inst()->get(Security::class); $authenticators = $security->getApplicableAuthenticators(Authenticator::LOGIN); - $request = Controller::curr()->getRequest(); - $member = null; - if (count($authenticators)) { - /** @var Authenticator $authenticator */ - foreach ($authenticators as $authenticator) { - $member = $authenticator->authenticate($args, $request, $result); - if ($result->isValid()) { - break; - } + // Login with authenticators + foreach ($authenticators as $authenticator) { + // Skip JWT authenticator itself + if ($authenticator instanceof JWTAuthenticator) { + continue; } - } - $authenticator = Injector::inst()->get(JWTAuthenticator::class); - - if ($member instanceof Member) { - $member->Token = $authenticator->generateToken($member); - } elseif (JWTAuthenticator::config()->get('anonymous_allowed')) { - $member = Member::create(['ID' => 0, 'FirstName' => 'Anonymous']); - // Create an anonymous token - $member->Token = $authenticator->generateToken($member); - } else { - Security::setCurrentUser(null); - Injector::inst()->get(IdentityStore::class)->logOut(); - // Return a token-less member - $member = Member::create(); + // Check if we can authenticate + $result = new ValidationResult(); + $member = $authenticator->authenticate($args, $request, $result); + if ($member && $result->isValid()) { + return $member; + } } - return $member; + return null; } } diff --git a/src/Mutations/RefreshTokenMutationCreator.php b/src/Mutations/RefreshTokenMutationCreator.php index b2cd607..ca2a07e 100644 --- a/src/Mutations/RefreshTokenMutationCreator.php +++ b/src/Mutations/RefreshTokenMutationCreator.php @@ -2,19 +2,28 @@ namespace Firesphere\GraphQLJWT\Mutations; -use Firesphere\GraphQLJWT\Authentication\JWTAuthenticator; +use App\Users\GraphQL\Types\TokenStatusEnum; +use BadMethodCallException; +use Exception; +use Firesphere\GraphQLJWT\Helpers\GeneratesTokenOutput; use Firesphere\GraphQLJWT\Helpers\HeaderExtractor; +use Firesphere\GraphQLJWT\Helpers\RequiresAuthenticator; +use Firesphere\GraphQLJWT\Model\JWTRecord; use GraphQL\Type\Definition\ResolveInfo; -use Lcobucci\JWT\Parser; +use OutOfBoundsException; +use Psr\Container\NotFoundExceptionInterface; use SilverStripe\Control\Controller; -use SilverStripe\Core\Injector\Injector; +use SilverStripe\Control\HTTPResponse_Exception; use SilverStripe\GraphQL\MutationCreator; use SilverStripe\GraphQL\OperationResolver; -use SilverStripe\ORM\ValidationResult; -use SilverStripe\Security\Member; +use SilverStripe\ORM\ValidationException; class RefreshTokenMutationCreator extends MutationCreator implements OperationResolver { + use RequiresAuthenticator; + use HeaderExtractor; + use GeneratesTokenOutput; + public function attributes() { return [ @@ -34,59 +43,49 @@ public function args() } /** - * @param mixed $object - * @param array $args - * @param mixed $context + * @param mixed $object + * @param array $args + * @param mixed $context * @param ResolveInfo $info - * @return Member|null - * @throws \Psr\Container\NotFoundExceptionInterface - * @throws \SilverStripe\ORM\ValidationException - * @throws \BadMethodCallException - * @throws \OutOfBoundsException + * @return array + * @throws NotFoundExceptionInterface + * @throws ValidationException + * @throws BadMethodCallException + * @throws OutOfBoundsException + * @throws HTTPResponse_Exception + * @throws Exception */ public function resolve($object, array $args, $context, ResolveInfo $info) { + $authenticator = $this->getJWTAuthenticator(); $request = Controller::curr()->getRequest(); - $authenticator = Injector::inst()->get(JWTAuthenticator::class); - $member = null; - $result = new ValidationResult(); - $matches = HeaderExtractor::getAuthorizationHeader($request); - - if (!empty($matches[1])) { - $member = $authenticator->authenticate(['token' => $matches[1]], $request, $result); - } + $token = $this->getAuthorizationHeader($request); - $expired = false; - // If we have a valid member, or there are no matches, there's no reason to go in here - if ($member === null && !empty($matches[1])) { - foreach ($result->getMessages() as $message) { - if (strpos($message['message'], 'Token is expired') !== false) { - // If expired is true, the rest of the token is valid, so we can refresh - $expired = true; - // We need a member, even if the result is false - $parser = new Parser(); - $parsedToken = $parser->parse((string)$matches[1]); - /** @var Member $member */ - $member = Member::get() - ->filter(['JWTUniqueID' => $parsedToken->getClaim('jti')]) - ->byID($parsedToken->getClaim('uid')); - } - } - } elseif ($member) { - $expired = true; + // Check status of existing token + /** @var JWTRecord $record */ + list($record, $status) = $authenticator->validateToken($token, $request); + $member = null; + switch ($status) { + case TokenStatusEnum::STATUS_OK: + case TokenStatusEnum::STATUS_EXPIRED: + $member = $record->Member(); + $renewable = true; + break; + case TokenStatusEnum::STATUS_DEAD: + case TokenStatusEnum::STATUS_INVALID: + default: + $member = null; + $renewable = false; + break; } - if ($expired && $member) { - $member->Token = $authenticator->generateToken($member); - } else { - // Everything is wrong, give an empty member without token - $member = Member::create(['ID' => 0, 'FirstName' => 'Anonymous']); - } - // Maybe not _everything_, we possibly have an anonymous allowed user - if ($member->ID === 0 && JWTAuthenticator::config()->get('anonymous_allowed')) { - $member->Token = $authenticator->generateToken($member); + // Check if renewable + if (!$renewable) { + return $this->generateResponse($status); } - return $member; + // Create new token for member + $token = $authenticator->generateToken($request, $member); + return $this->generateResponse(TokenStatusEnum::STATUS_OK, $member, $token); } } diff --git a/src/Queries/ValidateTokenQueryCreator.php b/src/Queries/ValidateTokenQueryCreator.php index 5477036..ec0c997 100644 --- a/src/Queries/ValidateTokenQueryCreator.php +++ b/src/Queries/ValidateTokenQueryCreator.php @@ -2,17 +2,28 @@ namespace Firesphere\GraphQLJWT\Queries; +use App\Users\GraphQL\Types\TokenStatusEnum; +use BadMethodCallException; +use Exception; use Firesphere\GraphQLJWT\Authentication\JWTAuthenticator; +use Firesphere\GraphQLJWT\Helpers\GeneratesTokenOutput; use Firesphere\GraphQLJWT\Helpers\HeaderExtractor; +use Firesphere\GraphQLJWT\Helpers\RequiresAuthenticator; +use Firesphere\GraphQLJWT\Model\JWTRecord; use GraphQL\Type\Definition\ResolveInfo; +use OutOfBoundsException; +use Psr\Container\NotFoundExceptionInterface; use SilverStripe\Control\Controller; -use SilverStripe\Core\Injector\Injector; +use SilverStripe\Control\HTTPResponse_Exception; use SilverStripe\GraphQL\OperationResolver; use SilverStripe\GraphQL\QueryCreator; -use SilverStripe\ORM\ValidationResult; class ValidateTokenQueryCreator extends QueryCreator implements OperationResolver { + use RequiresAuthenticator; + use HeaderExtractor; + use GeneratesTokenOutput; + public function attributes() { return [ @@ -28,46 +39,31 @@ public function args() public function type() { - return $this->manager->getType('ValidateToken'); + return $this->manager->getType('MemberToken'); } /** - * @param mixed $object - * @param array $args - * @param mixed $context + * @param mixed $object + * @param array $args + * @param mixed $context * @param ResolveInfo $info * @return array - * @throws \Psr\Container\NotFoundExceptionInterface - * @throws \OutOfBoundsException - * @throws \BadMethodCallException + * @throws NotFoundExceptionInterface + * @throws OutOfBoundsException + * @throws BadMethodCallException + * @throws HTTPResponse_Exception + * @throws Exception */ public function resolve($object, array $args, $context, ResolveInfo $info) { /** @var JWTAuthenticator $authenticator */ - $authenticator = Injector::inst()->get(JWTAuthenticator::class); - $msg = []; + $authenticator = $this->getJWTAuthenticator(); $request = Controller::curr()->getRequest(); - $matches = HeaderExtractor::getAuthorizationHeader($request); - $result = new ValidationResult(); - $code = 401; - - if (!empty($matches[1])) { - $authenticator->authenticate(['token' => $matches[1]], $request, $result); - if ($result->isValid()) { - $code = 200; - } - } else { - $result->addError('No Bearer token found'); - } - - foreach ($result->getMessages() as $message) { - if (strpos($message['message'], 'Token is expired') === 0) { - // An expired token is code 426 `Update required` - $code = 426; - } - $msg[] = $message['message']; - } + $token = $this->getAuthorizationHeader($request); - return ['Valid' => $result->isValid(), 'Message' => implode('; ', $msg), 'Code' => $code]; + /** @var JWTRecord $record */ + list($record, $status) = $authenticator->validateToken($token, $request); + $member = $status === TokenStatusEnum::STATUS_OK ? $record->Member() : null; + return $this->generateResponse($status, $member, $token); } } diff --git a/src/Types/MemberTokenTypeCreator.php b/src/Types/MemberTokenTypeCreator.php index 1f486f4..dd54c0d 100644 --- a/src/Types/MemberTokenTypeCreator.php +++ b/src/Types/MemberTokenTypeCreator.php @@ -2,9 +2,13 @@ namespace Firesphere\GraphQLJWT\Types; +use App\Users\GraphQL\Types\TokenStatusEnum; use GraphQL\Type\Definition\Type; use SilverStripe\GraphQL\TypeCreator; +/** + * Represents a member / token pair + */ class MemberTokenTypeCreator extends TypeCreator { public function attributes() @@ -16,15 +20,12 @@ public function attributes() public function fields() { - $string = Type::string(); - $id = Type::id(); - return [ - 'ID' => ['type' => $id], - 'FirstName' => ['type' => $string], - 'Surname' => ['type' => $string], - 'Email' => ['type' => $string], - 'Token' => ['type' => $string] + 'Valid' => ['type' => Type::boolean()], + 'Member' => ['type' => $this->manager->getType('Member')], + 'Token' => ['type' => Type::string()], + 'Status' => ['type' => TokenStatusEnum::instance()], + 'Code' => ['type' => Type::int()], ]; } } diff --git a/src/Types/MemberTypeCreator.php b/src/Types/MemberTypeCreator.php new file mode 100644 index 0000000..6ca45b7 --- /dev/null +++ b/src/Types/MemberTypeCreator.php @@ -0,0 +1,28 @@ + 'Member']; + } + + public function fields() + { + return [ + 'ID' => ['type' => Type::int()], + 'FirstName' => ['type' => Type::string()], + 'Surname' => ['type' => Type::string()], + 'Email' => ['type' => Type::string()], + 'Token' => ['type' => Type::string()] + ]; + } +} diff --git a/src/Types/TokenStatusEnum.php b/src/Types/TokenStatusEnum.php new file mode 100644 index 0000000..29cfa1f --- /dev/null +++ b/src/Types/TokenStatusEnum.php @@ -0,0 +1,80 @@ + [ + 'value' => self::STATUS_OK, + 'description' => 'JWT token is valid', + ], + self::STATUS_INVALID => [ + 'value' => self::STATUS_INVALID, + 'description' => 'JWT token is not valid', + ], + self::STATUS_EXPIRED => [ + 'value' => self::STATUS_EXPIRED, + 'description' => 'JWT token has expired, but can be renewed', + ], + self::STATUS_DEAD => [ + 'value' => self::STATUS_DEAD, + 'description' => 'JWT token has expired and cannot be renewed', + ], + self::STATUS_BAD_LOGIN => [ + 'value' => self::STATUS_BAD_LOGIN, + 'description' => 'JWT token could not be created due to invalid login credentials', + ], + ]; + $config = [ + 'name' => 'TokenStatus', + 'description' => 'Status of token', + 'values' => $values, + ]; + + parent::__construct($config); + } + + /** + * Safely create a single type creator only + * + * @return TokenStatusEnum + */ + public static function instance() + { + static $instance = null; + if (!$instance) { + $instance = new self(); + } + return $instance; + } +} diff --git a/src/Types/ValidateTokenTypeCreator.php b/src/Types/ValidateTokenTypeCreator.php deleted file mode 100644 index c1b3e29..0000000 --- a/src/Types/ValidateTokenTypeCreator.php +++ /dev/null @@ -1,25 +0,0 @@ - 'ValidateToken' - ]; - } - - public function fields() - { - return [ - 'Valid' => ['type' => Type::boolean()], - 'Message' => ['type' => Type::string()], - 'Code' => ['type' => Type::int()], - ]; - } -} From f3e3fe4306202bedeb7b3ef5db8c5320ed5c471e Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Thu, 18 Apr 2019 18:04:14 +1200 Subject: [PATCH 02/11] Anonymous authenticator Fix and upgrade tests Upgrade documentation Bump minimum dependency to 7.1 --- _config/config.yml | 36 ++---- _config/graphql.yml | 11 -- composer.json | 10 +- readme.md | 79 +++++++++++- .../AnonymousUserAuthenticator.php | 115 ++++++++++++++++++ .../JWTAuthenticationHandler.php | 39 ++---- src/Authentication/JWTAuthenticator.php | 55 +++------ src/Exceptions/JWTException.php | 2 +- src/Extensions/MemberExtension.php | 19 ++- src/Helpers/HeaderExtractor.php | 4 +- ...kenOutput.php => MemberTokenGenerator.php} | 62 +++++----- src/Helpers/PathResolver.php | 8 +- src/Helpers/RequiresAuthenticator.php | 10 +- src/Helpers/RequiresConfig.php | 4 +- src/Model/JWTRecord.php | 2 +- .../CreateAnonymousTokenMutationCreator.php | 71 ----------- src/Mutations/CreateTokenMutationCreator.php | 81 ++++++++---- src/Mutations/RefreshTokenMutationCreator.php | 26 ++-- src/Queries/ValidateTokenQueryCreator.php | 19 +-- src/Types/MemberTokenTypeCreator.php | 6 +- src/Types/MemberTypeCreator.php | 9 +- src/Types/TokenStatusEnum.php | 5 +- tests/unit/CreateTokenMutationCreatorTest.php | 54 ++++---- tests/unit/JWTAuthenticationHandlerTest.php | 37 +++--- tests/unit/JWTAuthenticatorTest.php | 81 ++++++------ tests/unit/MemberExtensionTest.php | 13 +- .../unit/RefreshTokenMutationCreatorTest.php | 40 +++--- tests/unit/ValidateTokenQueryCreatorTest.php | 37 ++++-- 28 files changed, 528 insertions(+), 407 deletions(-) delete mode 100644 _config/graphql.yml create mode 100644 src/Authentication/AnonymousUserAuthenticator.php rename src/Helpers/{GeneratesTokenOutput.php => MemberTokenGenerator.php} (53%) delete mode 100644 src/Mutations/CreateAnonymousTokenMutationCreator.php diff --git a/_config/config.yml b/_config/config.yml index cf37f87..cf823be 100644 --- a/_config/config.yml +++ b/_config/config.yml @@ -1,32 +1,20 @@ --- -after: graphqlconfig +Name: firesphere-jwt-injections --- SilverStripe\Core\Injector\Injector: SilverStripe\Security\AuthenticationHandler: properties: Handlers: - jwt: %$Firesphere\GraphQLJWT\Authentication\JWTAuthenticationHandler + jwt: %$Firesphere\GraphQLJWT\Authentication\JWTAuthenticationHandler Firesphere\GraphQLJWT\Authentication\JWTAuthenticationHandler: properties: - Authenticator: %$Firesphere\GraphQLJWT\Authentication\JWTAuthenticator ---- -after: graphqlroutes ---- -SilverStripe\Control\Director: - rules: - graphql: - Controller: 'SilverStripe\GraphQL\Controller' - # @internal - Experimental config - # @todo - move this to a per-schema configuration, and simply register the named schema for this endpoint - # https://github.com/silverstripe/silverstripe-graphql/issues/52 - Stage: Live - Permissions: false ---- -name: graphqljwt -after: - - '#coresecurity' ---- -Firesphere\GraphQLJWT\Authentication\JWTAuthenticator: - nbf_time: 0 - nbf_expiration: 3600 - anonymous_allowed: false \ No newline at end of file + JWTAuthenticator: %$Firesphere\GraphQLJWT\Authentication\JWTAuthenticator + Firesphere\GraphQLJWT\Mutations\CreateTokenMutationCreator: + properties: + JWTAuthenticator: %$Firesphere\GraphQLJWT\Authentication\JWTAuthenticator + Firesphere\GraphQLJWT\Mutations\RefreshTokenMutationCreator: + properties: + JWTAuthenticator: %$Firesphere\GraphQLJWT\Authentication\JWTAuthenticator + Firesphere\GraphQLJWT\Queries\ValidateTokenQueryCreator: + properties: + JWTAuthenticator: %$Firesphere\GraphQLJWT\Authentication\JWTAuthenticator diff --git a/_config/graphql.yml b/_config/graphql.yml deleted file mode 100644 index 7864f78..0000000 --- a/_config/graphql.yml +++ /dev/null @@ -1,11 +0,0 @@ -SilverStripe\GraphQL\Controller: - schema: - types: - MemberToken: 'Firesphere\GraphQLJWT\Types\MemberTokenTypeCreator' - Member: 'Firesphere\GraphQLJWT\Types\MemberTypeCreator' - mutations: - createAnonymousToken: 'Firesphere\GraphQLJWT\Mutations\CreateAnonymousTokenMutationCreator' - createToken: 'Firesphere\GraphQLJWT\Mutations\CreateTokenMutationCreator' - refreshToken: 'Firesphere\GraphQLJWT\Mutations\RefreshTokenMutationCreator' - queries: - validateToken: 'Firesphere\GraphQLJWT\Queries\ValidateTokenQueryCreator' diff --git a/composer.json b/composer.json index c603d7f..017fec3 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,7 @@ "type": "silverstripe-vendormodule", "license": "bsd-3-clause", "require": { - "php": ">=5.6", + "php": ">=7.1", "silverstripe/framework": "^4.3", "silverstripe/graphql": "^3.0", "lcobucci/jwt": "^3.2", @@ -26,12 +26,8 @@ }, "autoload": { "psr-4": { - "Firesphere\\GraphQLJWT\\Authentication\\": "src/Authentication", - "Firesphere\\GraphQLJWT\\Extensions\\": "src/Extensions", - "Firesphere\\GraphQLJWT\\Helpers\\": "src/Helpers", - "Firesphere\\GraphQLJWT\\Mutations\\": "src/Mutations", - "Firesphere\\GraphQLJWT\\Queries\\": "src/Queries", - "Firesphere\\GraphQLJWT\\Types\\": "src/Types" + "Firesphere\\GraphQLJWT\\": "src/", + "Firesphere\\GraphQLJWT\\Tests\\": "tests/unit/" } }, "prefer-stable": true, diff --git a/readme.md b/readme.md index 78f13fb..d9a9f0d 100644 --- a/readme.md +++ b/readme.md @@ -26,16 +26,63 @@ JWT_SIGNER_KEY="[your secret key]" You can also use public/private key files, using the following: ```ini -JWT_SIGNER_KEY="/path/to/private.key" -JWT_PUBLIC_KEY="/path/to/public.key" +JWT_SIGNER_KEY="./path/to/private.key" +JWT_PUBLIC_KEY="./path/to/public.key" ``` +Note: Relative paths will be relative to your BASE_PATH (prefixed with `./`) + Currently, only RSA keys are supported. ECDSA is not supported. The keys in the test-folder are generated by an online RSA key generator. The signer key [for HMAC can be of any length (keys longer than B bytes are first hashed using H). However, less than L bytes is strongly discouraged as it would decrease the security strength of the function.](https://tools.ietf.org/html/rfc2104#section-3). Thus, for SHA-256 the signer key should be between 16 and 64 bytes in length. **The keys in `tests/keys` should not be trusted!** +## Configuration + +Since admin/graphql is reserved exclusively for CMS graphql access, it will be necessary for you to register a custom schema for +your front-end application, and apply the provided queries and mutations to that. + +For example, given you've decided to create a schema named `frontend` at the url `/api` + +```yml +--- +Name: my-graphql-schema +--- +SilverStripe\GraphQL\Manager: + schemas: + frontend: + types: + MemberToken: 'Firesphere\GraphQLJWT\Types\MemberTokenTypeCreator' + Member: 'Firesphere\GraphQLJWT\Types\MemberTypeCreator' + mutations: + createToken: 'Firesphere\GraphQLJWT\Mutations\CreateTokenMutationCreator' + refreshToken: 'Firesphere\GraphQLJWT\Mutations\RefreshTokenMutationCreator' + queries: + validateToken: 'Firesphere\GraphQLJWT\Queries\ValidateTokenQueryCreator' +--- +Name: my-graphql-injections +--- +SilverStripe\Core\Injector\Injector: + SilverStripe\GraphQL\Manager.frontend: + class: SilverStripe\GraphQL\Manager + constructor: + identifier: frontend + SilverStripe\GraphQL\Controller.frontend: + class: SilverStripe\GraphQL\Controller + constructor: + manager: '%$SilverStripe\GraphQL\Manager.frontend' +--- +Name: my-graphql-routes +--- +SilverStripe\Control\Director: + rules: + api: + Controller: '%$SilverStripe\GraphQL\Controller.frontend' + Stage: Live +``` + + ## Log in To generate a JWT token, send a login request to the `createToken` mutator: @@ -84,13 +131,35 @@ If the token is invalid, `Valid` will be `false`. ## Anonymous tokens -Although not advised, it's possible to use anonymous tokens. A member ID of `0` will be returned, along with `"Anonymous"` as a name. To enable anonymous tokens, add the following to your configuration `.yml`: +Although not advised, it's possible to use anonymous tokens. When using an anonymous authenticator, SilverStripe +will generate a default database record in the Members table with the Email `anonymous` and no permissions by default. + +To enable anonymous tokens, add the following to your configuration `.yml`: ```yaml -Firesphere\GraphQLJWT\JWTAuthenticator: - anonymous_allowed: true +SilverStripe\Core\Injector\Injector: + Firesphere\GraphQLJWT\Mutations\CreateTokenMutationCreator: + properties: + CustomAuthenticators: + - Firesphere\GraphQLJWT\Authentication\AnonymousUserAuthenticator +``` + +You can then create an anonymous login with the below query. + +```graphql +mutation { + createToken(Email: "anonymous") { + Token + } +} ``` +Note: If the default anonymous authenticator doesn't suit your purposes, you can inject any other +core SilverStripe authenticator into `CustomAuthenticators`. + +Warning: The default `AnonymousUserAuthenticator` is not appropriate for general usage, so don't +register this under the core `Security` class! + ## Enable CORS To use JWT, CORS needs to be enabled. This can be done by adding the following to your configuration `.yml`: diff --git a/src/Authentication/AnonymousUserAuthenticator.php b/src/Authentication/AnonymousUserAuthenticator.php new file mode 100644 index 0000000..9515e98 --- /dev/null +++ b/src/Authentication/AnonymousUserAuthenticator.php @@ -0,0 +1,115 @@ + 'Anonymous', + ]; + + public function supportedServices(): int + { + return Authenticator::LOGIN | Authenticator::LOGOUT; + } + + public function authenticate(array $data, HTTPRequest $request, ValidationResult &$result = null): ?Member + { + // Only applies to request for anonymous user specifically + $email = $data['Email'] ?? null; + if ($email !== static::config()->get('anonymous_username')) { + return null; + } + + return parent::authenticate($data, $request, $result); + } + + /** + * Attempt to find and authenticate member if possible from the given data + * + * @skipUpgrade + * @param array $data Form submitted data + * @param ValidationResult $result + * @param Member $member This third parameter is used in the CMSAuthenticator(s) + * @return Member Found member, regardless of successful login + * @throws ValidationException + */ + protected function authenticateMember($data, ValidationResult &$result = null, Member $member = null): Member + { + // Get user, or create if not exists + $member = $this->getOrCreateAnonymousMember(); + + // Validate this member is still allowed to login + $result = $result ?: ValidationResult::create(); + $member->validateCanLogin($result); + + // Emit failure to member and form (if available) + if ($result->isValid()) { + $member->registerSuccessfulLogin(); + } else { + $member->registerFailedLogin(); + } + + return $member; + } + + public function checkPassword(Member $member, $password, ValidationResult &$result = null) + { + throw new BadMethodCallException("checkPassword not supported for anonymous users"); + } + + /** + * Build a new datarecord to contain the anonymous user + * + * @return Member + * @throws ValidationException + */ + protected function getOrCreateAnonymousMember() + { + // Fun facts about anonymous members + $identifierField = Member::config()->get('unique_identifier_field'); + $username = static::config()->get('anonymous_username'); + $fields = static::config()->get('anonymous_fields'); + + // Find existing member + /** @var Member $member */ + $member = Member::get()->find($identifierField, $username); + $this->extend('updateExistingAnonymousMember', $member); + if ($member) { + return $member; + } + + // Create new member + $member = Member::create(); + $member->{$identifierField} = $username; + $member->update($fields); + $this->extend('updateCreatedAnonymousMember', $member); + $member->write(); + return $member; + } +} diff --git a/src/Authentication/JWTAuthenticationHandler.php b/src/Authentication/JWTAuthenticationHandler.php index 819a6d1..54b0376 100644 --- a/src/Authentication/JWTAuthenticationHandler.php +++ b/src/Authentication/JWTAuthenticationHandler.php @@ -1,4 +1,4 @@ -authenticator; - } - - /** - * @param JWTAuthenticator $authenticator - * @return $this - */ - public function setAuthenticator(JWTAuthenticator $authenticator) - { - $this->authenticator = $authenticator; - return $this; - } + use RequiresAuthenticator; + use Injectable; /** * @param HTTPRequest $request @@ -51,7 +32,7 @@ public function setAuthenticator(JWTAuthenticator $authenticator) * @throws BadMethodCallException * @throws Exception */ - public function authenticateRequest(HTTPRequest $request) + public function authenticateRequest(HTTPRequest $request): ?Member { // Check token $token = $this->getAuthorizationHeader($request); @@ -61,7 +42,7 @@ public function authenticateRequest(HTTPRequest $request) // Validate the token. This is critical for security $member = $this - ->getAuthenticator() + ->getJWTAuthenticator() ->authenticate(['token' => $token], $request); if ($member) { @@ -79,7 +60,7 @@ public function authenticateRequest(HTTPRequest $request) * @param bool $persistent * @param HTTPRequest|null $request */ - public function logIn(Member $member, $persistent = false, HTTPRequest $request = null) + public function logIn(Member $member, $persistent = false, HTTPRequest $request = null): void { Security::setCurrentUser($member); } @@ -87,14 +68,14 @@ public function logIn(Member $member, $persistent = false, HTTPRequest $request /** * @param HTTPRequest|null $request */ - public function logOut(HTTPRequest $request = null) + public function logOut(HTTPRequest $request = null): void { // A token can actually not be invalidated, but let's flush all valid tokens from the DB. // Note that log-out acts as a global logout (all devices) /** @var Member|MemberExtension $member */ $member = Security::getCurrentUser(); if ($member) { - $member->AuthTokens()->removeAll(); + $member->DestroyAuthTokens(); } Security::setCurrentUser(null); diff --git a/src/Authentication/JWTAuthenticator.php b/src/Authentication/JWTAuthenticator.php index 88bb79b..5995b98 100644 --- a/src/Authentication/JWTAuthenticator.php +++ b/src/Authentication/JWTAuthenticator.php @@ -1,4 +1,4 @@ -getEnv('JWT_SIGNER_KEY'); if (PathResolver::resolve($signerKey)) { @@ -99,7 +87,7 @@ protected function getSigner() * * @return Key */ - protected function getPrivateKey() + protected function getPrivateKey(): Key { $signerKey = $this->getEnv('JWT_SIGNER_KEY'); $signerPath = PathResolver::resolve($signerKey); @@ -116,7 +104,7 @@ protected function getPrivateKey() * @return Key * @throws LogicException */ - private function getPublicKey() + private function getPublicKey(): Key { $signerKey = Environment::getEnv('JWT_SIGNER_KEY'); $signerPath = PathResolver::resolve($signerKey); @@ -139,7 +127,7 @@ private function getPublicKey() * * @return int */ - public function supportedServices() + public function supportedServices(): int { return Authenticator::LOGIN; } @@ -153,14 +141,14 @@ public function supportedServices() * @throws BadMethodCallException * @throws Exception */ - public function authenticate(array $data, HTTPRequest $request, ValidationResult &$result = null) + public function authenticate(array $data, HTTPRequest $request, ValidationResult &$result = null): ?Member { if (!$result) { $result = new ValidationResult(); } $token = $data['token']; - /** @var JWTRecord $token */ + /** @var JWTRecord $record */ list($record, $status) = $this->validateToken($token, $request); // Report success! @@ -181,27 +169,20 @@ public function authenticate(array $data, HTTPRequest $request, ValidationResult * Generate a new JWT token for a given request, and optional (if anonymous_allowed) user * * @param HTTPRequest $request - * @param string $subject Subject component to add to JWT token (additional data string) - * @param Member|MemberExtension $member If anonymous_allowed is true, this may be left blank for anonymous logins + * @param Member|MemberExtension $member * @return Token * @throws ValidationException */ - public function generateToken(HTTPRequest $request, string $subject, Member $member = null) + public function generateToken(HTTPRequest $request, Member $member): Token { $config = static::config(); - // Verify anonymous tokens are allowed - if (!$config->get('anonymous_allowed') && empty($member)) { - throw new InvalidArgumentException("Member is mandatory if anonymous_allowed is false"); - } - $uniqueID = uniqid(Environment::getEnv('JWT_PREFIX'), true); + $uniqueID = uniqid($this->getEnv('JWT_PREFIX', ''), true); // Create new record $record = new JWTRecord(); $record->UID = $uniqueID; $record->UserAgent = $request->getHeader('User-Agent'); - if ($member) { - $member->AuthTokens()->add($record); - } + $member->AuthTokens()->add($record); if (!$record->isInDB()) { $record->write(); } @@ -227,7 +208,7 @@ public function generateToken(HTTPRequest $request, string $subject, Member $mem // Configures a new claim, called "rid" ->set('rid', $record->ID) // Set the subject, which is the member - ->setSubject($subject) + ->setSubject($member->getJWTData()) // Sign the key with the Signer's key ->sign($this->getSigner(), $this->getPrivateKey()); @@ -241,7 +222,7 @@ public function generateToken(HTTPRequest $request, string $subject, Member $mem * @return array Array with JWTRecord and int status (STATUS_*) * @throws BadMethodCallException */ - public function validateToken($token, $request) + public function validateToken(string $token, HTTPrequest $request): array { // Ensure token given at all if (!$token) { @@ -251,7 +232,7 @@ public function validateToken($token, $request) // Parse token $parser = new Parser(); try { - $parsedToken = $parser->parse((string)$token); + $parsedToken = $parser->parse($token); } catch (Exception $ex) { // Un-parsable tokens are invalid return [null, TokenStatusEnum::STATUS_INVALID]; diff --git a/src/Exceptions/JWTException.php b/src/Exceptions/JWTException.php index 77cf59e..0956e6b 100644 --- a/src/Exceptions/JWTException.php +++ b/src/Exceptions/JWTException.php @@ -1,4 +1,4 @@ -get('unique_identifier_field'); @@ -64,4 +64,17 @@ public function getJWTData() return Convert::raw2json($data); } + + /** + * Destroy all JWT tokens + * + * @return Member + */ + public function DestroyAuthTokens(): Member + { + foreach ($this->owner->AuthTokens() as $token) { + $token->delete(); + } + return $this->owner; + } } diff --git a/src/Helpers/HeaderExtractor.php b/src/Helpers/HeaderExtractor.php index dbde249..7fe5e36 100644 --- a/src/Helpers/HeaderExtractor.php +++ b/src/Helpers/HeaderExtractor.php @@ -1,4 +1,4 @@ -getHeader('Authorization'); if ($authHeader && preg_match('/Bearer\s+(?.*)$/i', $authHeader, $matches)) { diff --git a/src/Helpers/GeneratesTokenOutput.php b/src/Helpers/MemberTokenGenerator.php similarity index 53% rename from src/Helpers/GeneratesTokenOutput.php rename to src/Helpers/MemberTokenGenerator.php index 241120c..03bc8d7 100644 --- a/src/Helpers/GeneratesTokenOutput.php +++ b/src/Helpers/MemberTokenGenerator.php @@ -1,18 +1,18 @@ - true, - 'Member' => $member && $member->exists() ? $member : null, - 'Token' => (string)$token, - 'Status' => $status, - 'Code' => 200, + $response = [ + 'Valid' => true, + 'Member' => $member && $member->exists() ? $member : null, + 'Token' => $token, + 'Status' => $status, + 'Code' => 200, + 'Message' => $this->getErrorMessage($status), + ]; + } else { + $response = [ + 'Valid' => false, + 'Member' => null, + 'Token' => $token, + 'Status' => $status, + 'Code' => 401, + 'Message' => $this->getErrorMessage($status), ]; } - // Note: Use 426 to denote "please renew me" as a response code - $code = $status === TokenStatusEnum::STATUS_EXPIRED ? 426 : 401; - - // Check if errors should use http errors - if (JWTAuthenticator::config()->get('prefer_http_errors')) { - $message = $this->getErrorMessage($status); - throw new HTTPResponse_Exception($message, $code); - } - - // JSON error instead - return [ - 'Valid' => false, - 'Member' => null, - 'Token' => $token ? (string)$token : null, - 'Status' => $status, - 'Code' => $code, - ]; + $this->extend('updateMemberToken', $response); + return $response; } } diff --git a/src/Helpers/PathResolver.php b/src/Helpers/PathResolver.php index abec798..1464b49 100644 --- a/src/Helpers/PathResolver.php +++ b/src/Helpers/PathResolver.php @@ -1,4 +1,4 @@ -jwtAuthenticator; } @@ -28,7 +28,7 @@ protected function getJWTAuthenticator() * @param JWTAuthenticator $authenticator * @return $this */ - public function setJWTAuthenticator(JWTAuthenticator $authenticator) + public function setJWTAuthenticator(JWTAuthenticator $authenticator): self { $this->jwtAuthenticator = $authenticator; return $this; diff --git a/src/Helpers/RequiresConfig.php b/src/Helpers/RequiresConfig.php index 0bc6f3f..e1d13c8 100644 --- a/src/Helpers/RequiresConfig.php +++ b/src/Helpers/RequiresConfig.php @@ -1,4 +1,4 @@ - 'createAnonymousToken', - 'description' => 'Creates a JWT token for an anonymous user. No email / password is required.' - ]; - } - - public function type() - { - return Type::string(); - } - - public function args() - { - return []; - } - - /** - * @param mixed $object - * @param array $args - * @param mixed $context - * @param ResolveInfo $info - * @return string The anonymous JWT token - * @throws NotFoundExceptionInterface - * @throws ValidationException - */ - public function resolve($object, array $args, $context, ResolveInfo $info) - { - // Verify anonymous tokens are allowed - if (JWTAuthenticator::config()->get('anonymous_allowed')) { - throw new BadMethodCallException('Anonymous JWT authentication is forbidden'); - } - - $request = Controller::curr()->getRequest(); - - // Create new token with anonymous payload - $authenticator = $this->getJWTAuthenticator(); - $token = $authenticator->generateToken($request, $this->getJWTData()); - return $token->__toString(); - } - - /** - * Get JWT subject data for anonymous user - * - * @return false|string - */ - protected function getJWTData() - { - return json_encode(['type' => 'anonymous']); - } -} diff --git a/src/Mutations/CreateTokenMutationCreator.php b/src/Mutations/CreateTokenMutationCreator.php index 5df10f9..8ddfac4 100644 --- a/src/Mutations/CreateTokenMutationCreator.php +++ b/src/Mutations/CreateTokenMutationCreator.php @@ -1,19 +1,18 @@ -customAuthenticators; + } + + /** + * @param Authenticator[] $authenticators + * @return CreateTokenMutationCreator + */ + public function setCustomAuthenticators(array $authenticators): self + { + $this->customAuthenticators = $authenticators; + return $this; + } + + public function attributes(): array { return [ 'name' => 'createToken', @@ -35,16 +60,16 @@ public function attributes() ]; } - public function type() + public function type(): Type { return $this->manager->getType('MemberToken'); } - public function args() + public function args(): array { return [ 'Email' => ['type' => Type::nonNull(Type::string())], - 'Password' => ['type' => Type::nonNull(Type::string())] + 'Password' => ['type' => Type::string()] ]; } @@ -55,10 +80,9 @@ public function args() * @param ResolveInfo $info * @return array * @throws NotFoundExceptionInterface - * @throws HTTPResponse_Exception * @throws ValidationException */ - public function resolve($object, array $args, $context, ResolveInfo $info) + public function resolve($object, array $args, $context, ResolveInfo $info): array { // Authenticate this member $request = Controller::curr()->getRequest(); @@ -71,8 +95,8 @@ public function resolve($object, array $args, $context, ResolveInfo $info) // Create new token from this member $authenticator = $this->getJWTAuthenticator(); - $token = $authenticator->generateToken($request, $member->getJWTData(), $member); - return $this->generateResponse(TokenStatusEnum::STATUS_OK, $member, $token); + $token = $authenticator->generateToken($request, $member); + return $this->generateResponse(TokenStatusEnum::STATUS_OK, $member, $token->__toString()); } /** @@ -82,20 +106,10 @@ public function resolve($object, array $args, $context, ResolveInfo $info) * @param HTTPRequest $request * @return Member|MemberExtension */ - protected function getAuthenticatedMember(array $args, HTTPRequest $request): Member + protected function getAuthenticatedMember(array $args, HTTPRequest $request): ?Member { - /** @var Security $security */ - $security = Injector::inst()->get(Security::class); - $authenticators = $security->getApplicableAuthenticators(Authenticator::LOGIN); - // Login with authenticators - foreach ($authenticators as $authenticator) { - // Skip JWT authenticator itself - if ($authenticator instanceof JWTAuthenticator) { - continue; - } - - // Check if we can authenticate + foreach ($this->getLoginAuthenticators() as $authenticator) { $result = new ValidationResult(); $member = $authenticator->authenticate($args, $request, $result); if ($member && $result->isValid()) { @@ -105,4 +119,19 @@ protected function getAuthenticatedMember(array $args, HTTPRequest $request): Me return null; } + + /** + * Get any authenticator we should use for logging in users + * + * @return Authenticator[]|Generator + */ + protected function getLoginAuthenticators(): Generator + { + // Check injected authenticators + yield from $this->getCustomAuthenticators(); + + // Get other login handlers from Security + $security = Security::singleton(); + yield from $security->getApplicableAuthenticators(Authenticator::LOGIN); + } } diff --git a/src/Mutations/RefreshTokenMutationCreator.php b/src/Mutations/RefreshTokenMutationCreator.php index ca2a07e..49b7f0a 100644 --- a/src/Mutations/RefreshTokenMutationCreator.php +++ b/src/Mutations/RefreshTokenMutationCreator.php @@ -1,19 +1,20 @@ - 'refreshToken', @@ -32,16 +34,11 @@ public function attributes() ]; } - public function type() + public function type(): Type { return $this->manager->getType('MemberToken'); } - public function args() - { - return []; - } - /** * @param mixed $object * @param array $args @@ -52,10 +49,9 @@ public function args() * @throws ValidationException * @throws BadMethodCallException * @throws OutOfBoundsException - * @throws HTTPResponse_Exception * @throws Exception */ - public function resolve($object, array $args, $context, ResolveInfo $info) + public function resolve($object, array $args, $context, ResolveInfo $info): array { $authenticator = $this->getJWTAuthenticator(); $request = Controller::curr()->getRequest(); @@ -85,7 +81,7 @@ public function resolve($object, array $args, $context, ResolveInfo $info) } // Create new token for member - $token = $authenticator->generateToken($request, $member); - return $this->generateResponse(TokenStatusEnum::STATUS_OK, $member, $token); + $newToken = $authenticator->generateToken($request, $member); + return $this->generateResponse(TokenStatusEnum::STATUS_OK, $member, $newToken->__toString()); } } diff --git a/src/Queries/ValidateTokenQueryCreator.php b/src/Queries/ValidateTokenQueryCreator.php index ec0c997..43c4b0c 100644 --- a/src/Queries/ValidateTokenQueryCreator.php +++ b/src/Queries/ValidateTokenQueryCreator.php @@ -1,4 +1,4 @@ - 'validateToken', @@ -32,12 +34,12 @@ public function attributes() ]; } - public function args() + public function args(): array { return []; } - public function type() + public function type(): Type { return $this->manager->getType('MemberToken'); } @@ -51,10 +53,9 @@ public function type() * @throws NotFoundExceptionInterface * @throws OutOfBoundsException * @throws BadMethodCallException - * @throws HTTPResponse_Exception * @throws Exception */ - public function resolve($object, array $args, $context, ResolveInfo $info) + public function resolve($object, array $args, $context, ResolveInfo $info): array { /** @var JWTAuthenticator $authenticator */ $authenticator = $this->getJWTAuthenticator(); diff --git a/src/Types/MemberTokenTypeCreator.php b/src/Types/MemberTokenTypeCreator.php index dd54c0d..17b7216 100644 --- a/src/Types/MemberTokenTypeCreator.php +++ b/src/Types/MemberTokenTypeCreator.php @@ -1,4 +1,4 @@ - 'MemberToken' ]; } - public function fields() + public function fields(): array { return [ 'Valid' => ['type' => Type::boolean()], diff --git a/src/Types/MemberTypeCreator.php b/src/Types/MemberTypeCreator.php index 6ca45b7..0864170 100644 --- a/src/Types/MemberTypeCreator.php +++ b/src/Types/MemberTypeCreator.php @@ -1,4 +1,4 @@ - 'Member']; } - public function fields() + public function fields(): array { return [ 'ID' => ['type' => Type::int()], 'FirstName' => ['type' => Type::string()], 'Surname' => ['type' => Type::string()], 'Email' => ['type' => Type::string()], - 'Token' => ['type' => Type::string()] + 'Token' => ['type' => Type::string()], + 'Message' => ['type' => Type::string()], ]; } } diff --git a/src/Types/TokenStatusEnum.php b/src/Types/TokenStatusEnum.php index 29cfa1f..6fae356 100644 --- a/src/Types/TokenStatusEnum.php +++ b/src/Types/TokenStatusEnum.php @@ -1,4 +1,4 @@ -member = $this->objFromFixture(Member::class, 'admin'); } - public function tearDown() - { - parent::tearDown(); - } - + /** + * @throws ValidationException + */ public function testResolveValid() { - $createToken = Injector::inst()->get(CreateTokenMutationCreator::class); + $createToken = CreateTokenMutationCreator::singleton(); $response = $createToken->resolve( null, @@ -41,40 +38,51 @@ public function testResolveValid() new ResolveInfo([]) ); - $this->assertTrue($response instanceof Member); - $this->assertNotNull($response->Token); + $this->assertTrue($response['Member'] instanceof Member); + $this->assertNotNull($response['Token']); } + /** + * @throws ValidationException + */ public function testResolveInvalidWithAllowedAnonymous() { - Config::modify()->set(JWTAuthenticator::class, 'anonymous_allowed', true); - $authenticator = Injector::inst()->get(CreateTokenMutationCreator::class); + $authenticator = CreateTokenMutationCreator::singleton(); + + // Inject custom authenticator + $authenticator->setCustomAuthenticators([ + AnonymousUserAuthenticator::singleton(), + ]); $response = $authenticator->resolve( null, - ['Email' => 'admin@silverstripe.com', 'Password' => 'wrong'], + ['Email' => 'anonymous'], [], new ResolveInfo([]) ); - $this->assertTrue($response instanceof Member); - $this->assertEquals(0, $response->ID); - $this->assertNotNull($response->Token); + /** @var Member $member */ + $member = $response['Member']; + $this->assertTrue($member instanceof Member); + $this->assertTrue($member->exists()); + $this->assertEquals($member->Email, 'anonymous'); + $this->assertNotNull($response['Token']); } + /** + * @throws ValidationException + */ public function testResolveInvalidWithoutAllowedAnonymous() { - Config::modify()->set(JWTAuthenticator::class, 'anonymous_allowed', false); - $authenticator = Injector::inst()->get(CreateTokenMutationCreator::class); - + $authenticator = CreateTokenMutationCreator::singleton(); $response = $authenticator->resolve( null, - ['Email' => 'admin@silverstripe.com', 'Password' => 'wrong'], + ['Email' => 'anonymous'], [], new ResolveInfo([]) ); - $this->assertTrue($response instanceof Member); - $this->assertNull($response->Token); + $this->assertNull($response['Member']); + $this->assertNull($response['Token']); } } diff --git a/tests/unit/JWTAuthenticationHandlerTest.php b/tests/unit/JWTAuthenticationHandlerTest.php index b0b9221..33efd9a 100644 --- a/tests/unit/JWTAuthenticationHandlerTest.php +++ b/tests/unit/JWTAuthenticationHandlerTest.php @@ -1,15 +1,15 @@ member = $this->objFromFixture(Member::class, 'admin'); - $createToken = Injector::inst()->get(CreateTokenMutationCreator::class); + $createToken = CreateTokenMutationCreator::singleton(); $response = $createToken->resolve( null, @@ -35,22 +38,20 @@ public function setUp() new ResolveInfo([]) ); - $this->token = $response->Token; - } - - public function tearDown() - { - parent::tearDown(); + $this->token = $response['Token']; } + /** + * @throws Exception + */ public function testInvalidAuthenticateRequest() { Environment::putEnv('JWT_SIGNER_KEY=string'); - $request = new HTTPRequest('POST', Director::absoluteBaseURL() . '/graphql'); + $request = clone Controller::curr()->getRequest(); $request->addHeader('Authorization', 'Bearer ' . $this->token); - $handler = Injector::inst()->get(JWTAuthenticationHandler::class); + $handler = JWTAuthenticationHandler::singleton(); $result = $handler->authenticateRequest($request); Environment::putEnv('JWT_SIGNER_KEY=test_signer'); @@ -58,16 +59,18 @@ public function testInvalidAuthenticateRequest() $this->assertNull($result); } + /** + * @throws Exception + */ public function testAuthenticateRequest() { - $request = new HTTPRequest('POST', Director::absoluteBaseURL() . '/graphql'); + $request = clone Controller::curr()->getRequest(); $request->addHeader('Authorization', 'Bearer ' . $this->token); - $handler = Injector::inst()->get(JWTAuthenticationHandler::class); + $handler = JWTAuthenticationHandler::singleton(); $result = $handler->authenticateRequest($request); - $this->assertInstanceOf(Member::class, $result); - $this->assertGreaterThan(0, $result->ID); + $this->assertTrue($result->isInDB()); } } diff --git a/tests/unit/JWTAuthenticatorTest.php b/tests/unit/JWTAuthenticatorTest.php index 87eec13..96d50ba 100644 --- a/tests/unit/JWTAuthenticatorTest.php +++ b/tests/unit/JWTAuthenticatorTest.php @@ -1,16 +1,18 @@ member = $this->objFromFixture(Member::class, 'admin'); - $createToken = Injector::inst()->get(CreateTokenMutationCreator::class); - + $createToken = CreateTokenMutationCreator::singleton(); $response = $createToken->resolve( null, ['Email' => 'admin@silverstripe.com', 'Password' => 'error'], @@ -36,18 +40,16 @@ public function setUp() new ResolveInfo([]) ); - $this->token = $response->Token; - } - - public function tearDown() - { - parent::tearDown(); + $this->token = $response['Token']; } + /** + * @throws Exception + */ public function testValidToken() { - $authenticator = Injector::inst()->get(JWTAuthenticator::class); - $request = new HTTPRequest('POST', Director::absoluteBaseURL() . '/graphql'); + $authenticator = JWTAuthenticator::singleton(); + $request = clone Controller::curr()->getRequest(); $request->addHeader('Authorization', 'Bearer ' . $this->token); $result = $authenticator->authenticate(['token' => $this->token], $request); @@ -56,43 +58,54 @@ public function testValidToken() $this->assertEquals($this->member->ID, $result->ID); } + /** + * @throws Exception + */ public function testInvalidToken() { - Environment::putEnv('JWT_SIGNER_KEY=string'); + Environment::setEnv('JWT_SIGNER_KEY', 'string'); - $authenticator = Injector::inst()->get(JWTAuthenticator::class); - $request = new HTTPRequest('POST', Director::absoluteBaseURL() . '/graphql'); + $authenticator = JWTAuthenticator::singleton(); + $request = clone Controller::curr()->getRequest(); $request->addHeader('Authorization', 'Bearer ' . $this->token); $result = $authenticator->authenticate(['token' => $this->token], $request); $this->assertNotInstanceOf(Member::class, $result); - - Environment::putEnv('JWT_SIGNER_KEY=test_signer'); } + /** + * @throws Exception + */ public function testInvalidUniqueID() { - $authenticator = Injector::inst()->get(JWTAuthenticator::class); - $request = new HTTPRequest('POST', Director::absoluteBaseURL() . '/graphql'); + $authenticator = JWTAuthenticator::singleton(); + $request = clone Controller::curr()->getRequest(); $request->addHeader('Authorization', 'Bearer ' . $this->token); // Invalidate the Unique ID by making it something arbitrarily wrong + /** @var Member|MemberExtension $member */ $member = Member::get()->filter(['Email' => 'admin@silverstripe.com'])->first(); - $member->JWTUniqueID = 'make_error'; - $member->write(); - - $result = $authenticator->authenticate(['token' => $this->token], $request); + $member->DestroyAuthTokens(); + $validationResult = ValidationResult::create(); + $result = $authenticator->authenticate(['token' => $this->token], $request, $validationResult); + $this->assertFalse($validationResult->isValid()); + $this->assertNotEmpty($validationResult->getMessages()); + $this->assertEquals('Invalid token provided', $validationResult->getMessages()[TokenStatusEnum::STATUS_INVALID]['message']); $this->assertNull($result); } + /** + * @throws Exception + */ public function testRSAKey() { - Environment::putEnv('JWT_SIGNER_KEY=graphql-jwt/tests/keys/private.key'); - Environment::putEnv('JWT_PUBLIC_KEY=graphql-jwt/tests/keys/public.pub'); + $keys = realpath(__DIR__ . '/../keys'); + Environment::setEnv('JWT_SIGNER_KEY', "{$keys}/private.key"); + Environment::setEnv('JWT_PUBLIC_KEY', "{$keys}/public.pub"); - $createToken = Injector::inst()->get(CreateTokenMutationCreator::class); + $createToken = CreateTokenMutationCreator::singleton(); $response = $createToken->resolve( null, @@ -101,10 +114,10 @@ public function testRSAKey() new ResolveInfo([]) ); - $token = $response->Token; + $token = $response['Token']; - $authenticator = Injector::inst()->get(JWTAuthenticator::class); - $request = new HTTPRequest('POST', Director::absoluteBaseURL() . '/graphql'); + $authenticator = JWTAuthenticator::singleton(); + $request = clone Controller::curr()->getRequest(); $request->addHeader('Authorization', 'Bearer ' . $token); $result = $authenticator->authenticate(['token' => $token], $request); @@ -112,7 +125,7 @@ public function testRSAKey() $this->assertInstanceOf(Member::class, $result); $this->assertEquals($this->member->ID, $result->ID); - Environment::putEnv('JWT_SIGNER_KEY=test_signer'); + Environment::setEnv('JWT_SIGNER_KEY', 'test_signer'); // After changing the key to a string, the token should be invalid $result = $authenticator->authenticate(['token' => $token], $request); $this->assertNull($result); diff --git a/tests/unit/MemberExtensionTest.php b/tests/unit/MemberExtensionTest.php index 08b5cda..95e22cb 100644 --- a/tests/unit/MemberExtensionTest.php +++ b/tests/unit/MemberExtensionTest.php @@ -8,6 +8,7 @@ namespace Firesphere\GraphQLJWT\Tests; +use Firesphere\GraphQLJWT\Extensions\MemberExtension; use SilverStripe\Core\Config\Config; use SilverStripe\Core\Convert; use SilverStripe\Dev\SapphireTest; @@ -17,13 +18,9 @@ class MemberExtensionTest extends SapphireTest { protected static $fixture_file = '../fixtures/JWTAuthenticatorTest.yml'; - protected function setUp() - { - return parent::setUp(); - } - public function testMemberExists() { + /** @var Member|MemberExtension $member */ $member = $this->objFromFixture(Member::class, 'admin'); $data = $member->getJWTData(); @@ -34,7 +31,7 @@ public function testMemberExists() public function testExtraMemberData() { - /** @var Member $member */ + /** @var Member|MemberExtension $member */ $member = $this->objFromFixture(Member::class, 'admin'); $member->Surname = 'Member'; Config::modify()->set(Member::class, 'jwt_subject_fields', ['FirstName', 'Surname']); @@ -48,7 +45,9 @@ public function testExtraMemberData() public function testNoMember() { - $data = Member::create()->getJWTData(); + /** @var Member|MemberExtension $memberl */ + $memberl = Member::create(); + $data = $memberl->getJWTData(); $result = Convert::json2array($data); $this->assertEquals(0, $result['id']); diff --git a/tests/unit/RefreshTokenMutationCreatorTest.php b/tests/unit/RefreshTokenMutationCreatorTest.php index 211c7e7..3166672 100644 --- a/tests/unit/RefreshTokenMutationCreatorTest.php +++ b/tests/unit/RefreshTokenMutationCreatorTest.php @@ -2,18 +2,18 @@ namespace Firesphere\GraphQLJWT\Tests; +use Firesphere\GraphQLJWT\Authentication\AnonymousUserAuthenticator; use Firesphere\GraphQLJWT\Authentication\JWTAuthenticator; use Firesphere\GraphQLJWT\Mutations\CreateTokenMutationCreator; use Firesphere\GraphQLJWT\Mutations\RefreshTokenMutationCreator; use GraphQL\Type\Definition\ResolveInfo; use SilverStripe\Control\Controller; -use SilverStripe\Control\Director; -use SilverStripe\Control\HTTPRequest; use SilverStripe\Control\Session; use SilverStripe\Core\Config\Config; use SilverStripe\Core\Environment; use SilverStripe\Core\Injector\Injector; use SilverStripe\Dev\SapphireTest; +use SilverStripe\ORM\ValidationException; use SilverStripe\Security\Member; class RefreshTokenMutationCreatorTest extends SapphireTest @@ -26,32 +26,40 @@ class RefreshTokenMutationCreatorTest extends SapphireTest protected $anonymousToken; + /** + * @throws ValidationException + */ public function setUp() { - Environment::putEnv('JWT_SIGNER_KEY=test_signer'); + Environment::setENv('JWT_SIGNER_KEY', 'test_signer'); parent::setUp(); $this->member = $this->objFromFixture(Member::class, 'admin'); - $createToken = Injector::inst()->get(CreateTokenMutationCreator::class); + + // Enable anonymous authentication for this test + $createToken = CreateTokenMutationCreator::singleton(); + $createToken->setCustomAuthenticators([AnonymousUserAuthenticator::singleton()]); + // Requires to be an expired token Config::modify()->set(JWTAuthenticator::class, 'nbf_expiration', -5); + // Normal token $response = $createToken->resolve( null, ['Email' => 'admin@silverstripe.com', 'Password' => 'error'], [], new ResolveInfo([]) ); + $this->token = $response['Token']; - $this->token = $response->Token; + // Anonymous token $response = $createToken->resolve( null, - ['Email' => 'admin@silverstripe.com', 'Password' => 'notCorrect'], + ['Email' => 'anonymous'], [], new ResolveInfo([]) ); - - $this->anonymousToken = $response->Token; + $this->anonymousToken = $response['Token']; } public function tearDown() @@ -61,11 +69,8 @@ public function tearDown() private function buildRequest($anonymous = false) { - $token = $this->token; - if ($anonymous) { - $token = $this->anonymousToken; - } - $request = new HTTPRequest('POST', Director::absoluteBaseURL() . '/graphql'); + $token = $anonymous ? $this->anonymousToken : $this->token; + $request = clone Controller::curr()->getRequest(); $request->addHeader('Authorization', 'Bearer ' . $token); $request->setSession(new Session(['hello' => 'bye'])); // We need a session @@ -81,19 +86,18 @@ public function testRefreshToken() $queryCreator = Injector::inst()->get(RefreshTokenMutationCreator::class); $response = $queryCreator->resolve(null, [], [], new ResolveInfo([])); - $this->assertNotNull($response->Token); - $this->assertInstanceOf(Member::class, $response); + $this->assertNotNull($response['Token']); + $this->assertInstanceOf(Member::class, $response['Member']); } public function testAnonRefreshToken() { $this->buildRequest(true); - Config::modify()->set(JWTAuthenticator::class, 'anonymous_allowed', true); $queryCreator = Injector::inst()->get(RefreshTokenMutationCreator::class); $response = $queryCreator->resolve(null, [], [], new ResolveInfo([])); - $this->assertNotNull($response->Token); - $this->assertInstanceOf(Member::class, $response); + $this->assertNotNull($response['Token']); + $this->assertInstanceOf(Member::class, $response['Member']); } } diff --git a/tests/unit/ValidateTokenQueryCreatorTest.php b/tests/unit/ValidateTokenQueryCreatorTest.php index 2a60078..5a6482f 100644 --- a/tests/unit/ValidateTokenQueryCreatorTest.php +++ b/tests/unit/ValidateTokenQueryCreatorTest.php @@ -1,19 +1,19 @@ member = $this->objFromFixture(Member::class, 'admin'); - $createToken = Injector::inst()->get(CreateTokenMutationCreator::class); + $createToken = CreateTokenMutationCreator::singleton(); $response = $createToken->resolve( null, @@ -39,7 +42,7 @@ public function setUp() new ResolveInfo([]) ); - $this->token = $response->Token; + $this->token = $response['Token']; } public function tearDown() @@ -49,30 +52,36 @@ public function tearDown() private function buildRequest() { - $request = new HTTPRequest('POST', Director::absoluteBaseURL() . '/graphql'); + $request = clone Controller::curr()->getRequest(); $request->addHeader('Authorization', 'Bearer ' . $this->token); - $request->setSession(new Session(['hello' => 'bye'])); // We need a session Controller::curr()->setRequest($request); return $request; } + /** + * @throws Exception + */ public function testValidateToken() { $this->buildRequest(); - $queryCreator = Injector::inst()->get(ValidateTokenQueryCreator::class); + $queryCreator = ValidateTokenQueryCreator::singleton(); $response = $queryCreator->resolve(null, [], [], new ResolveInfo([])); $this->assertTrue($response['Valid']); } + /** + * @throws ValidationException + * @throws Exception + */ public function testExpiredToken() { Config::modify()->set(JWTAuthenticator::class, 'nbf_expiration', -5); - $createToken = Injector::inst()->get(CreateTokenMutationCreator::class); + $createToken = CreateTokenMutationCreator::singleton(); $response = $createToken->resolve( null, @@ -80,14 +89,16 @@ public function testExpiredToken() [], new ResolveInfo([]) ); - $this->token = $response->Token; + $this->token = $response['Token']; $this->buildRequest(); - $queryCreator = Injector::inst()->get(ValidateTokenQueryCreator::class); + $queryCreator = ValidateTokenQueryCreator::singleton(); $response = $queryCreator->resolve(null, [], [], new ResolveInfo([])); $this->assertFalse($response['Valid']); - $this->assertContains('Token is expired', $response['Message']); + $this->assertEquals(TokenStatusEnum::STATUS_EXPIRED, $response['Status']); + $this->assertEquals(401, $response['Code']); + $this->assertEquals('Token is expired, please renew your token with a refreshToken query', $response['Message']); } } From d6c5efbb29147d664de9e324ca5a51c5fccd2b1f Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Thu, 18 Apr 2019 18:11:20 +1200 Subject: [PATCH 03/11] I put Message on the wrong type --- src/Types/MemberTokenTypeCreator.php | 11 ++++++----- src/Types/MemberTypeCreator.php | 1 - 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Types/MemberTokenTypeCreator.php b/src/Types/MemberTokenTypeCreator.php index 17b7216..3a4b2c6 100644 --- a/src/Types/MemberTokenTypeCreator.php +++ b/src/Types/MemberTokenTypeCreator.php @@ -21,11 +21,12 @@ public function attributes(): array public function fields(): array { return [ - 'Valid' => ['type' => Type::boolean()], - 'Member' => ['type' => $this->manager->getType('Member')], - 'Token' => ['type' => Type::string()], - 'Status' => ['type' => TokenStatusEnum::instance()], - 'Code' => ['type' => Type::int()], + 'Valid' => ['type' => Type::boolean()], + 'Member' => ['type' => $this->manager->getType('Member')], + 'Token' => ['type' => Type::string()], + 'Status' => ['type' => TokenStatusEnum::instance()], + 'Code' => ['type' => Type::int()], + 'Message' => ['type' => Type::string()], ]; } } diff --git a/src/Types/MemberTypeCreator.php b/src/Types/MemberTypeCreator.php index 0864170..f6c1b07 100644 --- a/src/Types/MemberTypeCreator.php +++ b/src/Types/MemberTypeCreator.php @@ -23,7 +23,6 @@ public function fields(): array 'Surname' => ['type' => Type::string()], 'Email' => ['type' => Type::string()], 'Token' => ['type' => Type::string()], - 'Message' => ['type' => Type::string()], ]; } } From c9f5bf6db1d1a53e0b98b0cfa410603942c17e1d Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Thu, 18 Apr 2019 18:14:53 +1200 Subject: [PATCH 04/11] Bump major version --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 017fec3..52aa6c2 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,7 @@ }, "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "2.x-dev" }, "installer-name": "graphql-jwt" }, From aa1953e4a0b0f2aa5e4d354a5a5ebcacd5d3c30a Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Wed, 24 Apr 2019 17:30:28 +1200 Subject: [PATCH 05/11] Restore tight docblocks --- src/Authentication/AnonymousUserAuthenticator.php | 4 ++-- src/Authentication/JWTAuthenticationHandler.php | 4 ++-- src/Authentication/JWTAuthenticator.php | 8 ++++---- src/Helpers/RequiresConfig.php | 2 +- src/Mutations/CreateTokenMutationCreator.php | 8 ++++---- src/Mutations/RefreshTokenMutationCreator.php | 6 +++--- src/Queries/ValidateTokenQueryCreator.php | 6 +++--- 7 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/Authentication/AnonymousUserAuthenticator.php b/src/Authentication/AnonymousUserAuthenticator.php index 9515e98..90cc036 100644 --- a/src/Authentication/AnonymousUserAuthenticator.php +++ b/src/Authentication/AnonymousUserAuthenticator.php @@ -53,9 +53,9 @@ public function authenticate(array $data, HTTPRequest $request, ValidationResult * Attempt to find and authenticate member if possible from the given data * * @skipUpgrade - * @param array $data Form submitted data + * @param array $data Form submitted data * @param ValidationResult $result - * @param Member $member This third parameter is used in the CMSAuthenticator(s) + * @param Member $member This third parameter is used in the CMSAuthenticator(s) * @return Member Found member, regardless of successful login * @throws ValidationException */ diff --git a/src/Authentication/JWTAuthenticationHandler.php b/src/Authentication/JWTAuthenticationHandler.php index 54b0376..a558258 100644 --- a/src/Authentication/JWTAuthenticationHandler.php +++ b/src/Authentication/JWTAuthenticationHandler.php @@ -56,8 +56,8 @@ public function authenticateRequest(HTTPRequest $request): ?Member * Authenticate on every run, based on the header, not relying on sessions or cookies * JSON Web Tokens are stateless * - * @param Member $member - * @param bool $persistent + * @param Member $member + * @param bool $persistent * @param HTTPRequest|null $request */ public function logIn(Member $member, $persistent = false, HTTPRequest $request = null): void diff --git a/src/Authentication/JWTAuthenticator.php b/src/Authentication/JWTAuthenticator.php index 5995b98..8d200e0 100644 --- a/src/Authentication/JWTAuthenticator.php +++ b/src/Authentication/JWTAuthenticator.php @@ -133,8 +133,8 @@ public function supportedServices(): int } /** - * @param array $data - * @param HTTPRequest $request + * @param array $data + * @param HTTPRequest $request * @param ValidationResult|null $result * @return Member|null * @throws OutOfBoundsException @@ -168,7 +168,7 @@ public function authenticate(array $data, HTTPRequest $request, ValidationResult /** * Generate a new JWT token for a given request, and optional (if anonymous_allowed) user * - * @param HTTPRequest $request + * @param HTTPRequest $request * @param Member|MemberExtension $member * @return Token * @throws ValidationException @@ -217,7 +217,7 @@ public function generateToken(HTTPRequest $request, Member $member): Token } /** - * @param string $token + * @param string $token * @param HTTPRequest $request * @return array Array with JWTRecord and int status (STATUS_*) * @throws BadMethodCallException diff --git a/src/Helpers/RequiresConfig.php b/src/Helpers/RequiresConfig.php index e1d13c8..0f9555b 100644 --- a/src/Helpers/RequiresConfig.php +++ b/src/Helpers/RequiresConfig.php @@ -11,7 +11,7 @@ trait RequiresConfig * Get an environment value. If $default is not set and the environment isn't set either this will error. * * @param string $key - * @param mixed $default + * @param mixed $default * @throws LogicException Error if environment variable is required, but not configured * @return mixed */ diff --git a/src/Mutations/CreateTokenMutationCreator.php b/src/Mutations/CreateTokenMutationCreator.php index 8ddfac4..11f31b0 100644 --- a/src/Mutations/CreateTokenMutationCreator.php +++ b/src/Mutations/CreateTokenMutationCreator.php @@ -74,9 +74,9 @@ public function args(): array } /** - * @param mixed $object - * @param array $args - * @param mixed $context + * @param mixed $object + * @param array $args + * @param mixed $context * @param ResolveInfo $info * @return array * @throws NotFoundExceptionInterface @@ -102,7 +102,7 @@ public function resolve($object, array $args, $context, ResolveInfo $info): arra /** * Get an authenticated member from the given request * - * @param array $args + * @param array $args * @param HTTPRequest $request * @return Member|MemberExtension */ diff --git a/src/Mutations/RefreshTokenMutationCreator.php b/src/Mutations/RefreshTokenMutationCreator.php index 49b7f0a..022aa7e 100644 --- a/src/Mutations/RefreshTokenMutationCreator.php +++ b/src/Mutations/RefreshTokenMutationCreator.php @@ -40,9 +40,9 @@ public function type(): Type } /** - * @param mixed $object - * @param array $args - * @param mixed $context + * @param mixed $object + * @param array $args + * @param mixed $context * @param ResolveInfo $info * @return array * @throws NotFoundExceptionInterface diff --git a/src/Queries/ValidateTokenQueryCreator.php b/src/Queries/ValidateTokenQueryCreator.php index 43c4b0c..c819d93 100644 --- a/src/Queries/ValidateTokenQueryCreator.php +++ b/src/Queries/ValidateTokenQueryCreator.php @@ -45,9 +45,9 @@ public function type(): Type } /** - * @param mixed $object - * @param array $args - * @param mixed $context + * @param mixed $object + * @param array $args + * @param mixed $context * @param ResolveInfo $info * @return array * @throws NotFoundExceptionInterface From 9e6684f6761c8e5bd2fee81718dd83ee652d1458 Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Wed, 24 Apr 2019 17:44:24 +1200 Subject: [PATCH 06/11] Move anonymous user generation to a factory --- _config/anonymous.yml | 7 +++ .../AnonymousUserAuthenticator.php | 43 ++------------- src/Authentication/AnonymousUserFactory.php | 52 +++++++++++++++++++ 3 files changed, 62 insertions(+), 40 deletions(-) create mode 100644 _config/anonymous.yml create mode 100644 src/Authentication/AnonymousUserFactory.php diff --git a/_config/anonymous.yml b/_config/anonymous.yml new file mode 100644 index 0000000..cbe8eac --- /dev/null +++ b/_config/anonymous.yml @@ -0,0 +1,7 @@ +--- +Name: firesphere-jwt-anonymous +--- +SilverStripe\Core\Injector\Injector: + SilverStripe\Security\Member.anonymous: + class: SilverStripe\Security\Member + factory: Firesphere\GraphQLJWT\Authentication\AnonymousUserFactory diff --git a/src/Authentication/AnonymousUserAuthenticator.php b/src/Authentication/AnonymousUserAuthenticator.php index 90cc036..ba5956c 100644 --- a/src/Authentication/AnonymousUserAuthenticator.php +++ b/src/Authentication/AnonymousUserAuthenticator.php @@ -6,6 +6,7 @@ use SilverStripe\Control\HTTPRequest; use SilverStripe\Core\Config\Configurable; use SilverStripe\Core\Injector\Injectable; +use SilverStripe\Core\Injector\Injector; use SilverStripe\ORM\ValidationException; use SilverStripe\ORM\ValidationResult; use SilverStripe\Security\Authenticator; @@ -24,15 +25,6 @@ class AnonymousUserAuthenticator extends MemberAuthenticator */ private static $anonymous_username = 'anonymous'; - /** - * Default field values to assign to anonymous user - * - * @var array - */ - private static $anonymous_fields = [ - 'FirstName' => 'Anonymous', - ]; - public function supportedServices(): int { return Authenticator::LOGIN | Authenticator::LOGOUT; @@ -62,7 +54,8 @@ public function authenticate(array $data, HTTPRequest $request, ValidationResult protected function authenticateMember($data, ValidationResult &$result = null, Member $member = null): Member { // Get user, or create if not exists - $member = $this->getOrCreateAnonymousMember(); + $username = static::config()->get('anonymous_username'); + $member = Injector::inst()->get(Member::class . '.anonymous', true, ['username' => $username]); // Validate this member is still allowed to login $result = $result ?: ValidationResult::create(); @@ -82,34 +75,4 @@ public function checkPassword(Member $member, $password, ValidationResult &$resu { throw new BadMethodCallException("checkPassword not supported for anonymous users"); } - - /** - * Build a new datarecord to contain the anonymous user - * - * @return Member - * @throws ValidationException - */ - protected function getOrCreateAnonymousMember() - { - // Fun facts about anonymous members - $identifierField = Member::config()->get('unique_identifier_field'); - $username = static::config()->get('anonymous_username'); - $fields = static::config()->get('anonymous_fields'); - - // Find existing member - /** @var Member $member */ - $member = Member::get()->find($identifierField, $username); - $this->extend('updateExistingAnonymousMember', $member); - if ($member) { - return $member; - } - - // Create new member - $member = Member::create(); - $member->{$identifierField} = $username; - $member->update($fields); - $this->extend('updateCreatedAnonymousMember', $member); - $member->write(); - return $member; - } } diff --git a/src/Authentication/AnonymousUserFactory.php b/src/Authentication/AnonymousUserFactory.php new file mode 100644 index 0000000..776ae22 --- /dev/null +++ b/src/Authentication/AnonymousUserFactory.php @@ -0,0 +1,52 @@ + 'Anonymous', + ]; + + /** + * Creates a new service instance. + * + * @param string $service The class name of the service. + * @param array $params The constructor parameters. + * @return Member The member that was created + * @throws ValidationException + */ + public function create($service, array $params = array()) + { + // In case we configure multiple users + $username = $params['username'] ?? 'anonymous'; + $identifierField = Member::config()->get('unique_identifier_field'); + $fields = static::config()->get('anonymous_fields'); + + // Find existing member + /** @var Member $member */ + $member = Member::get()->find($identifierField, $username); + if ($member) { + return $member; + } + + // Create new member + $member = Member::create(); + $member->{$identifierField} = $username; + $member->update($fields); + $member->write(); + return $member; + } +} From 96e3c8c09e2d05b750ae53daca1b043e5457699c Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Wed, 24 Apr 2019 18:23:53 +1200 Subject: [PATCH 07/11] Refactor key generation code --- .../AnonymousUserAuthenticator.php | 2 - src/Authentication/AnonymousUserFactory.php | 2 +- .../JWTAuthenticationHandler.php | 2 +- src/Authentication/JWTAuthenticator.php | 125 ++++++++++++++---- src/Extensions/MemberExtension.php | 5 +- src/Helpers/MemberTokenGenerator.php | 2 +- src/Helpers/PathResolver.php | 22 --- tests/unit/JWTAuthenticatorTest.php | 2 +- 8 files changed, 103 insertions(+), 59 deletions(-) delete mode 100644 src/Helpers/PathResolver.php diff --git a/src/Authentication/AnonymousUserAuthenticator.php b/src/Authentication/AnonymousUserAuthenticator.php index ba5956c..55c9651 100644 --- a/src/Authentication/AnonymousUserAuthenticator.php +++ b/src/Authentication/AnonymousUserAuthenticator.php @@ -7,7 +7,6 @@ use SilverStripe\Core\Config\Configurable; use SilverStripe\Core\Injector\Injectable; use SilverStripe\Core\Injector\Injector; -use SilverStripe\ORM\ValidationException; use SilverStripe\ORM\ValidationResult; use SilverStripe\Security\Authenticator; use SilverStripe\Security\Member; @@ -49,7 +48,6 @@ public function authenticate(array $data, HTTPRequest $request, ValidationResult * @param ValidationResult $result * @param Member $member This third parameter is used in the CMSAuthenticator(s) * @return Member Found member, regardless of successful login - * @throws ValidationException */ protected function authenticateMember($data, ValidationResult &$result = null, Member $member = null): Member { diff --git a/src/Authentication/AnonymousUserFactory.php b/src/Authentication/AnonymousUserFactory.php index 776ae22..7429f8a 100644 --- a/src/Authentication/AnonymousUserFactory.php +++ b/src/Authentication/AnonymousUserFactory.php @@ -44,7 +44,7 @@ public function create($service, array $params = array()) // Create new member $member = Member::create(); - $member->{$identifierField} = $username; + $member->__set($identifierField, $username); $member->update($fields); $member->write(); return $member; diff --git a/src/Authentication/JWTAuthenticationHandler.php b/src/Authentication/JWTAuthenticationHandler.php index a558258..0394e61 100644 --- a/src/Authentication/JWTAuthenticationHandler.php +++ b/src/Authentication/JWTAuthenticationHandler.php @@ -75,7 +75,7 @@ public function logOut(HTTPRequest $request = null): void /** @var Member|MemberExtension $member */ $member = Security::getCurrentUser(); if ($member) { - $member->DestroyAuthTokens(); + $member->destroyAuthTokens(); } Security::setCurrentUser(null); diff --git a/src/Authentication/JWTAuthenticator.php b/src/Authentication/JWTAuthenticator.php index 8d200e0..01c37f2 100644 --- a/src/Authentication/JWTAuthenticator.php +++ b/src/Authentication/JWTAuthenticator.php @@ -7,7 +7,6 @@ use Exception; use Firesphere\GraphQLJWT\Extensions\MemberExtension; use Firesphere\GraphQLJWT\Helpers\MemberTokenGenerator; -use Firesphere\GraphQLJWT\Helpers\PathResolver; use Firesphere\GraphQLJWT\Helpers\RequiresConfig; use Firesphere\GraphQLJWT\Model\JWTRecord; use Lcobucci\JWT\Builder; @@ -23,7 +22,6 @@ use SilverStripe\Control\Director; use SilverStripe\Control\HTTPRequest; use SilverStripe\Core\Config\Configurable; -use SilverStripe\Core\Environment; use SilverStripe\Core\Injector\Injectable; use SilverStripe\ORM\FieldType\DBDatetime; use SilverStripe\ORM\ValidationException; @@ -39,6 +37,27 @@ class JWTAuthenticator extends MemberAuthenticator use RequiresConfig; use MemberTokenGenerator; + const JWT_SIGNER_KEY = 'JWT_SIGNER_KEY'; + + const JWT_KEY_PASSWORD = 'JWT_KEY_PASSWORD'; + + const JWT_PUBLIC_KEY = 'JWT_PUBLIC_KEY'; + + /** + * Key is RSA public/private pair + */ + const RSA = 'RSA'; + + /** + * Key is RSA public/private pair, with password enabled + */ + const RSA_PASSWORD = 'RSA_PASSWORD'; + + /** + * Key is HMAC string + */ + const HMAC = 'HMAC'; + /** * Set to true to allow anonymous JWT tokens (no member record / email / password) * @@ -69,57 +88,91 @@ class JWTAuthenticator extends MemberAuthenticator */ private static $nbf_refresh_expiration = 604800; + /** + * Keys are one of: + * - public / private RSA pair files + * - public / private RSA pair files, password protected private key + * - private HMAC string + * + * @return string + */ + protected function getKeyType(): string + { + $signerKey = $this->getEnv(self::JWT_SIGNER_KEY); + $path = $this->resolvePath($signerKey); + if (!$path) { + return self::HMAC; + } + if ($this->getEnv(self::JWT_KEY_PASSWORD, null)) { + return self::RSA_PASSWORD; + } + return self::RSA; + } + /** * @return Signer */ protected function getSigner(): Signer { - $signerKey = $this->getEnv('JWT_SIGNER_KEY'); - if (PathResolver::resolve($signerKey)) { - return new Rsa\Sha256(); - } else { - return new Hmac\Sha256(); + switch ($this->getKeyType()) { + case self::HMAC: + return new Hmac\Sha256(); + case self::RSA: + case self::RSA_PASSWORD: + default: + return new Rsa\Sha256(); } } /** - * Get private key + * Get private key used to generate JWT tokens * * @return Key */ protected function getPrivateKey(): Key { - $signerKey = $this->getEnv('JWT_SIGNER_KEY'); - $signerPath = PathResolver::resolve($signerKey); - if ($signerPath) { - $password = $this->getEnv('JWT_KEY_PASSWORD', null); - return new Key('file://' . $signerPath, $password); - } - return new Key($signerKey); + // Note: Only private key has password enabled + $password = $this->getEnv(self::JWT_KEY_PASSWORD, null); + return $this->makeKey(self::JWT_SIGNER_KEY, $password); } /** - * Get public key + * Get public key used to validate JWT tokens * * @return Key * @throws LogicException */ - private function getPublicKey(): Key + protected function getPublicKey(): Key { - $signerKey = Environment::getEnv('JWT_SIGNER_KEY'); - $signerPath = PathResolver::resolve($signerKey); - // If it's a private key, we also need a public key for validation! - if (empty($signerPath)) { - return new Key($signerKey); + switch ($this->getKeyType()) { + case self::HMAC: + // If signer key is a HMAC string instead of a path, public key == private key + return $this->getPrivateKey(); + default: + // If signer key is a path to RSA token, then we require a separate public key path + return $this->makeKey(self::JWT_PUBLIC_KEY); } + } + + /** + * Construct a new key from the named config variable + * + * @param string $name Key name + * @param string|null $password Optional password + * @return Key + */ + private function makeKey(string $name, string $password = null): Key + { + $key = $this->getEnv($name); + $path = $this->resolvePath($key); - // Ensure public key exists - $publicKey = Environment::getEnv('JWT_PUBLIC_KEY'); - $publicPath = PathResolver::resolve($publicKey); - if (empty($publicPath)) { - throw new LogicException('JWT_PUBLIC_KEY path does not exist'); + // String key + if (empty($path)) { + return new Key($path); } - return new Key('file://' . $publicPath); + + // Build key from path + return new Key('file://' . $path, $password); } /** @@ -282,4 +335,20 @@ public function validateToken(string $token, HTTPrequest $request): array // If expired and cannot be renewed, it's dead return [$record, TokenStatusEnum::STATUS_DEAD]; } + + /** + * Return an absolute path from a relative one + * If the path doesn't exist, returns null + * + * @param string $path + * @param string $base + * @return string|null + */ + protected function resolvePath(string $path, string $base = BASE_PATH): ?string + { + if (strstr($path, '/') !== 0) { + $path = $base . '/' . $path; + } + return realpath($path) ?: null; + } } diff --git a/src/Extensions/MemberExtension.php b/src/Extensions/MemberExtension.php index 4525b46..a1ea7a1 100644 --- a/src/Extensions/MemberExtension.php +++ b/src/Extensions/MemberExtension.php @@ -3,7 +3,6 @@ namespace Firesphere\GraphQLJWT\Extensions; use Firesphere\GraphQLJWT\Model\JWTRecord; -use SilverStripe\Core\Convert; use SilverStripe\Forms\FieldList; use SilverStripe\ORM\DataExtension; use SilverStripe\ORM\HasManyList; @@ -62,7 +61,7 @@ public function getJWTData(): string } } - return Convert::raw2json($data); + return json_encode($data); } /** @@ -70,7 +69,7 @@ public function getJWTData(): string * * @return Member */ - public function DestroyAuthTokens(): Member + public function destroyAuthTokens(): Member { foreach ($this->owner->AuthTokens() as $token) { $token->delete(); diff --git a/src/Helpers/MemberTokenGenerator.php b/src/Helpers/MemberTokenGenerator.php index 03bc8d7..5d5c06e 100644 --- a/src/Helpers/MemberTokenGenerator.php +++ b/src/Helpers/MemberTokenGenerator.php @@ -50,7 +50,7 @@ public function getErrorMessage(string $status): string protected function generateResponse(string $status, Member $member = null, string $token = null): array { // Success response - if ($status == TokenStatusEnum::STATUS_OK) { + if ($status === TokenStatusEnum::STATUS_OK) { $response = [ 'Valid' => true, 'Member' => $member && $member->exists() ? $member : null, diff --git a/src/Helpers/PathResolver.php b/src/Helpers/PathResolver.php deleted file mode 100644 index 1464b49..0000000 --- a/src/Helpers/PathResolver.php +++ /dev/null @@ -1,22 +0,0 @@ -filter(['Email' => 'admin@silverstripe.com'])->first(); - $member->DestroyAuthTokens(); + $member->destroyAuthTokens(); $validationResult = ValidationResult::create(); $result = $authenticator->authenticate(['token' => $this->token], $request, $validationResult); From 246d2ee4a89cee0e5bcf9fb79cee58c803f504f9 Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Wed, 24 Apr 2019 18:32:51 +1200 Subject: [PATCH 08/11] Remove unused classes Merge single-use traits into parent classes for simplicity Add DB index Cleanup phpcs violations --- src/Authentication/JWTAuthenticator.php | 24 ++++++++++++++-- src/Exceptions/JWTException.php | 9 ------ src/Helpers/RequiresConfig.php | 29 -------------------- src/Model/JWTRecord.php | 10 ++++++- src/Mutations/CreateTokenMutationCreator.php | 2 +- tests/unit/JWTAuthenticatorTest.php | 5 +++- tests/unit/ValidateTokenQueryCreatorTest.php | 5 +++- 7 files changed, 40 insertions(+), 44 deletions(-) delete mode 100644 src/Exceptions/JWTException.php delete mode 100644 src/Helpers/RequiresConfig.php diff --git a/src/Authentication/JWTAuthenticator.php b/src/Authentication/JWTAuthenticator.php index 01c37f2..9a0f3b1 100644 --- a/src/Authentication/JWTAuthenticator.php +++ b/src/Authentication/JWTAuthenticator.php @@ -7,7 +7,6 @@ use Exception; use Firesphere\GraphQLJWT\Extensions\MemberExtension; use Firesphere\GraphQLJWT\Helpers\MemberTokenGenerator; -use Firesphere\GraphQLJWT\Helpers\RequiresConfig; use Firesphere\GraphQLJWT\Model\JWTRecord; use Lcobucci\JWT\Builder; use Lcobucci\JWT\Parser; @@ -22,6 +21,7 @@ use SilverStripe\Control\Director; use SilverStripe\Control\HTTPRequest; use SilverStripe\Core\Config\Configurable; +use SilverStripe\Core\Environment; use SilverStripe\Core\Injector\Injectable; use SilverStripe\ORM\FieldType\DBDatetime; use SilverStripe\ORM\ValidationException; @@ -34,7 +34,6 @@ class JWTAuthenticator extends MemberAuthenticator { use Injectable; use Configurable; - use RequiresConfig; use MemberTokenGenerator; const JWT_SIGNER_KEY = 'JWT_SIGNER_KEY'; @@ -351,4 +350,25 @@ protected function resolvePath(string $path, string $base = BASE_PATH): ?string } return realpath($path) ?: null; } + + + /** + * Get an environment value. If $default is not set and the environment isn't set either this will error. + * + * @param string $key + * @param string|null $default + * @throws LogicException Error if environment variable is required, but not configured + * @return string|null + */ + protected function getEnv(string $key, $default = null): ?string + { + $value = Environment::getEnv($key); + if ($value) { + return $value; + } + if (func_num_args() === 1) { + throw new LogicException("Required environment variable {$key} not set"); + } + return $default; + } } diff --git a/src/Exceptions/JWTException.php b/src/Exceptions/JWTException.php deleted file mode 100644 index 0956e6b..0000000 --- a/src/Exceptions/JWTException.php +++ /dev/null @@ -1,9 +0,0 @@ - 'Varchar(255)', @@ -22,4 +23,11 @@ class JWTRecord extends DataObject private static $has_one = [ 'Member' => Member::class, ]; + + private static $indexes = [ + 'UID' => [ + 'type' => DBIndexable::TYPE_UNIQUE, + 'columns' => ['UID'], + ], + ]; } diff --git a/src/Mutations/CreateTokenMutationCreator.php b/src/Mutations/CreateTokenMutationCreator.php index 11f31b0..beec8b8 100644 --- a/src/Mutations/CreateTokenMutationCreator.php +++ b/src/Mutations/CreateTokenMutationCreator.php @@ -110,7 +110,7 @@ protected function getAuthenticatedMember(array $args, HTTPRequest $request): ?M { // Login with authenticators foreach ($this->getLoginAuthenticators() as $authenticator) { - $result = new ValidationResult(); + $result = ValidationResult::create(); $member = $authenticator->authenticate($args, $request, $result); if ($member && $result->isValid()) { return $member; diff --git a/tests/unit/JWTAuthenticatorTest.php b/tests/unit/JWTAuthenticatorTest.php index 684d3f2..06a9d1f 100644 --- a/tests/unit/JWTAuthenticatorTest.php +++ b/tests/unit/JWTAuthenticatorTest.php @@ -92,7 +92,10 @@ public function testInvalidUniqueID() $result = $authenticator->authenticate(['token' => $this->token], $request, $validationResult); $this->assertFalse($validationResult->isValid()); $this->assertNotEmpty($validationResult->getMessages()); - $this->assertEquals('Invalid token provided', $validationResult->getMessages()[TokenStatusEnum::STATUS_INVALID]['message']); + $this->assertEquals( + 'Invalid token provided', + $validationResult->getMessages()[TokenStatusEnum::STATUS_INVALID]['message'] + ); $this->assertNull($result); } diff --git a/tests/unit/ValidateTokenQueryCreatorTest.php b/tests/unit/ValidateTokenQueryCreatorTest.php index 5a6482f..b3914ff 100644 --- a/tests/unit/ValidateTokenQueryCreatorTest.php +++ b/tests/unit/ValidateTokenQueryCreatorTest.php @@ -99,6 +99,9 @@ public function testExpiredToken() $this->assertFalse($response['Valid']); $this->assertEquals(TokenStatusEnum::STATUS_EXPIRED, $response['Status']); $this->assertEquals(401, $response['Code']); - $this->assertEquals('Token is expired, please renew your token with a refreshToken query', $response['Message']); + $this->assertEquals( + 'Token is expired, please renew your token with a refreshToken query', + $response['Message'] + ); } } From 8fd6b877d78de4d520a9eb566a757e017bbb8ea5 Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Fri, 26 Apr 2019 08:48:15 +1200 Subject: [PATCH 09/11] Remove branch limits to circleci --- .circleci/config.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index d3a84c8..2b9682a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -25,12 +25,6 @@ jobs: working_directory: ~/var/www - branches: - only: - - master - - develop - - /feature.*/ - steps: - run: composer create-project firesphere/graphql-jwt:dev-master ~/var/www -n - run: vendor/bin/sake dev/build From 2548b7354d9e2340320f3487409a6bbe4459505d Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Mon, 29 Apr 2019 09:47:38 +1200 Subject: [PATCH 10/11] Break up large method to appease codeclimate --- src/Authentication/JWTAuthenticator.php | 116 +++++++++++++++++------- src/Helpers/MemberTokenGenerator.php | 28 ++---- 2 files changed, 92 insertions(+), 52 deletions(-) diff --git a/src/Authentication/JWTAuthenticator.php b/src/Authentication/JWTAuthenticator.php index 9a0f3b1..fc23f3e 100644 --- a/src/Authentication/JWTAuthenticator.php +++ b/src/Authentication/JWTAuthenticator.php @@ -276,46 +276,20 @@ public function generateToken(HTTPRequest $request, Member $member): Token */ public function validateToken(string $token, HTTPrequest $request): array { - // Ensure token given at all - if (!$token) { - return [null, TokenStatusEnum::STATUS_INVALID]; - } - // Parse token - $parser = new Parser(); - try { - $parsedToken = $parser->parse($token); - } catch (Exception $ex) { - // Un-parsable tokens are invalid + $parsedToken = $this->parseToken($token); + if ($parsedToken) { return [null, TokenStatusEnum::STATUS_INVALID]; } - // Validate token against Id and user-agent - $userAgent = $request->getHeader('User-Agent'); - /** @var JWTRecord $record */ - $record = JWTRecord::get() - ->filter(['UserAgent' => $userAgent]) - ->byID($parsedToken->getClaim('rid')); + // Find local record for this token + $record = $this->findTokenRecord($parsedToken, $request); if (!$record) { return [null, TokenStatusEnum::STATUS_INVALID]; } - // Get validator for this token - $now = DBDatetime::now()->getTimestamp(); - $validator = new ValidationData(); - $validator->setIssuer($request->getHeader('Origin')); - $validator->setAudience(Director::absoluteBaseURL()); - $validator->setId($record->UID); - $validator->setCurrentTime($now); - $verified = $parsedToken->verify($this->getSigner(), $this->getPublicKey()); - $valid = $parsedToken->validate($validator); - - // If unverified, break - if (!$verified) { - return [$record, TokenStatusEnum::STATUS_INVALID]; - } - // Verified and valid = ok! + $valid = $this->validateParsedToken($parsedToken, $request, $record); if ($valid) { return [$record, TokenStatusEnum::STATUS_OK]; } @@ -326,8 +300,8 @@ public function validateToken(string $token, HTTPrequest $request): array } // If expired, check if it can be renewed - $renewBefore = $parsedToken->getClaim('rexp'); - if ($renewBefore > $now) { + $canReniew = $this->canTokenBeRenewed($parsedToken); + if ($canReniew) { return [$record, TokenStatusEnum::STATUS_EXPIRED]; } @@ -335,6 +309,82 @@ public function validateToken(string $token, HTTPrequest $request): array return [$record, TokenStatusEnum::STATUS_DEAD]; } + /** + * Parse a string into a token + * + * @param string $token + * @return Token|null + */ + protected function parseToken(string $token): ?Token + { + // Ensure token given at all + if (!$token) { + return null; + } + + try { + // Verify parsed token matches signer + $parser = new Parser(); + $parsedToken = $parser->parse($token); + } catch (Exception $ex) { + // Un-parsable tokens are invalid + return null; + } + + // Verify this token with configured keys + $verified = $parsedToken->verify($this->getSigner(), $this->getPublicKey()); + return $verified ? $parsedToken : null; + } + + /** + * Given a parsed Token, find the matching JWTRecord dataobject + * + * @param Token $parsedToken + * @param HTTPRequest $request + * @return JWTRecord|null + */ + protected function findTokenRecord(Token $parsedToken, HTTPrequest $request): ?JWTRecord + { + $userAgent = $request->getHeader('User-Agent'); + /** @var JWTRecord $record */ + $record = JWTRecord::get() + ->filter(['UserAgent' => $userAgent]) + ->byID($parsedToken->getClaim('rid')); + return $record; + } + + /** + * Determine if the given token is current, given the context of the current request + * + * @param Token $parsedToken + * @param HTTPRequest $request + * @param JWTRecord $record + * @return bool + */ + protected function validateParsedToken(Token $parsedToken, HTTPrequest $request, JWTRecord $record): bool + { + $now = DBDatetime::now()->getTimestamp(); + $validator = new ValidationData(); + $validator->setIssuer($request->getHeader('Origin')); + $validator->setAudience(Director::absoluteBaseURL()); + $validator->setId($record->UID); + $validator->setCurrentTime($now); + return $parsedToken->validate($validator); + } + + /** + * Check if the given token can be renewed + * + * @param Token $parsedToken + * @return bool + */ + protected function canTokenBeRenewed(Token $parsedToken): bool + { + $renewBefore = $parsedToken->getClaim('rexp'); + $now = DBDatetime::now()->getTimestamp(); + return $renewBefore > $now; + } + /** * Return an absolute path from a relative one * If the path doesn't exist, returns null diff --git a/src/Helpers/MemberTokenGenerator.php b/src/Helpers/MemberTokenGenerator.php index 5d5c06e..8ce4a9c 100644 --- a/src/Helpers/MemberTokenGenerator.php +++ b/src/Helpers/MemberTokenGenerator.php @@ -50,25 +50,15 @@ public function getErrorMessage(string $status): string protected function generateResponse(string $status, Member $member = null, string $token = null): array { // Success response - if ($status === TokenStatusEnum::STATUS_OK) { - $response = [ - 'Valid' => true, - 'Member' => $member && $member->exists() ? $member : null, - 'Token' => $token, - 'Status' => $status, - 'Code' => 200, - 'Message' => $this->getErrorMessage($status), - ]; - } else { - $response = [ - 'Valid' => false, - 'Member' => null, - 'Token' => $token, - 'Status' => $status, - 'Code' => 401, - 'Message' => $this->getErrorMessage($status), - ]; - } + $valid = $status === TokenStatusEnum::STATUS_OK; + $response = [ + 'Valid' => $valid, + 'Member' => $valid && $member && $member->exists() ? $member : null, + 'Token' => $token, + 'Status' => $status, + 'Code' => $valid ? 200 : 401, + 'Message' => $this->getErrorMessage($status), + ]; $this->extend('updateMemberToken', $response); return $response; From f2b6ecb373dcb8984b78b244f7796edbf673f2d4 Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Tue, 30 Apr 2019 09:51:21 +1200 Subject: [PATCH 11/11] Remove breakage on user-agent changing --- src/Authentication/JWTAuthenticator.php | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/src/Authentication/JWTAuthenticator.php b/src/Authentication/JWTAuthenticator.php index fc23f3e..4d3b31b 100644 --- a/src/Authentication/JWTAuthenticator.php +++ b/src/Authentication/JWTAuthenticator.php @@ -283,7 +283,8 @@ public function validateToken(string $token, HTTPrequest $request): array } // Find local record for this token - $record = $this->findTokenRecord($parsedToken, $request); + /** @var JWTRecord $record */ + $record = JWTRecord::get()->byID($parsedToken->getClaim('rid')); if (!$record) { return [null, TokenStatusEnum::STATUS_INVALID]; } @@ -336,23 +337,6 @@ protected function parseToken(string $token): ?Token return $verified ? $parsedToken : null; } - /** - * Given a parsed Token, find the matching JWTRecord dataobject - * - * @param Token $parsedToken - * @param HTTPRequest $request - * @return JWTRecord|null - */ - protected function findTokenRecord(Token $parsedToken, HTTPrequest $request): ?JWTRecord - { - $userAgent = $request->getHeader('User-Agent'); - /** @var JWTRecord $record */ - $record = JWTRecord::get() - ->filter(['UserAgent' => $userAgent]) - ->byID($parsedToken->getClaim('rid')); - return $record; - } - /** * Determine if the given token is current, given the context of the current request *