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 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/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/_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 0e030bc..0000000 --- a/_config/graphql.yml +++ /dev/null @@ -1,10 +0,0 @@ -SilverStripe\GraphQL\Controller: - schema: - types: - MemberToken: 'Firesphere\GraphQLJWT\Types\MemberTokenTypeCreator' - ValidateToken: 'Firesphere\GraphQLJWT\Types\ValidateTokenTypeCreator' - mutations: - createToken: 'Firesphere\GraphQLJWT\Mutations\CreateTokenMutationCreator' - refreshToken: 'Firesphere\GraphQLJWT\Mutations\RefreshTokenMutationCreator' - queries: - validateToken: 'Firesphere\GraphQLJWT\Queries\ValidateTokenQueryCreator' \ No newline at end of file diff --git a/composer.json b/composer.json index affb71e..52aa6c2 100644 --- a/composer.json +++ b/composer.json @@ -1,39 +1,35 @@ { - "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": ">=7.1", + "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": "2.x-dev" + }, + "installer-name": "graphql-jwt" + }, + "config": { + "process-timeout": 600 + }, + "autoload": { + "psr-4": { + "Firesphere\\GraphQLJWT\\": "src/", + "Firesphere\\GraphQLJWT\\Tests\\": "tests/unit/" + } + }, + "prefer-stable": true, + "minimum-stability": "dev" } 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..55c9651 --- /dev/null +++ b/src/Authentication/AnonymousUserAuthenticator.php @@ -0,0 +1,76 @@ +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 + */ + protected function authenticateMember($data, ValidationResult &$result = null, Member $member = null): Member + { + // Get user, or create if not exists + $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(); + $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"); + } +} diff --git a/src/Authentication/AnonymousUserFactory.php b/src/Authentication/AnonymousUserFactory.php new file mode 100644 index 0000000..7429f8a --- /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->__set($identifierField, $username); + $member->update($fields); + $member->write(); + return $member; + } +} diff --git a/src/Authentication/JWTAuthenticationHandler.php b/src/Authentication/JWTAuthenticationHandler.php index 3277060..0394e61 100644 --- a/src/Authentication/JWTAuthenticationHandler.php +++ b/src/Authentication/JWTAuthenticationHandler.php @@ -1,10 +1,15 @@ -authenticator; - } - - /** - * @param mixed $authenticator - */ - public function setAuthenticator($authenticator) - { - $this->authenticator = $authenticator; - } + use HeaderExtractor; + use RequiresAuthenticator; + use Injectable; /** * @param HTTPRequest $request * @return null|Member - * @throws \OutOfBoundsException - * @throws \BadMethodCallException + * @throws OutOfBoundsException + * @throws BadMethodCallException + * @throws Exception */ - public function authenticateRequest(HTTPRequest $request) + public function authenticateRequest(HTTPRequest $request): ?Member { - $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 + ->getJWTAuthenticator() + ->authenticate(['token' => $token], $request); + if ($member) { $this->logIn($member); } @@ -71,28 +60,24 @@ 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); } /** * @param HTTPRequest|null $request - * @throws ValidationException */ - public function logOut(HTTPRequest $request = null) + public function logOut(HTTPRequest $request = null): void { - // 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->destroyAuthTokens(); } - // 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..4d3b31b 100644 --- a/src/Authentication/JWTAuthenticator.php +++ b/src/Authentication/JWTAuthenticator.php @@ -1,22 +1,29 @@ -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; + } /** - * JWTAuthenticator constructor. - * @throws JWTException + * @return Signer */ - public function __construct() + protected function getSigner(): Signer { - $key = Environment::getEnv('JWT_SIGNER_KEY'); - if (empty($key)) { - throw new JWTException('No key defined!', 1); + switch ($this->getKeyType()) { + case self::HMAC: + return new Hmac\Sha256(); + case self::RSA: + case self::RSA_PASSWORD: + default: + return new Rsa\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 used to generate JWT tokens + * + * @return Key + */ + protected function getPrivateKey(): Key + { + // 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 used to validate JWT tokens + * + * @return Key + * @throws LogicException + */ + protected function getPublicKey(): Key + { + 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); } } /** - * Setup the keys this has to be done on the spot for if the signer changes between validation cycles + * Construct a new key from the named config variable + * + * @param string $name Key name + * @param string|null $password Optional password + * @return Key */ - private function setKeys() + private function makeKey(string $name, string $password = null): Key { - $signerKey = Environment::getEnv('JWT_SIGNER_KEY'); - // If it's a private key, we also need a public key for validation! - if (file_exists($signerKey)) { - $this->signer = new RsaSha256(); - $password = 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; + $key = $this->getEnv($name); + $path = $this->resolvePath($key); + + // String key + if (empty($path)) { + return new Key($path); } + + // Build key from path + return new Key('file://' . $path, $password); } /** @@ -83,9 +179,9 @@ private function setKeys() * * @return int */ - public function supportedServices() + public function supportedServices(): int { - return Authenticator::LOGIN | Authenticator::CMS_LOGIN; + return Authenticator::LOGIN; } /** @@ -95,58 +191,78 @@ public function supportedServices() * @return Member|null * @throws OutOfBoundsException * @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']; - return $this->validateToken($token, $request, $result); + /** @var JWTRecord $record */ + 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 Member|MemberExtension $member * @return Token * @throws ValidationException - * @throws BadMethodCallException */ - public function generateToken(Member $member) + public function generateToken(HTTPRequest $request, Member $member): Token { - $this->setKeys(); $config = static::config(); - $uniqueID = uniqid(Environment::getEnv('JWT_PREFIX'), true); + $uniqueID = uniqid($this->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'); + $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()) // 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(); @@ -155,71 +271,138 @@ public function generateToken(Member $member) /** * @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(string $token, HTTPrequest $request): array { - $this->setKeys(); - $parser = new Parser(); - $parsedToken = $parser->parse((string)$token); - - // Get a validator and the Member for this token - list($validator, $member) = $this->getValidator($request, $parsedToken); - - $verified = $parsedToken->verify($this->signer, $this->publicKey); - $valid = $parsedToken->validate($validator); - - // If the token is not verified, just give up - if (!$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'); - } - // 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; + // Parse token + $parsedToken = $this->parseToken($token); + if ($parsedToken) { + return [null, TokenStatusEnum::STATUS_INVALID]; + } + + // Find local record for this token + /** @var JWTRecord $record */ + $record = JWTRecord::get()->byID($parsedToken->getClaim('rid')); + if (!$record) { + return [null, TokenStatusEnum::STATUS_INVALID]; + } + + // Verified and valid = ok! + $valid = $this->validateParsedToken($parsedToken, $request, $record); + if ($valid) { + return [$record, TokenStatusEnum::STATUS_OK]; + } + + // If the token is invalid, but not because it has expired, fail + if (!$parsedToken->isExpired()) { + return [$record, TokenStatusEnum::STATUS_INVALID]; + } + + // If expired, check if it can be renewed + $canReniew = $this->canTokenBeRenewed($parsedToken); + if ($canReniew) { + return [$record, TokenStatusEnum::STATUS_EXPIRED]; + } + + // If expired and cannot be renewed, it's dead + return [$record, TokenStatusEnum::STATUS_DEAD]; } /** - * @param HTTPRequest $request - * @param Token $parsedToken - * @return array Contains a ValidationData and Member object - * @throws OutOfBoundsException + * Parse a string into a token + * + * @param string $token + * @return Token|null */ - private function getValidator($request, $parsedToken) + protected function parseToken(string $token): ?Token { - $audience = $request->getHeader('Origin'); + // Ensure token given at all + if (!$token) { + return null; + } - $member = null; - $id = 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; + } + + /** + * 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($audience); + $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; + } - 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; + /** + * 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; + } - $validator->setId($id); - return [$validator, $member]; + /** + * 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 77cf59e..0000000 --- a/src/Exceptions/JWTException.php +++ /dev/null @@ -1,9 +0,0 @@ - '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'); } /** @@ -50,12 +44,13 @@ public function onBeforeWrite() * * @return string */ - public function getJWTData() + public function getJWTData(): string { $data = new stdClass(); $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; @@ -66,6 +61,19 @@ public function getJWTData() } } - return Convert::raw2json($data); + return json_encode($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 baa93a9..7fe5e36 100644 --- a/src/Helpers/HeaderExtractor.php +++ b/src/Helpers/HeaderExtractor.php @@ -1,23 +1,26 @@ -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/MemberTokenGenerator.php b/src/Helpers/MemberTokenGenerator.php new file mode 100644 index 0000000..8ce4a9c --- /dev/null +++ b/src/Helpers/MemberTokenGenerator.php @@ -0,0 +1,66 @@ + $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; + } +} diff --git a/src/Helpers/RequiresAuthenticator.php b/src/Helpers/RequiresAuthenticator.php new file mode 100644 index 0000000..a18b501 --- /dev/null +++ b/src/Helpers/RequiresAuthenticator.php @@ -0,0 +1,36 @@ +jwtAuthenticator; + } + + /** + * Inject authenticator this mutation should use + * + * @param JWTAuthenticator $authenticator + * @return $this + */ + public function setJWTAuthenticator(JWTAuthenticator $authenticator): self + { + $this->jwtAuthenticator = $authenticator; + return $this; + } +} diff --git a/src/Model/JWTRecord.php b/src/Model/JWTRecord.php new file mode 100644 index 0000000..adfa5f3 --- /dev/null +++ b/src/Model/JWTRecord.php @@ -0,0 +1,33 @@ + 'Varchar(255)', + 'UserAgent' => 'Text', + ]; + + 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 e992a80..beec8b8 100644 --- a/src/Mutations/CreateTokenMutationCreator.php +++ b/src/Mutations/CreateTokenMutationCreator.php @@ -1,22 +1,58 @@ -customAuthenticators; + } + + /** + * @param Authenticator[] $authenticators + * @return CreateTokenMutationCreator + */ + public function setCustomAuthenticators(array $authenticators): self + { + $this->customAuthenticators = $authenticators; + return $this; + } + + public function attributes(): array { return [ 'name' => 'createToken', @@ -24,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()] ]; } @@ -42,42 +78,60 @@ public function args() * @param array $args * @param mixed $context * @param ResolveInfo $info - * @return null|Member|static - * @throws \Psr\Container\NotFoundExceptionInterface + * @return array + * @throws NotFoundExceptionInterface + * @throws ValidationException */ - public function resolve($object, array $args, $context, ResolveInfo $info) + public function resolve($object, array $args, $context, ResolveInfo $info): array { - /** @var Security $security */ - $security = Injector::inst()->get(Security::class); - $authenticators = $security->getApplicableAuthenticators(Authenticator::LOGIN); + // Authenticate this member $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; - } - } + $member = $this->getAuthenticatedMember($args, $request); + + // Handle unauthenticated + if (!$member) { + return $this->generateResponse(TokenStatusEnum::STATUS_BAD_LOGIN); } - $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(); + + // Create new token from this member + $authenticator = $this->getJWTAuthenticator(); + $token = $authenticator->generateToken($request, $member); + return $this->generateResponse(TokenStatusEnum::STATUS_OK, $member, $token->__toString()); + } + + /** + * 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 + { + // Login with authenticators + foreach ($this->getLoginAuthenticators() as $authenticator) { + $result = ValidationResult::create(); + $member = $authenticator->authenticate($args, $request, $result); + if ($member && $result->isValid()) { + return $member; + } } - return $member; + 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 b2cd607..022aa7e 100644 --- a/src/Mutations/RefreshTokenMutationCreator.php +++ b/src/Mutations/RefreshTokenMutationCreator.php @@ -1,21 +1,32 @@ - 'refreshToken', @@ -23,70 +34,54 @@ 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 * @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 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(); - $authenticator = Injector::inst()->get(JWTAuthenticator::class); - $member = null; - $result = new ValidationResult(); - $matches = HeaderExtractor::getAuthorizationHeader($request); + $token = $this->getAuthorizationHeader($request); - if (!empty($matches[1])) { - $member = $authenticator->authenticate(['token' => $matches[1]], $request, $result); - } - - $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 + $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 5477036..c819d93 100644 --- a/src/Queries/ValidateTokenQueryCreator.php +++ b/src/Queries/ValidateTokenQueryCreator.php @@ -1,19 +1,32 @@ - 'validateToken', @@ -21,14 +34,14 @@ public function attributes() ]; } - public function args() + public function args(): array { return []; } - public function type() + public function type(): Type { - return $this->manager->getType('ValidateToken'); + return $this->manager->getType('MemberToken'); } /** @@ -37,37 +50,21 @@ public function type() * @param mixed $context * @param ResolveInfo $info * @return array - * @throws \Psr\Container\NotFoundExceptionInterface - * @throws \OutOfBoundsException - * @throws \BadMethodCallException + * @throws NotFoundExceptionInterface + * @throws OutOfBoundsException + * @throws BadMethodCallException + * @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 = 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..3a4b2c6 100644 --- a/src/Types/MemberTokenTypeCreator.php +++ b/src/Types/MemberTokenTypeCreator.php @@ -1,30 +1,32 @@ - 'MemberToken' ]; } - public function fields() + public function fields(): array { - $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()], + 'Message' => ['type' => Type::string()], ]; } } diff --git a/src/Types/MemberTypeCreator.php b/src/Types/MemberTypeCreator.php new file mode 100644 index 0000000..f6c1b07 --- /dev/null +++ b/src/Types/MemberTypeCreator.php @@ -0,0 +1,28 @@ + 'Member']; + } + + 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()], + ]; + } +} diff --git a/src/Types/TokenStatusEnum.php b/src/Types/TokenStatusEnum.php new file mode 100644 index 0000000..6fae356 --- /dev/null +++ b/src/Types/TokenStatusEnum.php @@ -0,0 +1,81 @@ + [ + '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 + * @todo Create a TypeCreator for non-object types + * + * @return TokenStatusEnum + */ + public static function instance(): self + { + 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()], - ]; - } -} diff --git a/tests/unit/CreateTokenMutationCreatorTest.php b/tests/unit/CreateTokenMutationCreatorTest.php index 81cf49c..926076c 100644 --- a/tests/unit/CreateTokenMutationCreatorTest.php +++ b/tests/unit/CreateTokenMutationCreatorTest.php @@ -2,13 +2,12 @@ namespace Firesphere\GraphQLJWT\Tests; -use Firesphere\GraphQLJWT\Authentication\JWTAuthenticator; +use Firesphere\GraphQLJWT\Authentication\AnonymousUserAuthenticator; use Firesphere\GraphQLJWT\Mutations\CreateTokenMutationCreator; use GraphQL\Type\Definition\ResolveInfo; -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 CreateTokenMutationCreatorTest extends SapphireTest @@ -25,14 +24,12 @@ public function setUp() $this->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..06a9d1f 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,57 @@ 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 +117,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 +128,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..b3914ff 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,19 @@ 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'] + ); } }