diff --git a/.craftplugin b/.craftplugin new file mode 100644 index 0000000..c33ef9c --- /dev/null +++ b/.craftplugin @@ -0,0 +1 @@ +{"pluginName":"GraphQL Authentication","pluginDescription":"GraphQL authentication for your headless Craft CMS applications.","pluginVersion":"1.0.0","pluginAuthorName":"James Edmonston","pluginVendorName":"jamesedmonston","pluginAuthorUrl":"jamesedmonston.co.uk","pluginAuthorGithub":"jamesedmonston/graphql-authentication","codeComments":"","pluginComponents":"","consolecommandName":"","controllerName":"","cpsectionName":"","elementName":"","fieldName":"","modelName":"","purchasableName":"","recordName":"","serviceName":"","taskName":"","utilityName":"","widgetName":"","apiVersion":"api_version_3_0"} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a17970c --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# CRAFT ENVIRONMENT +.env.php +.env.sh +.env + +# COMPOSER +/vendor + +# BUILD FILES +/bower_components/* +/node_modules/* +/build/* +/yarn-error.log + +# MISC FILES +.cache +.DS_Store +.idea +.project +.settings +*.esproj +*.sublime-workspace +*.sublime-project +*.tmproj +*.tmproject +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +config.codekit3 +prepros-6.config diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..695a43a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# GraphQL Authentication Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). + +## 1.0.0 - 2020-10-29 + +### Added + +- Initial release diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..30e7beb --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright (c) 2020 James Edmonston + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f913aa --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# GraphQL Authentication plugin for Craft CMS 3.5+ + +GraphQL authentication for your headless Craft CMS applications. + +## Requirements + +This plugin requires Craft CMS 3.5 or later. + +## Installation + +To install the plugin, follow these instructions. + +1. Open your terminal and go to your Craft project: + + cd /path/to/project + +2. Then tell Composer to load the plugin: + + composer require jamesedmonston/graphql-authentication + +3. In the Control Panel, go to Settings → Plugins and click the “Install” button for GraphQL Authentication. + +## GraphQL Authentication Overview + +-Insert text here- + +## Configuring GraphQL Authentication + +-Insert text here- + +## Using GraphQL Authentication + +-Insert text here- + +## GraphQL Authentication Roadmap + +Some things to do, and ideas for potential features: + +- Release it + +Brought to you by [James Edmonston](https://github.com/jamesedmonston) diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..09a700d --- /dev/null +++ b/composer.json @@ -0,0 +1,43 @@ +{ + "name": "jamesedmonston/graphql-authentication", + "description": "GraphQL authentication for your headless Craft CMS applications.", + "type": "craft-plugin", + "version": "1.0.0", + "keywords": [ + "craft", + "cms", + "craftcms", + "craft-plugin", + "graphql", + "authentication", + "graphql-authentication" + ], + "support": { + "docs": "https://github.com/jamesedmonston/graphql-authentication/blob/master/README.md", + "issues": "https://github.com/jamesedmonston/graphql-authentication/issues" + }, + "license": "MIT", + "authors": [ + { + "name": "James Edmonston", + "homepage": "jamesedmonston.co.uk" + } + ], + "require": { + "craftcms/cms": "^3.5.0" + }, + "autoload": { + "psr-4": { + "jamesedmonston\\graphqlauthentication\\": "src/" + } + }, + "extra": { + "name": "GraphQL Authentication", + "handle": "graphql-authentication", + "developer": "James Edmonston", + "developerUrl": "https://github.com/jamesedmonston", + "documentationUrl": "https://github.com/jamesedmonston/graphql-authentication/blob/master/README.md", + "changelogUrl": "https://raw.githubusercontent.com/jamesedmonston/graphql-authentication/master/CHANGELOG.md", + "class": "jamesedmonston\\graphqlauthentication\\GraphqlAuthentication" + } +} diff --git a/src/GraphqlAuthentication copy.php b/src/GraphqlAuthentication copy.php new file mode 100644 index 0000000..c17b21d --- /dev/null +++ b/src/GraphqlAuthentication copy.php @@ -0,0 +1,788 @@ +_isSchemaSet()) { + return; + } + + $event->queries['getUser'] = [ + 'type' => UserType::generateType(User::class), + 'description' => 'Gets authenticated user.', + 'args' => [], + 'resolve' => function () { + $token = $this->_getHeaderToken(); + $user = Craft::$app->getUsers()->getUserById($this->_extractUserId($token)); + + if (!$user) { + throw new Error("We couldn't find any matching users"); + } + + return $user; + }, + ]; + } + + public function registerGqlMutations(Event $event) + { + if (!$this->_isSchemaSet()) { + return; + } + + $tokenAndUser = Type::nonNull( + GqlEntityRegistry::createEntity('Auth', new ObjectType([ + 'name' => 'Auth', + 'fields' => [ + 'accessToken' => Type::nonNull(Type::string()), + 'user' => UserType::generateType(User::class), + ], + ])) + ); + + $event->mutations['authenticate'] = [ + 'type' => $tokenAndUser, + 'description' => 'Logs a user in. Returns user and token.', + 'args' => [ + 'email' => Type::nonNull(Type::string()), + 'password' => Type::nonNull(Type::string()), + ], + 'resolve' => function ($source, array $arguments) { + $email = $arguments['email']; + $password = $arguments['password']; + $user = Craft::$app->getUsers()->getUserByUsernameOrEmail($email); + $error = "We couldn't log you in with the provided details"; + + if (!$user) { + throw new Error($error); + } + + $userPermissions = Craft::$app->getUserPermissions()->getPermissionsByUserId($user->id); + + if (!in_array('accessCp', $userPermissions)) { + Craft::$app->getUserPermissions()->saveUserPermissions($user->id, array_merge($userPermissions, ['accessCp'])); + } + + if (!$user->authenticate($password)) { + Craft::$app->getUserPermissions()->saveUserPermissions($user->id, $userPermissions); + throw new Error($error); + } + + Craft::$app->getUserPermissions()->saveUserPermissions($user->id, $userPermissions); + + return [ + 'accessToken' => $this->_generateToken($user), + 'user' => $user, + ]; + }, + ]; + + $event->mutations['register'] = [ + 'type' => $tokenAndUser, + 'description' => 'Registers a user. Returns user and token.', + 'args' => [ + 'email' => Type::nonNull(Type::string()), + 'password' => Type::nonNull(Type::string()), + 'firstName' => Type::nonNull(Type::string()), + 'lastName' => Type::nonNull(Type::string()), + ], + 'resolve' => function ($source, array $arguments) { + $email = $arguments['email']; + $password = $arguments['password']; + $firstName = $arguments['firstName']; + $lastName = $arguments['lastName']; + + $user = new User(); + $user->username = $email; + $user->email = $email; + $user->newPassword = $password; + $user->firstName = $firstName; + $user->lastName = $lastName; + + if (!Craft::$app->getElements()->saveElement($user)) { + throw new Error(json_encode($user->getErrors())); + } + + if ($this->getSettings()->userGroup) { + Craft::$app->getUsers()->assignUserToGroups($user->id, [$this->getSettings()->userGroup]); + } + + return [ + 'accessToken' => $this->_generateToken($user), + 'user' => $user, + ]; + }, + ]; + + $event->mutations['forgottenPassword'] = [ + 'type' => Type::nonNull(Type::string()), + 'description' => "Sends a password reset email to the user's email address. Returns success message.", + 'args' => [ + 'email' => Type::nonNull(Type::string()), + ], + 'resolve' => function ($source, array $arguments) { + $email = $arguments['email']; + $user = Craft::$app->getUsers()->getUserByUsernameOrEmail($email); + $message = 'You will receive an email if it matches an account in our system'; + + if (!$user) { + return $message; + } + + Craft::$app->getUsers()->sendPasswordResetEmail($user); + + return $message; + }, + ]; + + $event->mutations['setPassword'] = [ + 'type' => Type::nonNull(Type::string()), + 'description' => 'Sets password for unauthenticated users. Requires `code` and `id` from Craft reset password email. Returns success message.', + 'args' => [ + 'password' => Type::nonNull(Type::string()), + 'code' => Type::nonNull(Type::string()), + 'id' => Type::nonNull(Type::string()), + ], + 'resolve' => function ($source, array $arguments) { + $password = $arguments['password']; + $code = $arguments['code']; + $id = $arguments['id']; + + $user = Craft::$app->getUsers()->getUserByUid($id); + + if (!$user || !Craft::$app->getUsers()->isVerificationCodeValidForUser($user, $code)) { + throw new Error('Cannot validate request'); + } + + $user->newPassword = $password; + + if (!Craft::$app->getElements()->saveElement($user)) { + throw new Error(json_encode($user->getErrors())); + } + + return 'Successfully saved password'; + }, + ]; + + $event->mutations['updatePassword'] = [ + 'type' => Type::nonNull(Type::string()), + 'description' => 'Updates password for authenticated users. Requires access token and current password. Returns success message.', + 'args' => [ + 'currentPassword' => Type::nonNull(Type::string()), + 'newPassword' => Type::nonNull(Type::string()), + 'confirmPassword' => Type::nonNull(Type::string()), + ], + 'resolve' => function ($source, array $arguments) { + $token = $this->_getHeaderToken(); + $user = Craft::$app->getUsers()->getUserById($this->_extractUserId($token)); + $error = "We couldn't update the password with the provided details"; + + if (!$user) { + throw new Error($error); + } + + $newPassword = $arguments['newPassword']; + $confirmPassword = $arguments['confirmPassword']; + + if ($newPassword !== $confirmPassword) { + throw new Error('New passwords do not match'); + } + + $currentPassword = $arguments['currentPassword']; + + if (!$user->authenticate($currentPassword)) { + throw new Error($error); + } + + $user->newPassword = $newPassword; + + if (!Craft::$app->getElements()->saveElement($user)) { + throw new Error(json_encode($user->getErrors())); + } + + return 'Successfully updated password'; + }, + ]; + + $event->mutations['updateUser'] = [ + 'type' => UserType::generateType(User::class), + 'description' => 'Updates authenticated user. Returns user.', + 'args' => [ + 'email' => Type::string(), + 'firstName' => Type::string(), + 'lastName' => Type::string(), + ], + 'resolve' => function ($source, array $arguments) { + $token = $this->_getHeaderToken(); + $user = Craft::$app->getUsers()->getUserById($this->_extractUserId($token)); + + if (!$user) { + throw new Error("We couldn't update the user with the provided details"); + } + + $email = $arguments['email']; + $firstName = $arguments['firstName']; + $lastName = $arguments['lastName']; + + if ($email) { + $user->username = $email; + $user->email = $email; + } + + if ($firstName) { + $user->firstName = $firstName; + } + + if ($lastName) { + $user->lastName = $lastName; + } + + if (!Craft::$app->getElements()->saveElement($user)) { + throw new Error(json_encode($user->getErrors())); + } + + return $user; + }, + ]; + + $event->mutations['deleteCurrentToken'] = [ + 'type' => Type::nonNull(Type::boolean()), + 'description' => 'Deletes authenticated user access token. Useful for logging out of current device. Returns boolean.', + 'args' => [], + 'resolve' => function () { + $token = $this->_getHeaderToken(); + + if (!$token) { + throw new Error("We couldn't find any matching tokens"); + } + + Craft::$app->getGql()->deleteTokenById($token->id); + + return true; + }, + ]; + + $event->mutations['deleteAllTokens'] = [ + 'type' => Type::nonNull(Type::boolean()), + 'description' => 'Deletes all access tokens belonging to the authenticated user. Useful for logging out of all devices. Returns boolean.', + 'args' => [], + 'resolve' => function () { + $token = $this->_getHeaderToken(); + $user = Craft::$app->getUsers()->getUserById($this->_extractUserId($token)); + $error = "We couldn't find any matching tokens"; + + if (!$user) { + throw new Error($error); + } + + $savedTokens = Craft::$app->getGql()->getTokens(); + + if (!$savedTokens || !count($savedTokens)) { + throw new Error($error); + } + + foreach ($savedTokens as $savedToken) { + if (strpos($savedToken->name, "user-{$user->id}") !== false) { + Craft::$app->getGql()->deleteTokenById($savedToken->id); + } + } + + return true; + }, + ]; + } + + public function restrictQueries(ExecuteGqlQueryEvent $event) + { + $query = $event->query; + + $publicMutations = [ + '/authenticate\(/', + '/authenticate\s+\(/', + '/register\(/', + '/register\s+\(/', + '/forgottenPassword\(/', + '/forgottenPassword\s+\(/', + '/setPassword\(/', + '/setPassword\s+\(/' + ]; + + $isPublicMutation = false; + + foreach ($publicMutations as $publicMutation) { + preg_match($publicMutation, $query, $matches); + + if (count($matches)) { + $isPublicMutation = true; + } + } + + if ($isPublicMutation) { + return; + } + + $token = $this->_getHeaderToken(); + $userId = $this->_extractUserId($token); + $settings = $this->getSettings(); + $variables = $event->variables; + + // add `authorId` to `entry` queries + $queryRewrites = [ + ['/entries\(/', 'entries('], + ['/entries\s+\(/', 'entries('], + ['/entry\(/', 'entry('], + ['/entry\s+\(/', 'entry('], + ['/entryCount\(/', 'entryCount('], + ['/entryCount\s+\(/', 'entryCount('], + ]; + + $authorOnlySections = $settings->queries ?? []; + + foreach ($queryRewrites as $queryRewrite) { + preg_match($queryRewrite[0], $query, $matches); + + if (!count($matches)) { + continue; + } + + // if (!isset($variables['section']) && !isset($variables['sectionId'])) { + // throw new Error('Query must supply either a `section` or `sectionId` variable'); + // } + + foreach ($authorOnlySections as $section => $value) { + if (!(bool) $value) { + continue; + } + + if (isset($variables['section']) && trim($variables['section']) !== $section) { + continue; + } + + if (isset($variables['sectionId']) && trim((string) $variables['sectionId']) !== Craft::$app->getSections()->getSectionByHandle($section)->id) { + continue; + } + + $query = preg_replace($queryRewrite[0], "{$queryRewrite[1]}authorId:{$userId},", $query); + } + } + + // always add `authorId` to empty `entry` queries + $fallbackRewrites = [ + ['/entries\{/', 'entries'], + ['/entries\s+\{/', 'entries'], + ['/entry\{/', 'entry'], + ['/entry\s+\{/', 'entry'], + ]; + + foreach ($fallbackRewrites as $fallbackRewrite) { + preg_match($fallbackRewrite[0], $query, $matches); + + if (!count($matches)) { + continue; + } + + $query = preg_replace($fallbackRewrite[0], "{$fallbackRewrite[1]}(authorId:{$userId}) {", $query); + } + + // always add `authorId` to empty `entryCount` queries + $fallbackRewrites = [ + '/entryCount}/', + '/entryCount\s+}/', + ]; + + foreach ($fallbackRewrites as $fallbackRewrite) { + preg_match($fallbackRewrite, $query, $matches); + + if (!count($matches)) { + continue; + } + + $query = preg_replace($fallbackRewrite, "entryCount(authorId:{$userId})}", $query); + } + + // add `uploader` to `asset` queries + $queryRewrites = [ + ['/assets\(/', 'assets('], + ['/assets\s+\(/', 'assets('], + ['/asset\(/', 'asset('], + ['/asset\s+\(/', 'asset('], + ['/assetCount\(/', 'assetCount('], + ['/assetCount\s+\(/', 'assetCount('], + ]; + + foreach ($queryRewrites as $queryRewrite) { + preg_match($queryRewrite[0], $query, $matches); + + if (!count($matches)) { + continue; + } + + $query = preg_replace($queryRewrite[0], "{$queryRewrite[1]}uploader:{$userId},", $query); + } + + // always add `uploader` to empty `asset` queries + $fallbackRewrites = [ + ['/assets\{/', 'assets'], + ['/assets\s+\{/', 'assets'], + ['/asset\{/', 'asset'], + ['/asset\s+\{/', 'asset'], + ]; + + foreach ($fallbackRewrites as $fallbackRewrite) { + preg_match($fallbackRewrite[0], $query, $matches); + + if (!count($matches)) { + continue; + } + + $query = preg_replace($fallbackRewrite[0], "{$fallbackRewrite[1]}(uploader:{$userId}) {", $query); + } + + // always add `uploader` to empty `assetCount` queries + $fallbackRewrites = [ + '/assetCount}/', + '/assetCount\s+}/', + ]; + + foreach ($fallbackRewrites as $fallbackRewrite) { + preg_match($fallbackRewrite, $query, $matches); + + if (!count($matches)) { + continue; + } + + $query = preg_replace($fallbackRewrite, "assetCount(uploader:{$userId})}", $query); + } + + $event->result = [$query]; + + // $event->result = GraphQL::executeQuery( + // Craft::$app->getGql()->getSchemaDef($token->getSchema()), + // $query, + // $event->rootValue, + // $event->context, + // $event->variables, + // $event->operationName, + // null, + // Craft::$app->getGql()->getValidationRules(false) + // )->toArray(false); + + // $event->result = Craft::$app->getGql()->executeQuery($token->getSchema(), $query, $variables, $event->operationName, false)->toArray(false); + } + + public function restrictMutations(ModelEvent $event) + { + if (!Craft::$app->getRequest()->getBodyParam('query')) { + return; + } + + $token = $this->_getHeaderToken(); + $userId = $this->_extractUserId($token); + + if ($event->isNew) { + $event->sender->authorId = $userId; + return; + } + + $authorOnlySections = $this->getSettings()->mutations ?? []; + $entrySection = Craft::$app->getSections()->getSectionById($event->sender->sectionId)->handle; + + if (in_array($entrySection, array_keys($authorOnlySections))) { + foreach ($authorOnlySections as $key => $value) { + if (!(bool) $value || $key !== $entrySection) { + continue; + } + + if ($userId !== $event->sender->authorId) { + throw new Error("User doesn't have permission to perform this mutation"); + } + } + } + } + + // Protected Methods + // ========================================================================= + + protected function _isSchemaSet(): bool + { + return (bool) isset($this->getSettings()->schemaId); + } + + protected function _getHeaderToken(): GqlToken + { + $request = Craft::$app->getRequest(); + $requestHeaders = $request->getHeaders(); + + foreach ($requestHeaders->get('authorization', [], false) as $authHeader) { + $authValues = array_map('trim', explode(',', $authHeader)); + + foreach ($authValues as $authValue) { + if (preg_match('/^Bearer\s+(.+)$/i', $authValue, $matches)) { + try { + $token = Craft::$app->getGql()->getTokenByAccessToken($matches[1]); + } catch (InvalidArgumentException $e) { + throw new InvalidArgumentException($e); + } + + if (!$token) { + throw new BadRequestHttpException('Invalid Authorization header'); + } + + break 2; + } + } + } + + if (!isset($token)) { + throw new BadRequestHttpException('Missing Authorization header'); + } + + if (strtotime(date('y-m-d H:i:s')) >= strtotime($token->expiryDate->format('y-m-d H:i:s'))) { + throw new BadRequestHttpException('Invalid Authorization header'); + } + + return $token; + } + + protected function _generateToken(User $user): string + { + if (!$this->_isSchemaSet()) { + throw new Error('No schema has been created'); + } + + $settings = $this->getSettings(); + $accessToken = Craft::$app->getSecurity()->generateRandomString(32); + $time = time(); + + $fields = [ + 'name' => "user-{$user->id}-{$time}", + 'accessToken' => $accessToken, + 'enabled' => true, + 'schemaId' => $settings->schemaId, + ]; + + if ($settings->expiration) { + $fields['expiryDate'] = (new DateTime())->modify("+ {$settings->expiration}"); + } + + $token = new GqlToken($fields); + + if (!Craft::$app->getGql()->saveToken($token)) { + throw new Error(json_encode($token->getErrors())); + } + + return $accessToken; + } + + protected function _extractUserId(GqlToken $token): string + { + return explode('-', $token->name)[1]; + } + + protected function createSettingsModel() + { + return new Settings(); + } + + protected function settingsHtml() + { + $settings = $this->getSettings(); + $userGroups = Craft::$app->getUserGroups()->getAllGroups(); + $schemas = Craft::$app->getGql()->getSchemas(); + $publicSchema = Craft::$app->getGql()->getPublicSchema(); + + $userOptions = [ + [ + 'label' => '', + 'value' => '', + ] + ]; + + foreach ($userGroups as $userGroup) { + $userOptions[] = [ + 'label' => $userGroup->name, + 'value' => $userGroup->id, + ]; + } + + $schemaOptions = [ + [ + 'label' => '', + 'value' => '', + ] + ]; + + foreach ($schemas as $schema) { + if ($publicSchema && $schema->id === $publicSchema->id) { + continue; + } + + $schemaOptions[] = [ + 'label' => $schema->name, + 'value' => $schema->id, + ]; + } + + $queries = null; + $mutations = null; + + if ($settings->schemaId) { + $selectedSchema = Craft::$app->getGql()->getSchemaById($settings->schemaId); + $entryTypes = Craft::$app->getSections()->getAllEntryTypes(); + $queries = []; + $mutations = []; + + $scopes = array_filter($selectedSchema->scope, function ($key) { + return strpos($key, 'entrytypes') !== false; + }); + + foreach ($scopes as $scope) { + $scopeId = explode(':', explode('.', $scope)[1])[0]; + + $entryType = array_values(array_filter($entryTypes, function ($type) use ($scopeId) { + return $type['uid'] === $scopeId; + }))[0]; + + $name = $entryType->name; + $handle = $entryType->handle; + + if (strpos($scope, ':read') !== false) { + if (isset($queries[$name])) { + continue; + } + + $queries[$name] = [ + 'label' => $name, + 'handle' => $handle, + ]; + + continue; + } + + if (isset($mutations[$name])) { + continue; + } + + $mutations[$name] = [ + 'label' => $name, + 'handle' => $handle, + ]; + } + } + + return Craft::$app->getView()->renderTemplate('graphql-authentication/index', [ + 'settings' => $settings, + 'userOptions' => $userOptions, + 'schemaOptions' => $schemaOptions, + 'queries' => $queries, + 'mutations' => $mutations, + ]); + } +} diff --git a/src/GraphqlAuthentication.php b/src/GraphqlAuthentication.php new file mode 100644 index 0000000..001fb3e --- /dev/null +++ b/src/GraphqlAuthentication.php @@ -0,0 +1,696 @@ +_isSchemaSet()) { + return; + } + + $event->queries['entries'] = [ + 'description' => 'This query is used to query for entries.', + 'type' => Type::listOf(EntryInterface::getType()), + 'args' => EntryArguments::getArguments(), + 'resolve' => EntryResolver::class . '::resolve', + ]; + + $event->queries['entry'] = [ + 'description' => 'This query is used to query for a single entry.', + 'type' => EntryInterface::getType(), + 'args' => EntryArguments::getArguments(), + 'resolve' => EntryResolver::class . '::resolveOne', + ]; + + $event->queries['entryCount'] = [ + 'description' => 'This query is used to return the number of entries.', + 'type' => Type::nonNull(Type::int()), + 'args' => EntryArguments::getArguments(), + 'resolve' => EntryResolver::class . '::resolveCount', + ]; + + $event->queries['assets'] = [ + 'description' => 'This query is used to query for assets.', + 'type' => Type::listOf(AssetInterface::getType()), + 'args' => AssetArguments::getArguments(), + 'resolve' => AssetResolver::class . '::resolve', + ]; + + $event->queries['asset'] = [ + 'description' => 'This query is used to query for a single asset.', + 'type' => AssetInterface::getType(), + 'args' => AssetArguments::getArguments(), + 'resolve' => AssetResolver::class . '::resolveOne', + ]; + + $event->queries['assetCount'] = [ + 'description' => 'This query is used to return the number of assets.', + 'type' => Type::nonNull(Type::int()), + 'args' => AssetArguments::getArguments(), + 'resolve' => AssetResolver::class . '::resolveCount', + ]; + + $event->queries['getUser'] = [ + 'description' => 'Gets authenticated user.', + 'type' => UserType::generateType(User::class), + 'args' => [], + 'resolve' => function () { + $user = $this->getUserFromToken(); + + if (!$user) { + throw new Error("We couldn't find any matching users"); + } + + return $user; + }, + ]; + } + + public function registerGqlMutations(Event $event) + { + if (!$this->_isSchemaSet()) { + return; + } + + $tokenAndUser = Type::nonNull( + GqlEntityRegistry::createEntity('Auth', new ObjectType([ + 'name' => 'Auth', + 'fields' => [ + 'accessToken' => Type::nonNull(Type::string()), + 'user' => UserType::generateType(User::class), + ], + ])) + ); + + $event->mutations['authenticate'] = [ + 'description' => 'Logs a user in. Returns user and token.', + 'type' => $tokenAndUser, + 'args' => [ + 'email' => Type::nonNull(Type::string()), + 'password' => Type::nonNull(Type::string()), + ], + 'resolve' => function ($source, array $arguments) { + $email = $arguments['email']; + $password = $arguments['password']; + $user = Craft::$app->getUsers()->getUserByUsernameOrEmail($email); + $error = "We couldn't log you in with the provided details"; + + if (!$user) { + throw new Error($error); + } + + $userPermissions = Craft::$app->getUserPermissions()->getPermissionsByUserId($user->id); + + if (!in_array('accessCp', $userPermissions)) { + Craft::$app->getUserPermissions()->saveUserPermissions($user->id, array_merge($userPermissions, ['accessCp'])); + } + + if (!$user->authenticate($password)) { + Craft::$app->getUserPermissions()->saveUserPermissions($user->id, $userPermissions); + throw new Error($error); + } + + Craft::$app->getUserPermissions()->saveUserPermissions($user->id, $userPermissions); + + return [ + 'accessToken' => $this->_generateToken($user), + 'user' => $user, + ]; + }, + ]; + + $event->mutations['register'] = [ + 'description' => 'Registers a user. Returns user and token.', + 'type' => $tokenAndUser, + 'args' => [ + 'email' => Type::nonNull(Type::string()), + 'password' => Type::nonNull(Type::string()), + 'firstName' => Type::nonNull(Type::string()), + 'lastName' => Type::nonNull(Type::string()), + ], + 'resolve' => function ($source, array $arguments) { + $email = $arguments['email']; + $password = $arguments['password']; + $firstName = $arguments['firstName']; + $lastName = $arguments['lastName']; + + $user = new User(); + $user->username = $email; + $user->email = $email; + $user->newPassword = $password; + $user->firstName = $firstName; + $user->lastName = $lastName; + + if (!Craft::$app->getElements()->saveElement($user)) { + throw new Error(json_encode($user->getErrors())); + } + + if ($this->getSettings()->userGroup) { + Craft::$app->getUsers()->assignUserToGroups($user->id, [$this->getSettings()->userGroup]); + } + + return [ + 'accessToken' => $this->_generateToken($user), + 'user' => $user, + ]; + }, + ]; + + $event->mutations['forgottenPassword'] = [ + 'description' => "Sends a password reset email to the user's email address. Returns success message.", + 'type' => Type::nonNull(Type::string()), + 'args' => [ + 'email' => Type::nonNull(Type::string()), + ], + 'resolve' => function ($source, array $arguments) { + $email = $arguments['email']; + $user = Craft::$app->getUsers()->getUserByUsernameOrEmail($email); + $message = 'You will receive an email if it matches an account in our system'; + + if (!$user) { + return $message; + } + + Craft::$app->getUsers()->sendPasswordResetEmail($user); + + return $message; + }, + ]; + + $event->mutations['setPassword'] = [ + 'description' => 'Sets password for unauthenticated users. Requires `code` and `id` from Craft reset password email. Returns success message.', + 'type' => Type::nonNull(Type::string()), + 'args' => [ + 'password' => Type::nonNull(Type::string()), + 'code' => Type::nonNull(Type::string()), + 'id' => Type::nonNull(Type::string()), + ], + 'resolve' => function ($source, array $arguments) { + $password = $arguments['password']; + $code = $arguments['code']; + $id = $arguments['id']; + + $user = Craft::$app->getUsers()->getUserByUid($id); + + if (!$user || !Craft::$app->getUsers()->isVerificationCodeValidForUser($user, $code)) { + throw new Error('Cannot validate request'); + } + + $user->newPassword = $password; + + if (!Craft::$app->getElements()->saveElement($user)) { + throw new Error(json_encode($user->getErrors())); + } + + return 'Successfully saved password'; + }, + ]; + + $event->mutations['updatePassword'] = [ + 'description' => 'Updates password for authenticated users. Requires access token and current password. Returns success message.', + 'type' => Type::nonNull(Type::string()), + 'args' => [ + 'currentPassword' => Type::nonNull(Type::string()), + 'newPassword' => Type::nonNull(Type::string()), + 'confirmPassword' => Type::nonNull(Type::string()), + ], + 'resolve' => function ($source, array $arguments) { + $user = $this->getUserFromToken(); + $error = "We couldn't update the password with the provided details"; + + if (!$user) { + throw new Error($error); + } + + $newPassword = $arguments['newPassword']; + $confirmPassword = $arguments['confirmPassword']; + + if ($newPassword !== $confirmPassword) { + throw new Error('New passwords do not match'); + } + + $currentPassword = $arguments['currentPassword']; + + if (!$user->authenticate($currentPassword)) { + throw new Error($error); + } + + $user->newPassword = $newPassword; + + if (!Craft::$app->getElements()->saveElement($user)) { + throw new Error(json_encode($user->getErrors())); + } + + return 'Successfully updated password'; + }, + ]; + + $event->mutations['updateUser'] = [ + 'description' => 'Updates authenticated user. Returns user.', + 'type' => UserType::generateType(User::class), + 'args' => [ + 'email' => Type::string(), + 'firstName' => Type::string(), + 'lastName' => Type::string(), + ], + 'resolve' => function ($source, array $arguments) { + $user = $this->getUserFromToken(); + + if (!$user) { + throw new Error("We couldn't update the user with the provided details"); + } + + $email = $arguments['email']; + $firstName = $arguments['firstName']; + $lastName = $arguments['lastName']; + + if ($email) { + $user->username = $email; + $user->email = $email; + } + + if ($firstName) { + $user->firstName = $firstName; + } + + if ($lastName) { + $user->lastName = $lastName; + } + + if (!Craft::$app->getElements()->saveElement($user)) { + throw new Error(json_encode($user->getErrors())); + } + + return $user; + }, + ]; + + $event->mutations['deleteCurrentToken'] = [ + 'description' => 'Deletes authenticated user access token. Useful for logging out of current device. Returns boolean.', + 'type' => Type::nonNull(Type::boolean()), + 'args' => [], + 'resolve' => function () { + $token = $this->_getHeaderToken(); + + if (!$token) { + throw new Error("We couldn't find any matching tokens"); + } + + Craft::$app->getGql()->deleteTokenById($token->id); + + return true; + }, + ]; + + $event->mutations['deleteAllTokens'] = [ + 'description' => 'Deletes all access tokens belonging to the authenticated user. Useful for logging out of all devices. Returns boolean.', + 'type' => Type::nonNull(Type::boolean()), + 'args' => [], + 'resolve' => function () { + $user = $this->getUserFromToken(); + $error = "We couldn't find any matching tokens"; + + if (!$user) { + throw new Error($error); + } + + $savedTokens = Craft::$app->getGql()->getTokens(); + + if (!$savedTokens || !count($savedTokens)) { + throw new Error($error); + } + + foreach ($savedTokens as $savedToken) { + if (strpos($savedToken->name, "user-{$user->id}") !== false) { + Craft::$app->getGql()->deleteTokenById($savedToken->id); + } + } + + return true; + }, + ]; + } + + public function restrictMutations(ModelEvent $event) + { + if (!Craft::$app->getRequest()->getBodyParam('query')) { + return; + } + + $user = $this->getUserFromToken(); + $fields = $event->sender->getFieldValues(); + $error = "User doesn't have permission to perform this mutation"; + + foreach ($fields as $key => $field) { + if (!isset($field->elementType) || !isset($field->id)) { + continue; + } + + if ($field->elementType === 'craft\\elements\\Entry') { + $entry = Craft::$app->getElements()->getElementById($field->id[0]); + + if (!$entry) { + continue; + } + + $authorOnlySections = $this->getSettings()->queries; + + if ((string) $event->sender->authorId === (string) $user->id) { + continue; + } + + foreach ($authorOnlySections as $section => $value) { + if (!(bool) $value) { + continue; + } + + if ($entry->sectionId !== Craft::$app->getSections()->getSectionByHandle($section)->id) { + continue; + } + + throw new Error($error); + } + } + + if ($field->elementType === 'craft\\elements\\Asset') { + $asset = Craft::$app->getAssets()->getAssetById($field->id[0]); + + if (!$asset || !$asset->uploaderId) { + continue; + } + + if ((string) $asset->uploader !== (string) $user->id) { + throw new Error($error); + } + } + } + + if ($event->isNew) { + $event->sender->authorId = $user->id; + return; + } + + $authorOnlySections = $this->getSettings()->mutations ?? []; + $entrySection = Craft::$app->getSections()->getSectionById($event->sender->sectionId)->handle; + + if (in_array($entrySection, array_keys($authorOnlySections))) { + foreach ($authorOnlySections as $key => $value) { + if (!(bool) $value || $key !== $entrySection) { + continue; + } + + if ((string) $event->sender->authorId !== (string) $user->id) { + throw new Error($error); + } + } + } + } + + public function getUserFromToken(): User + { + return Craft::$app->getUsers()->getUserById($this->_extractUserIdFromToken($this->_getHeaderToken())); + } + + // Protected Methods + // ========================================================================= + + protected function _isSchemaSet(): bool + { + return (bool) isset($this->getSettings()->schemaId); + } + + protected function _getHeaderToken(): GqlToken + { + $request = Craft::$app->getRequest(); + $requestHeaders = $request->getHeaders(); + + foreach ($requestHeaders->get('authorization', [], false) as $authHeader) { + $authValues = array_map('trim', explode(',', $authHeader)); + + foreach ($authValues as $authValue) { + if (preg_match('/^Bearer\s+(.+)$/i', $authValue, $matches)) { + try { + $token = Craft::$app->getGql()->getTokenByAccessToken($matches[1]); + } catch (InvalidArgumentException $e) { + throw new InvalidArgumentException($e); + } + + if (!$token) { + throw new BadRequestHttpException('Invalid Authorization header'); + } + + break 2; + } + } + } + + if (!isset($token)) { + throw new BadRequestHttpException('Missing Authorization header'); + } + + if (strtotime(date('y-m-d H:i:s')) >= strtotime($token->expiryDate->format('y-m-d H:i:s'))) { + throw new BadRequestHttpException('Invalid Authorization header'); + } + + return $token; + } + + protected function _generateToken(User $user): string + { + if (!$this->_isSchemaSet()) { + throw new Error('No schema has been created'); + } + + $settings = $this->getSettings(); + $accessToken = Craft::$app->getSecurity()->generateRandomString(32); + $time = time(); + + $fields = [ + 'name' => "user-{$user->id}-{$time}", + 'accessToken' => $accessToken, + 'enabled' => true, + 'schemaId' => $settings->schemaId, + ]; + + if ($settings->expiration) { + $fields['expiryDate'] = (new DateTime())->modify("+ {$settings->expiration}"); + } + + $token = new GqlToken($fields); + + if (!Craft::$app->getGql()->saveToken($token)) { + throw new Error(json_encode($token->getErrors())); + } + + return $accessToken; + } + + protected function _extractUserIdFromToken(GqlToken $token): string + { + return explode('-', $token->name)[1]; + } + + protected function createSettingsModel() + { + return new Settings(); + } + + protected function settingsHtml() + { + $settings = $this->getSettings(); + $userGroups = Craft::$app->getUserGroups()->getAllGroups(); + $schemas = Craft::$app->getGql()->getSchemas(); + $publicSchema = Craft::$app->getGql()->getPublicSchema(); + + $userOptions = [ + [ + 'label' => '', + 'value' => '', + ] + ]; + + foreach ($userGroups as $userGroup) { + $userOptions[] = [ + 'label' => $userGroup->name, + 'value' => $userGroup->id, + ]; + } + + $schemaOptions = [ + [ + 'label' => '', + 'value' => '', + ] + ]; + + foreach ($schemas as $schema) { + if ($publicSchema && $schema->id === $publicSchema->id) { + continue; + } + + $schemaOptions[] = [ + 'label' => $schema->name, + 'value' => $schema->id, + ]; + } + + $queries = null; + $mutations = null; + + if ($settings->schemaId) { + $selectedSchema = Craft::$app->getGql()->getSchemaById($settings->schemaId); + $entryTypes = Craft::$app->getSections()->getAllEntryTypes(); + $queries = []; + $mutations = []; + + $scopes = array_filter($selectedSchema->scope, function ($key) { + return strpos($key, 'entrytypes') !== false; + }); + + foreach ($scopes as $scope) { + $scopeId = explode(':', explode('.', $scope)[1])[0]; + + $entryType = array_values(array_filter($entryTypes, function ($type) use ($scopeId) { + return $type['uid'] === $scopeId; + }))[0]; + + $name = $entryType->name; + $handle = $entryType->handle; + + if (strpos($scope, ':read') !== false) { + if (isset($queries[$name])) { + continue; + } + + $queries[$name] = [ + 'label' => $name, + 'handle' => $handle, + ]; + + continue; + } + + if (isset($mutations[$name])) { + continue; + } + + $mutations[$name] = [ + 'label' => $name, + 'handle' => $handle, + ]; + } + } + + return Craft::$app->getView()->renderTemplate('graphql-authentication/index', [ + 'settings' => $settings, + 'userOptions' => $userOptions, + 'schemaOptions' => $schemaOptions, + 'queries' => $queries, + 'mutations' => $mutations, + ]); + } +} diff --git a/src/icon-mask.svg b/src/icon-mask.svg new file mode 100644 index 0000000..a9ca334 --- /dev/null +++ b/src/icon-mask.svg @@ -0,0 +1,71 @@ + + + + diff --git a/src/icon.svg b/src/icon.svg new file mode 100644 index 0000000..a8143c2 --- /dev/null +++ b/src/icon.svg @@ -0,0 +1,71 @@ + + + + diff --git a/src/models/Settings.php b/src/models/Settings.php new file mode 100644 index 0000000..f2e0a1b --- /dev/null +++ b/src/models/Settings.php @@ -0,0 +1,22 @@ + + * @since 3.3.0 + */ +class Asset extends ElementResolver +{ + /** + * @inheritdoc + */ + public static function prepareQuery($source, array $arguments, $fieldName = null) + { + // If this is the beginning of a resolver chain, start fresh + if ($source === null) { + $query = AssetElement::find(); + // If not, get the prepared element query + } else { + $query = $source->$fieldName; + } + + // If it's preloaded, it's preloaded. + if (is_array($query)) { + return $query; + } + + $arguments['uploader'] = GraphqlAuthentication::$plugin->getUserFromToken()->id; + + foreach ($arguments as $key => $value) { + $query->$key($value); + } + + $pairs = GqlHelper::extractAllowedEntitiesFromSchema('read'); + + if (!GqlHelper::canQueryAssets()) { + return []; + } + + $query->andWhere(['in', 'assets.volumeId', array_values(Db::idsByUids(Table::VOLUMES, $pairs['volumes']))]); + + return $query; + } +} diff --git a/src/resolvers/Entry.php b/src/resolvers/Entry.php new file mode 100644 index 0000000..9903f32 --- /dev/null +++ b/src/resolvers/Entry.php @@ -0,0 +1,80 @@ + + * @since 3.3.0 + */ +class Entry extends ElementResolver +{ + /** + * @inheritdoc + */ + public static function prepareQuery($source, array $arguments, $fieldName = null) + { + // If this is the beginning of a resolver chain, start fresh + if ($source === null) { + $query = EntryElement::find(); + // If not, get the prepared element query + } else { + $query = $source->$fieldName; + } + + // If it's preloaded, it's preloaded. + if (is_array($query)) { + return $query; + } + + $arguments['authorId'] = GraphqlAuthentication::$plugin->getUserFromToken()->id; + + if (isset($arguments['section']) || isset($arguments['sectionId'])) { + unset($arguments['authorId']); + $authorOnlySections = GraphqlAuthentication::$plugin->getSettings()->queries; + + foreach ($authorOnlySections as $section => $value) { + if (!(bool) $value) { + continue; + } + + if (isset($arguments['section']) && trim($arguments['section'][0]) !== $section) { + continue; + } + + if (isset($arguments['sectionId']) && trim((string) $arguments['sectionId'][0]) !== Craft::$app->getSections()->getSectionByHandle($section)->id) { + continue; + } + + $arguments['authorId'] = GraphqlAuthentication::$plugin->getUserFromToken()->id; + } + } + + foreach ($arguments as $key => $value) { + $query->$key($value); + } + + $pairs = GqlHelper::extractAllowedEntitiesFromSchema('read'); + + if (!GqlHelper::canQueryEntries()) { + return []; + } + + $query->andWhere(['in', 'entries.sectionId', array_values(Db::idsByUids(Table::SECTIONS, $pairs['sections']))]); + $query->andWhere(['in', 'entries.typeId', array_values(Db::idsByUids(Table::ENTRYTYPES, $pairs['entrytypes']))]); + + return $query; + } +} diff --git a/src/templates/index.twig b/src/templates/index.twig new file mode 100644 index 0000000..13ee035 --- /dev/null +++ b/src/templates/index.twig @@ -0,0 +1,102 @@ +{% import '_includes/forms' as forms %} + +{% set schemaInput = schemaOptions ? forms.selectField({ + name: 'schemaId', + options: schemaOptions, + value: settings.schemaId, + }) : tag('p', { + class: ['warning', 'with-icon'], + text: 'No schemas exist yet.'|t('app'), + }) +%} + +{{ forms.field({ + label: 'GraphQL Schema', + instructions: 'The schema that access tokens will be assigned to.', + name: 'schemaId', + required: true, + first: true, +}, schemaInput) }} + +{{ forms.selectField({ + label: 'Access Token Expiration', + instructions: 'The length of time before access tokens expire.', + name: 'expiration', + value: settings.expiration, + options: [ + { value: '', label: 'Never' }, + { value: '1 hour', label: '1 hour' }, + { value: '1 day', label: '1 day' }, + { value: '1 week', label: '1 week' }, + { value: '1 month', label: '1 month' }, + { value: '3 months', label: '3 months' }, + { value: '6 months', label: '6 months' }, + { value: '1 year', label: '1 year' }, + ], + required: true, +}) }} + +{{ forms.selectField({ + label: 'User Group', + instructions: 'The user group that users will be assigned to when created through the `register` mutation.', + name: 'userGroup', + value: settings.userGroup, + options: userOptions, +}) }} + +{% if not settings.schemaId %} + {{ forms.field({ + label: 'User Permissions', + instructions: 'Select your desired schema and save to modify user permissions.', + }, null) }} +{% else %} + {% if queries %} +
+
+ + +
+

Choose which sections are limited so that authenticated users can only query their own entries. Only sections allowed in your schema will show here.

+
+
+ +
+ {% for query in queries %} +
+ {{ forms.checkbox({ + label: query.label, + name: 'queries[' ~ query.handle ~ ']', + value: true, + checked: settings.queries[query.handle] ?? false, + }) }} +
+ {% endfor %} +
+
+ {% endif %} + + {% if mutations %} +
+
+ + +
+

Choose which sections are limited so that authenticated users can only mutate their own entries. Only sections allowed in your schema will show here.

+
+
+ +
+ {% for mutation in mutations %} +
+ {{ forms.checkbox({ + label: mutation.label, + name: 'mutations[' ~ mutation.handle ~ ']', + value: true, + checked: settings.mutations[mutation.handle] ?? false, + }) }} +
+ {% endfor %} +
+
+ {% endif %} +{% endif %}