From d17e4052c41c9329ba59f0f7af3169b3891f5aed Mon Sep 17 00:00:00 2001 From: Aaron Carlino Date: Wed, 9 Dec 2020 10:54:07 +1300 Subject: [PATCH 1/2] Update graphql constraint --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index a36b695..5c903ae 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,7 @@ "require": { "php": ">=7.1", "silverstripe/framework": "^4.3", - "silverstripe/graphql": "^3.0", + "silverstripe/graphql": "4.x-dev", "lcobucci/jwt": "^3.2", "ext-json": "*" }, From e5768103d8e490a00c1988dea2d0bba679efb4e2 Mon Sep 17 00:00:00 2001 From: Aaron Carlino Date: Wed, 9 Dec 2020 13:21:51 +1300 Subject: [PATCH 2/2] First cut of GraphQL 4 compatibility --- _config/config.yml | 9 - _graphql/config.yml | 2 + _graphql/enums.yml | 17 ++ _graphql/models.yml | 6 + _graphql/mutations.yml | 6 + _graphql/queries.yml | 3 + _graphql/types.yml | 9 + readme.md | 73 +++---- .../CustomAuthenticatorRegistry.php | 40 ++++ .../JWTAuthenticationHandler.php | 6 +- src/Authentication/JWTAuthenticator.php | 79 +++++--- src/Helpers/HeaderExtractor.php | 2 +- src/Helpers/MemberTokenGenerator.php | 31 ++- src/Helpers/RequiresAuthenticator.php | 36 ---- src/Mutations/CreateTokenMutationCreator.php | 137 ------------- src/Mutations/RefreshTokenMutationCreator.php | 87 --------- src/Queries/ValidateTokenQueryCreator.php | 70 ------- src/Resolvers/Resolver.php | 184 ++++++++++++++++++ src/Types/MemberTokenTypeCreator.php | 31 --- src/Types/MemberTypeCreator.php | 28 --- src/Types/TokenStatusEnum.php | 81 -------- tests/unit/CreateTokenMutationCreatorTest.php | 88 --------- tests/unit/CreateTokenTest.php | 67 +++++++ tests/unit/JWTAuthenticationHandlerTest.php | 14 +- tests/unit/JWTAuthenticatorTest.php | 25 +-- ...onCreatorTest.php => RefreshTokenTest.php} | 43 ++-- ...yCreatorTest.php => ValidateTokenTest.php} | 46 ++--- 27 files changed, 472 insertions(+), 748 deletions(-) create mode 100644 _graphql/config.yml create mode 100644 _graphql/enums.yml create mode 100644 _graphql/models.yml create mode 100644 _graphql/mutations.yml create mode 100644 _graphql/queries.yml create mode 100644 _graphql/types.yml create mode 100644 src/Authentication/CustomAuthenticatorRegistry.php delete mode 100644 src/Helpers/RequiresAuthenticator.php delete mode 100644 src/Mutations/CreateTokenMutationCreator.php delete mode 100644 src/Mutations/RefreshTokenMutationCreator.php delete mode 100644 src/Queries/ValidateTokenQueryCreator.php create mode 100644 src/Resolvers/Resolver.php delete mode 100644 src/Types/MemberTokenTypeCreator.php delete mode 100644 src/Types/MemberTypeCreator.php delete mode 100644 src/Types/TokenStatusEnum.php delete mode 100644 tests/unit/CreateTokenMutationCreatorTest.php create mode 100644 tests/unit/CreateTokenTest.php rename tests/unit/{RefreshTokenMutationCreatorTest.php => RefreshTokenTest.php} (58%) rename tests/unit/{ValidateTokenQueryCreatorTest.php => ValidateTokenTest.php} (54%) diff --git a/_config/config.yml b/_config/config.yml index 7b64b28..09c0420 100644 --- a/_config/config.yml +++ b/_config/config.yml @@ -9,12 +9,3 @@ SilverStripe\Core\Injector\Injector: Firesphere\GraphQLJWT\Authentication\JWTAuthenticationHandler: properties: 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/_graphql/config.yml b/_graphql/config.yml new file mode 100644 index 0000000..6002621 --- /dev/null +++ b/_graphql/config.yml @@ -0,0 +1,2 @@ +resolvers: + - Firesphere\GraphQLJWT\Resolvers\Resolver diff --git a/_graphql/enums.yml b/_graphql/enums.yml new file mode 100644 index 0000000..f7dd41f --- /dev/null +++ b/_graphql/enums.yml @@ -0,0 +1,17 @@ +TokenStatus: + values: + OK: + value: OK + description: JWT token is valid + INVALID: + value: INVALID + description: JWT token is valid + EXPIRED: + value: EXPIRED + description: JWT token has expired, but can be renewed + DEAD: + value: DEAD + description: JWT token has expired and cannot be renewed + BAD_LOGIN: + value: BAD_LOGIN + description: JWT token could not be created due to invalid login credentials diff --git a/_graphql/models.yml b/_graphql/models.yml new file mode 100644 index 0000000..9729c1c --- /dev/null +++ b/_graphql/models.yml @@ -0,0 +1,6 @@ +SilverStripe\Security\Member: + fields: + id: true + firstName: true + surname: true + email: true diff --git a/_graphql/mutations.yml b/_graphql/mutations.yml new file mode 100644 index 0000000..349b24c --- /dev/null +++ b/_graphql/mutations.yml @@ -0,0 +1,6 @@ +refreshToken: + type: MemberToken + description: Refreshes a JWT token for a valid user. To be done +'createToken(email: String!, password: String)': + type: MemberToken + description: Creates a JWT token for a valid user diff --git a/_graphql/queries.yml b/_graphql/queries.yml new file mode 100644 index 0000000..f45cd22 --- /dev/null +++ b/_graphql/queries.yml @@ -0,0 +1,3 @@ +validateToken: + description: Validates a given token from the Bearer header + type: MemberToken diff --git a/_graphql/types.yml b/_graphql/types.yml new file mode 100644 index 0000000..26914fe --- /dev/null +++ b/_graphql/types.yml @@ -0,0 +1,9 @@ +MemberToken: + fields: + valid: Boolean + member: + model: SilverStripe\Security\Member + token: String + status: TokenStatus + code: Int + message: String diff --git a/readme.md b/readme.md index d9a9f0d..862b952 100644 --- a/readme.md +++ b/readme.md @@ -43,43 +43,20 @@ The signer key [for HMAC can be of any length (keys longer than B bytes are firs 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` +For example, given you've decided to create a schema named `default` at the url `/graphql` +(which is provided by the core recipe out of the box), all you need to do is +add the `firesphere/graphql-jwt` config to your schema. ```yml --- Name: my-graphql-schema --- -SilverStripe\GraphQL\Manager: +SilverStripe\GraphQL\Schema\Schema: 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: + default: + src: + - 'firesphere/graphql-jwt: _graphql' Controller: '%$SilverStripe\GraphQL\Controller.frontend' - Stage: Live ``` @@ -89,11 +66,13 @@ To generate a JWT token, send a login request to the `createToken` mutator: ```graphql mutation { - createToken(Email: "admin", Password: "password") { - Token, // ...request or you won't have a token - ID, - FirstName, - Surname + createToken(email: "admin", password: "password") { + token // ...request or you won't have a token + id + member { + firstName + surname + } } } ``` @@ -105,9 +84,9 @@ If you have an app and want to validate your token, you can address the `validat ```graphql query validateToken { validateToken { - Valid - Message - Code + valid + message + code } } ``` @@ -118,16 +97,16 @@ It only needs to call the endpoint. The token should be in the header, via your { "data": { "validateToken": { - "Valid": true, - "Message": "", - "Code": 200, + "valid": true, + "message": "", + "code": 200, "__typename": "ValidateToken" } } } ``` -If the token is invalid, `Valid` will be `false`. +If the token is invalid, `valid` will be `false`. ## Anonymous tokens @@ -138,7 +117,7 @@ To enable anonymous tokens, add the following to your configuration `.yml`: ```yaml SilverStripe\Core\Injector\Injector: - Firesphere\GraphQLJWT\Mutations\CreateTokenMutationCreator: + Firesphere\GraphQLJWT\Authentication\CustomAuthenticatorRegistry: properties: CustomAuthenticators: - Firesphere\GraphQLJWT\Authentication\AnonymousUserAuthenticator @@ -148,14 +127,14 @@ You can then create an anonymous login with the below query. ```graphql mutation { - createToken(Email: "anonymous") { - Token + 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`. +core Silverstripe CMS authenticator into `CustomAuthenticators`. Warning: The default `AnonymousUserAuthenticator` is not appropriate for general usage, so don't register this under the core `Security` class! @@ -188,7 +167,7 @@ Currently, the default method for encrypting the JWT is with SHA256. JWT is sign ## Supported services -By default, JWT only supports login. As it's tokens can not be disabled, nor used for password changes or resets. +By default, JWT only supports login. As its tokens can not be disabled, nor used for password changes or resets. ## Caveats diff --git a/src/Authentication/CustomAuthenticatorRegistry.php b/src/Authentication/CustomAuthenticatorRegistry.php new file mode 100644 index 0000000..4b16599 --- /dev/null +++ b/src/Authentication/CustomAuthenticatorRegistry.php @@ -0,0 +1,40 @@ +customAuthenticators; + } + + /** + * @param Authenticator[] $authenticators + * @return $this + */ + public function setCustomAuthenticators(array $authenticators): self + { + $this->customAuthenticators = $authenticators; + return $this; + } + +} diff --git a/src/Authentication/JWTAuthenticationHandler.php b/src/Authentication/JWTAuthenticationHandler.php index 0394e61..0d4c0d6 100644 --- a/src/Authentication/JWTAuthenticationHandler.php +++ b/src/Authentication/JWTAuthenticationHandler.php @@ -6,10 +6,10 @@ use Exception; use Firesphere\GraphQLJWT\Extensions\MemberExtension; use Firesphere\GraphQLJWT\Helpers\HeaderExtractor; -use Firesphere\GraphQLJWT\Helpers\RequiresAuthenticator; use OutOfBoundsException; use SilverStripe\Control\HTTPRequest; use SilverStripe\Core\Injector\Injectable; +use SilverStripe\Core\Injector\Injector; use SilverStripe\Security\AuthenticationHandler; use SilverStripe\Security\Member; use SilverStripe\Security\Security; @@ -22,7 +22,6 @@ class JWTAuthenticationHandler implements AuthenticationHandler { use HeaderExtractor; - use RequiresAuthenticator; use Injectable; /** @@ -41,8 +40,7 @@ public function authenticateRequest(HTTPRequest $request): ?Member } // Validate the token. This is critical for security - $member = $this - ->getJWTAuthenticator() + $member = Injector::inst()->get(JWTAuthenticator::class) ->authenticate(['token' => $token], $request); if ($member) { diff --git a/src/Authentication/JWTAuthenticator.php b/src/Authentication/JWTAuthenticator.php index 35181ce..2f13312 100644 --- a/src/Authentication/JWTAuthenticator.php +++ b/src/Authentication/JWTAuthenticator.php @@ -3,12 +3,13 @@ namespace Firesphere\GraphQLJWT\Authentication; use BadMethodCallException; +use DateInterval; use DateTimeImmutable; use Exception; use Firesphere\GraphQLJWT\Extensions\MemberExtension; use Firesphere\GraphQLJWT\Helpers\MemberTokenGenerator; use Firesphere\GraphQLJWT\Model\JWTRecord; -use Firesphere\GraphQLJWT\Types\TokenStatusEnum; +use Firesphere\GraphQLJWT\Resolvers\Resolver; use Lcobucci\JWT\Builder; use Lcobucci\JWT\Parser; use Lcobucci\JWT\Signer; @@ -205,7 +206,7 @@ public function authenticate(array $data, HTTPRequest $request, ValidationResult list($record, $status) = $this->validateToken($token, $request); // Report success! - if ($status === TokenStatusEnum::STATUS_OK) { + if ($status === Resolver::STATUS_OK) { return $record->Member(); } @@ -225,6 +226,7 @@ public function authenticate(array $data, HTTPRequest $request, ValidationResult * @param Member|MemberExtension $member * @return Token * @throws ValidationException + * @throws Exception */ public function generateToken(HTTPRequest $request, Member $member): Token { @@ -242,31 +244,30 @@ public function generateToken(HTTPRequest $request, Member $member): Token // Create builder for this record $builder = new Builder(); - $now = DBDatetime::now()->getTimestamp(); $token = $builder // Configures the issuer (iss claim) - ->setIssuer($request->getHeader('Origin')) + ->issuedBy($request->getHeader('Origin')) // Configures the audience (aud claim) - ->setAudience(Director::absoluteBaseURL()) + ->permittedFor(Director::absoluteBaseURL()) // Configures the id (jti claim), replicating as a header item - ->setId($uniqueID, true) + ->identifiedBy($uniqueID)->withHeader('jti', $uniqueID) // Configures the time that the token was issue (iat claim) - ->setIssuedAt($now) + ->issuedAt($this->getNow()) // Configures the time that the token can be used (nbf claim) - ->setNotBefore($now + $config->get('nbf_time')) + ->canOnlyBeUsedAfter($this->getNowPlus($config->get('nbf_time'))) // Configures the expiration time of the token (nbf claim) - ->setExpiration($now + $config->get('nbf_expiration')) - // Set renew expiration - ->set('rexp', $now + $config->get('nbf_refresh_expiration')) + ->expiresAt($this->getNowPlus($config->get('nbf_expiration'))) + // Set renew expiration (unix timestamp) + ->withClaim('rexp', $this->getNowPlus($config->get('nbf_refresh_expiration'))) // Configures a new claim, called "rid" - ->set('rid', $record->ID) + ->withClaim('rid', $record->ID) // Set the subject, which is the member - ->setSubject($member->getJWTData()) + ->relatedTo($member->getJWTData()) // Sign the key with the Signer's key - ->sign($this->getSigner(), $this->getPrivateKey()); + ->getToken($this->getSigner(), $this->getPrivateKey()); // Return the token - return $token->getToken(); + return $token; } /** @@ -280,36 +281,35 @@ public function validateToken(?string $token, HTTPrequest $request): array // Parse token $parsedToken = $this->parseToken($token); if (!$parsedToken) { - return [null, TokenStatusEnum::STATUS_INVALID]; + return [null, Resolver::STATUS_INVALID]; } // Find local record for this token /** @var JWTRecord $record */ - $record = JWTRecord::get()->byID($parsedToken->getClaim('rid')); + $record = JWTRecord::get()->byID($parsedToken->claims()->get('rid')); if (!$record) { - return [null, TokenStatusEnum::STATUS_INVALID]; + return [null, Resolver::STATUS_INVALID]; } // Verified and valid = ok! $valid = $this->validateParsedToken($parsedToken, $request, $record); if ($valid) { - return [$record, TokenStatusEnum::STATUS_OK]; + return [$record, Resolver::STATUS_OK]; } // If the token is invalid, but not because it has expired, fail - $now = new DateTimeImmutable(DBDatetime::now()->getValue()); - if (!$parsedToken->isExpired($now)) { - return [$record, TokenStatusEnum::STATUS_INVALID]; + if (!$parsedToken->isExpired($this->getNow())) { + return [$record, Resolver::STATUS_INVALID]; } // If expired, check if it can be renewed $canReniew = $this->canTokenBeRenewed($parsedToken); if ($canReniew) { - return [$record, TokenStatusEnum::STATUS_EXPIRED]; + return [$record, Resolver::STATUS_EXPIRED]; } // If expired and cannot be renewed, it's dead - return [$record, TokenStatusEnum::STATUS_DEAD]; + return [$record, Resolver::STATUS_DEAD]; } /** @@ -346,15 +346,17 @@ protected function parseToken(?string $token): ?Token * @param HTTPRequest $request * @param JWTRecord $record * @return bool + * @throws Exception */ protected function validateParsedToken(Token $parsedToken, HTTPrequest $request, JWTRecord $record): bool { - $now = DBDatetime::now()->getTimestamp(); + // @todo - upgrade + // @see https://lcobucci-jwt.readthedocs.io/en/latest/upgrading/#replace-tokenverify-and-tokenvalidate-with-validation-api $validator = new ValidationData(); $validator->setIssuer($request->getHeader('Origin')); $validator->setAudience(Director::absoluteBaseURL()); $validator->setId($record->UID); - $validator->setCurrentTime($now); + $validator->setCurrentTime($this->getNow()->getTimestamp()); return $parsedToken->validate($validator); } @@ -363,12 +365,12 @@ protected function validateParsedToken(Token $parsedToken, HTTPrequest $request, * * @param Token $parsedToken * @return bool + * @throws Exception */ protected function canTokenBeRenewed(Token $parsedToken): bool { - $renewBefore = $parsedToken->getClaim('rexp'); - $now = DBDatetime::now()->getTimestamp(); - return $renewBefore > $now; + $renewBefore = $parsedToken->claims()->get('rexp'); + return $renewBefore > $this->getNow()->getTimestamp(); } /** @@ -407,4 +409,23 @@ protected function getEnv(string $key, $default = null): ?string } return $default; } + + /** + * @return DateTimeImmutable + * @throws Exception + */ + protected function getNow(): DateTimeImmutable + { + return new DateTimeImmutable(DBDatetime::now()->getValue()); + } + + /** + * @param int $seconds + * @return DateTimeImmutable + * @throws Exception + */ + protected function getNowPlus($seconds) + { + return $this->getNow()->add(new DateInterval(sprintf("PT%dS", $seconds))); + } } diff --git a/src/Helpers/HeaderExtractor.php b/src/Helpers/HeaderExtractor.php index 7fe5e36..02d1c31 100644 --- a/src/Helpers/HeaderExtractor.php +++ b/src/Helpers/HeaderExtractor.php @@ -15,7 +15,7 @@ trait HeaderExtractor * @param HTTPRequest $request * @return string|null */ - protected function getAuthorizationHeader(HTTPRequest $request): ?string + protected static function getAuthorizationHeader(HTTPRequest $request): ?string { $authHeader = $request->getHeader('Authorization'); if ($authHeader && preg_match('/Bearer\s+(?.*)$/i', $authHeader, $matches)) { diff --git a/src/Helpers/MemberTokenGenerator.php b/src/Helpers/MemberTokenGenerator.php index 5bb81cc..c5e1a5f 100644 --- a/src/Helpers/MemberTokenGenerator.php +++ b/src/Helpers/MemberTokenGenerator.php @@ -2,7 +2,7 @@ namespace Firesphere\GraphQLJWT\Helpers; -use Firesphere\GraphQLJWT\Types\TokenStatusEnum; +use Firesphere\GraphQLJWT\Resolvers\Resolver; use InvalidArgumentException; use SilverStripe\Core\Extensible; use SilverStripe\Security\Member; @@ -21,18 +21,18 @@ trait MemberTokenGenerator * @return string * @throws InvalidArgumentException */ - public function getErrorMessage(string $status): string + public static function getErrorMessage(string $status): string { switch ($status) { - case TokenStatusEnum::STATUS_EXPIRED: + case Resolver::STATUS_EXPIRED: return _t('JWT.STATUS_EXPIRED', 'Token is expired, please renew your token with a refreshToken query'); - case TokenStatusEnum::STATUS_DEAD: + case Resolver::STATUS_DEAD: return _t('JWT.STATUS_DEAD', 'Token is expired, but is too old to renew. Please log in again.'); - case TokenStatusEnum::STATUS_INVALID: + case Resolver::STATUS_INVALID: return _t('JWT.STATUS_INVALID', 'Invalid token provided'); - case TokenStatusEnum::STATUS_BAD_LOGIN: + case Resolver::STATUS_BAD_LOGIN: return _t('JWT.STATUS_BAD_LOGIN', 'Sorry your email and password combination is rejected'); - case TokenStatusEnum::STATUS_OK: + case Resolver::STATUS_OK: return _t('JWT.STATUS_OK', 'Token is ok'); default: throw new InvalidArgumentException("Invalid status"); @@ -47,20 +47,19 @@ public function getErrorMessage(string $status): string * @param string $token * @return array Response in format required by MemberToken */ - protected function generateResponse(string $status, Member $member = null, string $token = null): array + protected static function generateResponse(string $status, Member $member = null, string $token = null): array { // Success response - $valid = $status === TokenStatusEnum::STATUS_OK; + $valid = $status === Resolver::STATUS_OK; $response = [ - 'Valid' => $valid, - 'Member' => $valid && $member && $member->exists() ? $member : null, - 'Token' => $token, - 'Status' => $status, - 'Code' => $valid ? 200 : 401, - 'Message' => $this->getErrorMessage($status), + 'valid' => $valid, + 'member' => $valid && $member && $member->exists() ? $member : null, + 'token' => $token, + 'status' => $status, + 'code' => $valid ? 200 : 401, + 'message' => static::getErrorMessage($status), ]; - $this->extend('updateMemberToken', $response); return $response; } } diff --git a/src/Helpers/RequiresAuthenticator.php b/src/Helpers/RequiresAuthenticator.php deleted file mode 100644 index a18b501..0000000 --- a/src/Helpers/RequiresAuthenticator.php +++ /dev/null @@ -1,36 +0,0 @@ -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/Mutations/CreateTokenMutationCreator.php b/src/Mutations/CreateTokenMutationCreator.php deleted file mode 100644 index ea33feb..0000000 --- a/src/Mutations/CreateTokenMutationCreator.php +++ /dev/null @@ -1,137 +0,0 @@ -customAuthenticators; - } - - /** - * @param Authenticator[] $authenticators - * @return CreateTokenMutationCreator - */ - public function setCustomAuthenticators(array $authenticators): self - { - $this->customAuthenticators = $authenticators; - return $this; - } - - public function attributes(): array - { - return [ - 'name' => 'createToken', - 'description' => 'Creates a JWT token for a valid user' - ]; - } - - public function type(): Type - { - return $this->manager->getType('MemberToken'); - } - - public function args(): array - { - return [ - 'Email' => ['type' => Type::nonNull(Type::string())], - 'Password' => ['type' => Type::string()] - ]; - } - - /** - * @param mixed $object - * @param array $args - * @param mixed $context - * @param ResolveInfo $info - * @return array - * @throws NotFoundExceptionInterface - * @throws ValidationException - */ - public function resolve($object, array $args, $context, ResolveInfo $info): array - { - // 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); - 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 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 deleted file mode 100644 index 8e48982..0000000 --- a/src/Mutations/RefreshTokenMutationCreator.php +++ /dev/null @@ -1,87 +0,0 @@ - 'refreshToken', - 'description' => 'Refreshes a JWT token for a valid user. To be done' - ]; - } - - public function type(): Type - { - return $this->manager->getType('MemberToken'); - } - - /** - * @param mixed $object - * @param array $args - * @param mixed $context - * @param ResolveInfo $info - * @return array - * @throws NotFoundExceptionInterface - * @throws ValidationException - * @throws BadMethodCallException - * @throws OutOfBoundsException - * @throws Exception - */ - public function resolve($object, array $args, $context, ResolveInfo $info): array - { - $authenticator = $this->getJWTAuthenticator(); - $request = Controller::curr()->getRequest(); - $token = $this->getAuthorizationHeader($request); - - // 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; - } - - // Check if renewable - if (!$renewable) { - return $this->generateResponse($status); - } - - // 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 deleted file mode 100644 index c121e15..0000000 --- a/src/Queries/ValidateTokenQueryCreator.php +++ /dev/null @@ -1,70 +0,0 @@ - 'validateToken', - 'description' => 'Validates a given token from the Bearer header' - ]; - } - - public function args(): array - { - return []; - } - - public function type(): Type - { - return $this->manager->getType('MemberToken'); - } - - /** - * @param mixed $object - * @param array $args - * @param mixed $context - * @param ResolveInfo $info - * @return array - * @throws NotFoundExceptionInterface - * @throws OutOfBoundsException - * @throws BadMethodCallException - * @throws Exception - */ - public function resolve($object, array $args, $context, ResolveInfo $info): array - { - /** @var JWTAuthenticator $authenticator */ - $authenticator = $this->getJWTAuthenticator(); - $request = Controller::curr()->getRequest(); - $token = $this->getAuthorizationHeader($request); - - /** @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/Resolvers/Resolver.php b/src/Resolvers/Resolver.php new file mode 100644 index 0000000..881e78f --- /dev/null +++ b/src/Resolvers/Resolver.php @@ -0,0 +1,184 @@ +get(JWTAuthenticator::class); + $request = Controller::curr()->getRequest(); + $token = static::getAuthorizationHeader($request); + + /** @var JWTRecord $record */ + list($record, $status) = $authenticator->validateToken($token, $request); + $member = $status === self::STATUS_OK ? $record->Member() : null; + return static::generateResponse($status, $member, $token); + } + + /** + * @return array + * @throws NotFoundExceptionInterface + * @throws BadMethodCallException + * @throws OutOfBoundsException + * @throws Exception + */ + public static function resolveRefreshToken(): array + { + $authenticator = Injector::inst()->get(JWTAuthenticator::class); + $request = Controller::curr()->getRequest(); + $token = static::getAuthorizationHeader($request); + + // Check status of existing token + /** @var JWTRecord $record */ + list($record, $status) = $authenticator->validateToken($token, $request); + $member = null; + switch ($status) { + case self::STATUS_OK: + case self::STATUS_EXPIRED: + $member = $record->Member(); + $renewable = true; + break; + case self::STATUS_DEAD: + case self::STATUS_INVALID: + default: + $member = null; + $renewable = false; + break; + } + + // Check if renewable + if (!$renewable) { + return static::generateResponse($status); + } + + // Create new token for member + $newToken = $authenticator->generateToken($request, $member); + return static::generateResponse(self::STATUS_OK, $member, $newToken->__toString()); + } + + + /** + * @param mixed $object + * @param array $args + * @return array + * @throws NotFoundExceptionInterface + */ + public static function resolveCreateToken($object, array $args): array + { + // Authenticate this member + $request = Controller::curr()->getRequest(); + $member = static::getAuthenticatedMember($args, $request); + + // Handle unauthenticated + if (!$member) { + return static::generateResponse(self::STATUS_BAD_LOGIN); + } + + // Create new token from this member + $authenticator = Injector::inst()->get(JWTAuthenticator::class); + $token = $authenticator->generateToken($request, $member); + return static::generateResponse(self::STATUS_OK, $member, $token->__toString()); + } + + /** + * Get any authenticator we should use for logging in users + * + * @return Authenticator[]|Generator + */ + protected static function getLoginAuthenticators(): Generator + { + // Check injected authenticators + yield from CustomAuthenticatorRegistry::singleton()->getCustomAuthenticators(); + + // Get other login handlers from Security + $security = Security::singleton(); + yield from $security->getApplicableAuthenticators(Authenticator::LOGIN); + } + + /** + * Get an authenticated member from the given request + * + * @param array $args + * @param HTTPRequest $request + * @return Member|MemberExtension + */ + protected static function getAuthenticatedMember(array $args, HTTPRequest $request): ?Member + { + // Normalise the casing for the authenticator + $data = [ + 'Email' => $args['email'], + 'Password' => $args['password'] ?? null, + ]; + // Login with authenticators + foreach (static::getLoginAuthenticators() as $authenticator) { + $result = ValidationResult::create(); + $member = $authenticator->authenticate($data, $request, $result); + if ($member && $result->isValid()) { + return $member; + } + } + + return null; + } + +} diff --git a/src/Types/MemberTokenTypeCreator.php b/src/Types/MemberTokenTypeCreator.php deleted file mode 100644 index b87a5bb..0000000 --- a/src/Types/MemberTokenTypeCreator.php +++ /dev/null @@ -1,31 +0,0 @@ - 'MemberToken' - ]; - } - - 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()], - 'Message' => ['type' => Type::string()], - ]; - } -} diff --git a/src/Types/MemberTypeCreator.php b/src/Types/MemberTypeCreator.php deleted file mode 100644 index aa64ed1..0000000 --- a/src/Types/MemberTypeCreator.php +++ /dev/null @@ -1,28 +0,0 @@ - '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 deleted file mode 100644 index af687b3..0000000 --- a/src/Types/TokenStatusEnum.php +++ /dev/null @@ -1,81 +0,0 @@ - [ - '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/tests/unit/CreateTokenMutationCreatorTest.php b/tests/unit/CreateTokenMutationCreatorTest.php deleted file mode 100644 index 926076c..0000000 --- a/tests/unit/CreateTokenMutationCreatorTest.php +++ /dev/null @@ -1,88 +0,0 @@ -member = $this->objFromFixture(Member::class, 'admin'); - } - - /** - * @throws ValidationException - */ - public function testResolveValid() - { - $createToken = CreateTokenMutationCreator::singleton(); - - $response = $createToken->resolve( - null, - ['Email' => 'admin@silverstripe.com', 'Password' => 'error'], - [], - new ResolveInfo([]) - ); - - $this->assertTrue($response['Member'] instanceof Member); - $this->assertNotNull($response['Token']); - } - - /** - * @throws ValidationException - */ - public function testResolveInvalidWithAllowedAnonymous() - { - $authenticator = CreateTokenMutationCreator::singleton(); - - // Inject custom authenticator - $authenticator->setCustomAuthenticators([ - AnonymousUserAuthenticator::singleton(), - ]); - - $response = $authenticator->resolve( - null, - ['Email' => 'anonymous'], - [], - new ResolveInfo([]) - ); - - /** @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() - { - $authenticator = CreateTokenMutationCreator::singleton(); - $response = $authenticator->resolve( - null, - ['Email' => 'anonymous'], - [], - new ResolveInfo([]) - ); - - $this->assertNull($response['Member']); - $this->assertNull($response['Token']); - } -} diff --git a/tests/unit/CreateTokenTest.php b/tests/unit/CreateTokenTest.php new file mode 100644 index 0000000..cb91208 --- /dev/null +++ b/tests/unit/CreateTokenTest.php @@ -0,0 +1,67 @@ +member = $this->objFromFixture(Member::class, 'admin'); + } + + public function testResolveValid() + { + $response = Resolver::resolveCreateToken( + null, + ['email' => 'admin@silverstripe.com', 'password' => 'error'] + ); + + $this->assertTrue($response['member'] instanceof Member); + $this->assertNotNull($response['token']); + } + + public function testResolveInvalidWithAllowedAnonymous() + { + Injector::inst()->get(CustomAuthenticatorRegistry::class) + ->setCustomAuthenticators([ + AnonymousUserAuthenticator::singleton(), + ]); + $response = Resolver::resolveCreateToken( + null, + ['email' => 'anonymous'] + ); + + /** @var Member $member */ + $member = $response['member']; + $this->assertTrue($member instanceof Member); + $this->assertTrue($member->exists()); + $this->assertEquals($member->Email, 'anonymous'); + $this->assertNotNull($response['token']); + } + + public function testResolveInvalidWithoutAllowedAnonymous() + { + $response = Resolver::resolveCreateToken( + null, + ['email' => 'anonymous'] + ); + + $this->assertNull($response['member']); + $this->assertNull($response['token']); + } +} diff --git a/tests/unit/JWTAuthenticationHandlerTest.php b/tests/unit/JWTAuthenticationHandlerTest.php index 33efd9a..5cd9fdd 100644 --- a/tests/unit/JWTAuthenticationHandlerTest.php +++ b/tests/unit/JWTAuthenticationHandlerTest.php @@ -5,6 +5,7 @@ use Exception; use Firesphere\GraphQLJWT\Authentication\JWTAuthenticationHandler; use Firesphere\GraphQLJWT\Mutations\CreateTokenMutationCreator; +use Firesphere\GraphQLJWT\Resolvers\Resolver; use GraphQL\Type\Definition\ResolveInfo; use SilverStripe\Control\Controller; use SilverStripe\Core\Environment; @@ -20,25 +21,18 @@ class JWTAuthenticationHandlerTest extends SapphireTest protected $token; - /** - * @throws ValidationException - */ public function setUp() { Environment::putEnv('JWT_SIGNER_KEY=test_signer'); parent::setUp(); $this->member = $this->objFromFixture(Member::class, 'admin'); - $createToken = CreateTokenMutationCreator::singleton(); - - $response = $createToken->resolve( + $response = Resolver::resolveCreateToken( null, - ['Email' => 'admin@silverstripe.com', 'Password' => 'error'], - [], - new ResolveInfo([]) + ['email' => 'admin@silverstripe.com', 'password' => 'error'], ); - $this->token = $response['Token']; + $this->token = $response['token']; } /** diff --git a/tests/unit/JWTAuthenticatorTest.php b/tests/unit/JWTAuthenticatorTest.php index 3daaeea..fe5aeea 100644 --- a/tests/unit/JWTAuthenticatorTest.php +++ b/tests/unit/JWTAuthenticatorTest.php @@ -6,6 +6,7 @@ use Firesphere\GraphQLJWT\Authentication\JWTAuthenticator; use Firesphere\GraphQLJWT\Extensions\MemberExtension; use Firesphere\GraphQLJWT\Mutations\CreateTokenMutationCreator; +use Firesphere\GraphQLJWT\Resolvers\Resolver; use Firesphere\GraphQLJWT\Types\TokenStatusEnum; use GraphQL\Type\Definition\ResolveInfo; use SilverStripe\Control\Controller; @@ -23,24 +24,18 @@ class JWTAuthenticatorTest extends SapphireTest protected $token; - /** - * @throws ValidationException - */ public function setUp() { Environment::setEnv('JWT_SIGNER_KEY', 'test_signer'); parent::setUp(); $this->member = $this->objFromFixture(Member::class, 'admin'); - $createToken = CreateTokenMutationCreator::singleton(); - $response = $createToken->resolve( + $response = Resolver::resolveCreateToken( null, - ['Email' => 'admin@silverstripe.com', 'Password' => 'error'], - [], - new ResolveInfo([]) + ['email' => 'admin@silverstripe.com', 'password' => 'error'] ); - $this->token = $response['Token']; + $this->token = $response['token']; } /** @@ -94,7 +89,7 @@ public function testInvalidUniqueID() $this->assertNotEmpty($validationResult->getMessages()); $this->assertEquals( 'Invalid token provided', - $validationResult->getMessages()[TokenStatusEnum::STATUS_INVALID]['message'] + $validationResult->getMessages()[Resolver::STATUS_INVALID]['message'] ); $this->assertNull($result); } @@ -108,16 +103,12 @@ public function testRSAKey() Environment::setEnv('JWT_SIGNER_KEY', "{$keys}/private.key"); Environment::setEnv('JWT_PUBLIC_KEY', "{$keys}/public.pub"); - $createToken = CreateTokenMutationCreator::singleton(); - - $response = $createToken->resolve( + $response = Resolver::resolveCreateToken( null, - ['Email' => 'admin@silverstripe.com', 'Password' => 'error'], - [], - new ResolveInfo([]) + ['email' => 'admin@silverstripe.com', 'password' => 'error'] ); - $token = $response['Token']; + $token = $response['token']; $authenticator = JWTAuthenticator::singleton(); $request = clone Controller::curr()->getRequest(); diff --git a/tests/unit/RefreshTokenMutationCreatorTest.php b/tests/unit/RefreshTokenTest.php similarity index 58% rename from tests/unit/RefreshTokenMutationCreatorTest.php rename to tests/unit/RefreshTokenTest.php index 3166672..caa759a 100644 --- a/tests/unit/RefreshTokenMutationCreatorTest.php +++ b/tests/unit/RefreshTokenTest.php @@ -3,9 +3,9 @@ namespace Firesphere\GraphQLJWT\Tests; use Firesphere\GraphQLJWT\Authentication\AnonymousUserAuthenticator; +use Firesphere\GraphQLJWT\Authentication\CustomAuthenticatorRegistry; use Firesphere\GraphQLJWT\Authentication\JWTAuthenticator; -use Firesphere\GraphQLJWT\Mutations\CreateTokenMutationCreator; -use Firesphere\GraphQLJWT\Mutations\RefreshTokenMutationCreator; +use Firesphere\GraphQLJWT\Resolvers\Resolver; use GraphQL\Type\Definition\ResolveInfo; use SilverStripe\Control\Controller; use SilverStripe\Control\Session; @@ -16,7 +16,7 @@ use SilverStripe\ORM\ValidationException; use SilverStripe\Security\Member; -class RefreshTokenMutationCreatorTest extends SapphireTest +class RefreshTokenTest extends SapphireTest { protected static $fixture_file = '../fixtures/JWTAuthenticatorTest.yml'; @@ -26,9 +26,6 @@ class RefreshTokenMutationCreatorTest extends SapphireTest protected $anonymousToken; - /** - * @throws ValidationException - */ public function setUp() { Environment::setENv('JWT_SIGNER_KEY', 'test_signer'); @@ -37,29 +34,25 @@ public function setUp() $this->member = $this->objFromFixture(Member::class, 'admin'); // Enable anonymous authentication for this test - $createToken = CreateTokenMutationCreator::singleton(); - $createToken->setCustomAuthenticators([AnonymousUserAuthenticator::singleton()]); + Injector::inst()->get(CustomAuthenticatorRegistry::class) + ->setCustomAuthenticators([AnonymousUserAuthenticator::singleton()]); // Requires to be an expired token Config::modify()->set(JWTAuthenticator::class, 'nbf_expiration', -5); // Normal token - $response = $createToken->resolve( + $response = Resolver::resolveCreateToken( null, - ['Email' => 'admin@silverstripe.com', 'Password' => 'error'], - [], - new ResolveInfo([]) + ['email' => 'admin@silverstripe.com', 'password' => 'error'] ); - $this->token = $response['Token']; + $this->token = $response['token']; // Anonymous token - $response = $createToken->resolve( + $response = Resolver::resolveCreateToken( null, - ['Email' => 'anonymous'], - [], - new ResolveInfo([]) + ['email' => 'anonymous'] ); - $this->anonymousToken = $response['Token']; + $this->anonymousToken = $response['token']; } public function tearDown() @@ -83,21 +76,19 @@ public function testRefreshToken() { $this->buildRequest(); - $queryCreator = Injector::inst()->get(RefreshTokenMutationCreator::class); - $response = $queryCreator->resolve(null, [], [], new ResolveInfo([])); + $response = Resolver::resolveRefreshToken(); - $this->assertNotNull($response['Token']); - $this->assertInstanceOf(Member::class, $response['Member']); + $this->assertNotNull($response['token']); + $this->assertInstanceOf(Member::class, $response['member']); } public function testAnonRefreshToken() { $this->buildRequest(true); - $queryCreator = Injector::inst()->get(RefreshTokenMutationCreator::class); - $response = $queryCreator->resolve(null, [], [], new ResolveInfo([])); + $response = Resolver::resolveRefreshToken(); - $this->assertNotNull($response['Token']); - $this->assertInstanceOf(Member::class, $response['Member']); + $this->assertNotNull($response['token']); + $this->assertInstanceOf(Member::class, $response['member']); } } diff --git a/tests/unit/ValidateTokenQueryCreatorTest.php b/tests/unit/ValidateTokenTest.php similarity index 54% rename from tests/unit/ValidateTokenQueryCreatorTest.php rename to tests/unit/ValidateTokenTest.php index 476d70d..9669679 100644 --- a/tests/unit/ValidateTokenQueryCreatorTest.php +++ b/tests/unit/ValidateTokenTest.php @@ -4,9 +4,7 @@ use Exception; use Firesphere\GraphQLJWT\Authentication\JWTAuthenticator; -use Firesphere\GraphQLJWT\Mutations\CreateTokenMutationCreator; -use Firesphere\GraphQLJWT\Queries\ValidateTokenQueryCreator; -use Firesphere\GraphQLJWT\Types\TokenStatusEnum; +use Firesphere\GraphQLJWT\Resolvers\Resolver; use GraphQL\Type\Definition\ResolveInfo; use SilverStripe\Control\Controller; use SilverStripe\Control\Session; @@ -16,7 +14,7 @@ use SilverStripe\ORM\ValidationException; use SilverStripe\Security\Member; -class ValidateTokenQueryCreatorTest extends SapphireTest +class ValidateTokenTest extends SapphireTest { protected static $fixture_file = '../fixtures/JWTAuthenticatorTest.yml'; @@ -24,25 +22,18 @@ class ValidateTokenQueryCreatorTest extends SapphireTest protected $token; - /** - * @throws ValidationException - */ public function setUp() { Environment::putEnv('JWT_SIGNER_KEY=test_signer'); parent::setUp(); $this->member = $this->objFromFixture(Member::class, 'admin'); - $createToken = CreateTokenMutationCreator::singleton(); - - $response = $createToken->resolve( + $response = Resolver::resolveCreateToken( null, - ['Email' => 'admin@silverstripe.com', 'Password' => 'error'], - [], - new ResolveInfo([]) + ['email' => 'admin@silverstripe.com', 'password' => 'error'] ); - $this->token = $response['Token']; + $this->token = $response['token']; } public function tearDown() @@ -67,41 +58,34 @@ public function testValidateToken() { $this->buildRequest(); - $queryCreator = ValidateTokenQueryCreator::singleton(); - $response = $queryCreator->resolve(null, [], [], new ResolveInfo([])); + $response = Resolver::resolveValidateToken(); - $this->assertTrue($response['Valid']); + $this->assertTrue($response['valid']); } /** - * @throws ValidationException * @throws Exception */ public function testExpiredToken() { Config::modify()->set(JWTAuthenticator::class, 'nbf_expiration', -5); - $createToken = CreateTokenMutationCreator::singleton(); - - $response = $createToken->resolve( + $response = Resolver::resolveCreateToken( null, - ['Email' => 'admin@silverstripe.com', 'Password' => 'error'], - [], - new ResolveInfo([]) + ['email' => 'admin@silverstripe.com', 'password' => 'error'] ); - $this->token = $response['Token']; + $this->token = $response['token']; $this->buildRequest(); - $queryCreator = ValidateTokenQueryCreator::singleton(); - $response = $queryCreator->resolve(null, [], [], new ResolveInfo([])); + $response = Resolver::resolveValidateToken(); - $this->assertFalse($response['Valid']); - $this->assertEquals(TokenStatusEnum::STATUS_EXPIRED, $response['Status']); - $this->assertEquals(401, $response['Code']); + $this->assertFalse($response['valid']); + $this->assertEquals(Resolver::STATUS_EXPIRED, $response['status']); + $this->assertEquals(401, $response['code']); $this->assertEquals( 'Token is expired, please renew your token with a refreshToken query', - $response['Message'] + $response['message'] ); } }