├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── ecs.php ├── phpstan.neon └── src ├── GraphqlAuthentication.php ├── controllers └── SettingsController.php ├── elements ├── MagicCode.php ├── RefreshToken.php └── db │ ├── MagicCodeQuery.php │ └── RefreshTokenQuery.php ├── events ├── JwtCreateEvent.php └── JwtValidateEvent.php ├── gql ├── Auth.php └── Platform.php ├── icon-mask.svg ├── icon.svg ├── migrations ├── Install.php ├── m201129_224453_create_refresh_tokens.php ├── m211014_234909_schema_id_to_schema_name.php └── m230116_000217_create_magic_codes.php ├── models └── Settings.php ├── resolvers ├── Asset.php ├── Entry.php └── GlobalSet.php ├── services ├── AppleService.php ├── ErrorService.php ├── FacebookService.php ├── GoogleService.php ├── MagicService.php ├── MicrosoftService.php ├── RestrictionService.php ├── SocialService.php ├── TokenService.php ├── TwitterService.php ├── TwoFactorService.php └── UserService.php └── templates ├── _sections ├── fields.twig ├── messages.twig ├── social.twig ├── tokens.twig └── users.twig ├── magic-codes.twig ├── refresh-tokens.twig └── settings.twig /.gitignore: -------------------------------------------------------------------------------- 1 | # CRAFT ENVIRONMENT 2 | .env.php 3 | .env.sh 4 | .env 5 | 6 | # COMPOSER 7 | /vendor 8 | composer.lock 9 | 10 | # BUILD FILES 11 | /bower_components/* 12 | /node_modules/* 13 | /build/* 14 | /yarn-error.log 15 | 16 | # MISC FILES 17 | .cache 18 | .DS_Store 19 | .idea 20 | .project 21 | .settings 22 | *.esproj 23 | *.sublime-workspace 24 | *.sublime-project 25 | *.tmproj 26 | *.tmproject 27 | .vscode/* 28 | !.vscode/settings.json 29 | !.vscode/tasks.json 30 | !.vscode/launch.json 31 | !.vscode/extensions.json 32 | config.codekit3 33 | prepros-6.config 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © James Edmonston 2 | 3 | Permission is hereby granted to any person obtaining a copy of this software 4 | (the “Software”) to use, copy, modify, merge, publish and/or distribute copies 5 | of the Software, and to permit persons to whom the Software is furnished to do 6 | so, subject to the following conditions: 7 | 8 | 1. **Don’t plagiarize.** The above copyright notice and this license shall be 9 | included in all copies or substantial portions of the Software. 10 | 11 | 2. **Don’t use the same license on more than one project.** Each licensed copy 12 | of the Software shall be actively installed in no more than one production 13 | environment at a time. 14 | 15 | 3. **Don’t mess with the licensing features.** Software features related to 16 | licensing shall not be altered or circumvented in any way, including (but 17 | not limited to) license validation, payment prompts, feature restrictions, 18 | and update eligibility. 19 | 20 | 4. **Pay up.** Payment shall be made immediately upon receipt of any notice, 21 | prompt, reminder, or other message indicating that a payment is owed. 22 | 23 | 5. **Follow the law.** All use of the Software shall not violate any applicable 24 | law or regulation, nor infringe the rights of any other person or entity. 25 | 26 | Failure to comply with the foregoing conditions will automatically and 27 | immediately result in termination of the permission granted hereby. This 28 | license does not include any right to receive updates to the Software or 29 | technical support. Licensees bear all risk related to the quality and 30 | performance of the Software and any modifications made or obtained to it, 31 | including liability for actual and consequential harm, such as loss or 32 | corruption of data, and any necessary service, repair, or correction. 33 | 34 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 35 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 36 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 37 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER 38 | LIABILITY, INCLUDING SPECIAL, INCIDENTAL AND CONSEQUENTIAL DAMAGES, WHETHER IN 39 | AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 40 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GraphQL Authentication plugin for Craft CMS 4.0+ 2 | 3 | GraphQL Authentication adds a JWT authentication layer to your Craft CMS GraphQL endpoint. 4 | 5 | ## Plugin Overview 6 | 7 | - Adds support for user registration and authentication (see [Authentication](https://graphql-authentication.jamesedmonston.co.uk/usage/authentication)) 8 | - Adds support for Two-Factor Authentication (see [Two-Factor Authentication](https://graphql-authentication.jamesedmonston.co.uk/usage/two-factor-authentication)) 9 | - Adds support for Magic Link Authentication (see [Magic Authentication](https://graphql-authentication.jamesedmonston.co.uk/usage/magic-authentication)) 10 | - Adds support for social sign-in – currently Google, Facebook, Twitter, Apple, and Microsoft (see [Social](https://graphql-authentication.jamesedmonston.co.uk/usage/social)) 11 | - Adds ability to define per-section user restrictions (queries and mutations can be limited to author-only) (see [User Settings](https://graphql-authentication.jamesedmonston.co.uk/settings/users)) 12 | - Checks mutation fields against schema permissions, and prevents fields being saved if user is trying to access private entries/assets 13 | - Adds ability to assign unique schemas for each user group 14 | - Adds ability to restrict user queries and mutations to Craft multi-site sites 15 | - Adds ability to mark fields as private – stopping users from querying/mutating fields on entries 16 | - Adds a unique, per-user query cache 17 | 18 | --- 19 | 20 | Use the table below to determine which version of the plugin you should install. 21 | 22 | | Craft CMS Version | Plugin Version | 23 | | --- | --- | 24 | | 5 | 3 | 25 | | 4 | 2 | 26 | | 3 | 1 | 27 | 28 | ## Documentation 29 | 30 | You can view the documention for the plugin [here](https://graphql-authentication.jamesedmonston.co.uk). 31 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jamesedmonston/graphql-authentication", 3 | "description": "GraphQL authentication for your headless Craft CMS applications.", 4 | "type": "craft-plugin", 5 | "version": "3.0.0-RC5", 6 | "keywords": [ 7 | "craft", 8 | "cms", 9 | "craftcms", 10 | "craft-plugin", 11 | "graphql", 12 | "authentication", 13 | "graphql-authentication" 14 | ], 15 | "support": { 16 | "docs": "https://github.com/jamesedmonston/graphql-authentication/blob/master/README.md", 17 | "issues": "https://github.com/jamesedmonston/graphql-authentication/issues" 18 | }, 19 | "license": "MIT", 20 | "authors": [ 21 | { 22 | "name": "James Edmonston", 23 | "homepage": "https://jamesedmonston.co.uk" 24 | } 25 | ], 26 | "minimum-stability": "dev", 27 | "prefer-stable": true, 28 | "require": { 29 | "php": "^8.2", 30 | "abraham/twitteroauth": "^7.0", 31 | "craftcms/cms": "^5.0.0", 32 | "google/apiclient": "^2.16.0", 33 | "lcobucci/jwt": "^4.0.0 || ^5.0.0", 34 | "league/oauth2-facebook": "^2.2.0", 35 | "thenetworg/oauth2-azure": "^2.2.0" 36 | }, 37 | "require-dev": { 38 | "craftcms/ecs": "dev-main", 39 | "craftcms/phpstan": "dev-main", 40 | "craftcms/rector": "dev-main" 41 | }, 42 | "autoload": { 43 | "psr-4": { 44 | "jamesedmonston\\graphqlauthentication\\": "src/" 45 | } 46 | }, 47 | "extra": { 48 | "name": "GraphQL Authentication", 49 | "handle": "graphql-authentication", 50 | "developer": "James Edmonston", 51 | "developerUrl": "https://github.com/jamesedmonston", 52 | "documentationUrl": "https://github.com/jamesedmonston/graphql-authentication/blob/master/README.md", 53 | "changelogUrl": "https://raw.githubusercontent.com/jamesedmonston/graphql-authentication/master/CHANGELOG.md", 54 | "class": "jamesedmonston\\graphqlauthentication\\GraphqlAuthentication" 55 | }, 56 | "scripts": { 57 | "check-cs": "ecs check --ansi", 58 | "fix-cs": "ecs check --ansi --fix", 59 | "phpstan": "phpstan --memory-limit=1G" 60 | }, 61 | "config": { 62 | "allow-plugins": { 63 | "yiisoft/yii2-composer": true, 64 | "craftcms/plugin-installer": true 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /ecs.php: -------------------------------------------------------------------------------- 1 | parallel(); 10 | $ecsConfig->paths([ 11 | __DIR__ . '/src', 12 | __FILE__, 13 | ]); 14 | $ecsConfig->sets([SetList::CRAFT_CMS_4]); 15 | }; 16 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/craftcms/phpstan/phpstan.neon 3 | 4 | parameters: 5 | level: 4 6 | paths: 7 | - src 8 | earlyTerminatingMethodCalls: 9 | jamesedmonston\graphqlauthentication\services\ErrorService: 10 | - throw 11 | -------------------------------------------------------------------------------- /src/GraphqlAuthentication.php: -------------------------------------------------------------------------------- 1 | setComponents([ 162 | 'token' => TokenService::class, 163 | 'user' => UserService::class, 164 | 'restriction' => RestrictionService::class, 165 | 'social' => SocialService::class, 166 | 'google' => GoogleService::class, 167 | 'facebook' => FacebookService::class, 168 | 'twitter' => TwitterService::class, 169 | 'apple' => AppleService::class, 170 | 'microsoft' => MicrosoftService::class, 171 | 'magic' => MagicService::class, 172 | 'twoFactor' => TwoFactorService::class, 173 | 'error' => ErrorService::class, 174 | ]); 175 | 176 | $this->token->init(); 177 | $this->user->init(); 178 | $this->restriction->init(); 179 | $this->social->init(); 180 | $this->google->init(); 181 | $this->facebook->init(); 182 | $this->twitter->init(); 183 | $this->apple->init(); 184 | $this->microsoft->init(); 185 | $this->magic->init(); 186 | $this->error->init(); 187 | 188 | self::$plugin = $this; 189 | self::$tokenService = $this->token; 190 | self::$userService = $this->user; 191 | self::$restrictionService = $this->restriction; 192 | self::$socialService = $this->social; 193 | self::$googleService = $this->google; 194 | self::$facebookService = $this->facebook; 195 | self::$twitterService = $this->twitter; 196 | self::$appleService = $this->apple; 197 | self::$microsoftService = $this->microsoft; 198 | self::$errorService = $this->error; 199 | self::$magicService = $this->magic; 200 | self::$settings = $this->getSettings(); 201 | 202 | if (Craft::$app->plugins->isPluginEnabled('two-factor-authentication')) { 203 | $this->twoFactor->init(); 204 | self::$twoFactorService = $this->twoFactor; 205 | } 206 | 207 | Event::on( 208 | UrlManager::class, 209 | UrlManager::EVENT_REGISTER_CP_URL_RULES, 210 | [$this, 'onRegisterCPUrlRules'] 211 | ); 212 | 213 | Event::on( 214 | Cp::class, 215 | Cp::EVENT_REGISTER_CP_NAV_ITEMS, 216 | [$this, 'onRegisterCPNavItems'] 217 | ); 218 | } 219 | 220 | // Settings 221 | // ========================================================================= 222 | 223 | protected function createSettingsModel(): ?\craft\base\Model 224 | { 225 | return new Settings(); 226 | } 227 | 228 | public function getSettingsResponse(): mixed 229 | { 230 | return Craft::$app->controller->redirect(UrlHelper::cpUrl('graphql-authentication/settings')); 231 | } 232 | 233 | public function onRegisterCPUrlRules(RegisterUrlRulesEvent $event) 234 | { 235 | if (Craft::$app->getUser()->getIsAdmin()) { 236 | $event->rules['POST graphql-authentication/settings'] = 'graphql-authentication/settings/save'; 237 | $event->rules['graphql-authentication/settings'] = 'graphql-authentication/settings/index'; 238 | } 239 | } 240 | 241 | public function onRegisterCPNavItems(RegisterCpNavItemsEvent $event) 242 | { 243 | if (Craft::$app->getUser()->getIsAdmin()) { 244 | $event->navItems[] = [ 245 | 'url' => 'graphql-authentication/refresh-tokens', 246 | 'label' => 'JWT Refresh Tokens', 247 | 'icon' => '@jamesedmonston/graphqlauthentication/icon.svg', 248 | ]; 249 | 250 | if (self::$settings->allowMagicAuthentication) { 251 | $event->navItems[] = [ 252 | 'url' => 'graphql-authentication/magic-codes', 253 | 'label' => 'JWT Magic Codes', 254 | 'icon' => '@jamesedmonston/graphqlauthentication/icon.svg', 255 | ]; 256 | } 257 | } 258 | } 259 | 260 | public function getSettingsData(string $setting): string 261 | { 262 | if ($value = App::parseEnv($setting)) { 263 | return $value; 264 | } 265 | 266 | return $setting; 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /src/controllers/SettingsController.php: -------------------------------------------------------------------------------- 1 | getUser()->getIsAdmin()) { 23 | throw new HttpException(403); 24 | } 25 | 26 | $settings = GraphqlAuthentication::$settings; 27 | $settings->validate(); 28 | 29 | $namespace = 'settings'; 30 | $fullPageForm = true; 31 | 32 | $crumbs = [ 33 | ['label' => 'Settings', 'url' => UrlHelper::cpUrl('settings')], 34 | ]; 35 | 36 | $tabs = [ 37 | [ 38 | 'label' => 'Users', 39 | 'url' => "#settings-users", 40 | 'class' => null, 41 | ], 42 | [ 43 | 'label' => 'Tokens', 44 | 'url' => "#settings-tokens", 45 | 'class' => null, 46 | ], 47 | [ 48 | 'label' => 'Fields', 49 | 'url' => "#settings-fields", 50 | 'class' => null, 51 | ], 52 | [ 53 | 'label' => 'Social', 54 | 'url' => "#settings-social", 55 | 'class' => null, 56 | ], 57 | [ 58 | 'label' => 'Messages', 59 | 'url' => "#settings-messages", 60 | 'class' => null, 61 | ], 62 | ]; 63 | 64 | $userGroupsService = Craft::$app->getUserGroups(); 65 | $userGroups = $userGroupsService->getAllGroups(); 66 | 67 | $userOptions = [ 68 | [ 69 | 'label' => '-', 70 | 'value' => '', 71 | ], 72 | ]; 73 | 74 | foreach ($userGroups as $userGroup) { 75 | $userOptions[] = [ 76 | 'label' => $userGroup->name, 77 | 'value' => $userGroup->id, 78 | ]; 79 | } 80 | 81 | $sitesService = Craft::$app->getSites(); 82 | $sites = $sitesService->getAllSites(); 83 | 84 | $siteOptions = [ 85 | [ 86 | 'label' => 'All Sites', 87 | 'value' => '', 88 | ], 89 | ]; 90 | 91 | foreach ($sites as $site) { 92 | $siteOptions[] = [ 93 | 'label' => $site->name, 94 | 'value' => $site->id, 95 | ]; 96 | } 97 | 98 | $gqlService = Craft::$app->getGql(); 99 | $schemas = $gqlService->getSchemas(); 100 | $publicSchema = $gqlService->getPublicSchema(); 101 | 102 | $schemaOptions = [ 103 | [ 104 | 'label' => '-', 105 | 'value' => '', 106 | ], 107 | ]; 108 | 109 | foreach ($schemas as $schema) { 110 | $schemaOptions[] = [ 111 | 'label' => $schema->name, 112 | 'value' => $schema->name, 113 | ]; 114 | } 115 | 116 | asort($schemaOptions); 117 | 118 | $entryQueries = null; 119 | $entryMutations = null; 120 | $assetQueries = null; 121 | $assetMutations = null; 122 | 123 | if ($settings->permissionType === 'single' && $settings->schemaName && $settings->schemaName !== $publicSchema->name) { 124 | $schemaId = GqlSchemaRecord::find()->select(['id'])->where(['name' => $settings->schemaName])->scalar(); 125 | $schemaPermissions = $this->_getSchemaPermissions($gqlService->getSchemaById($schemaId)); 126 | $entryQueries = $schemaPermissions['entryQueries']; 127 | $entryMutations = $schemaPermissions['entryMutations']; 128 | $assetQueries = $schemaPermissions['assetQueries']; 129 | $assetMutations = $schemaPermissions['assetMutations']; 130 | } 131 | 132 | if ($settings->permissionType === 'multiple') { 133 | foreach ($userGroups as $userGroup) { 134 | $schemaName = $settings->granularSchemas['group-' . $userGroup->id]['schemaName'] ?? null; 135 | $schemaId = GqlSchemaRecord::find()->select(['id'])->where(['name' => $schemaName])->scalar(); 136 | 137 | if (!$schemaId || $schemaName === $publicSchema->name) { 138 | continue; 139 | } 140 | 141 | $schema = $gqlService->getSchemaById($schemaId); 142 | 143 | if ($schema) { 144 | $schemaPermissions = $this->_getSchemaPermissions($schema); 145 | $entryQueries['group-' . $userGroup->id] = $schemaPermissions['entryQueries']; 146 | $entryMutations['group-' . $userGroup->id] = $schemaPermissions['entryMutations']; 147 | $assetQueries['group-' . $userGroup->id] = $schemaPermissions['assetQueries']; 148 | $assetMutations['group-' . $userGroup->id] = $schemaPermissions['assetMutations']; 149 | } 150 | } 151 | } 152 | 153 | if (!$settings->jwtSecretKey) { 154 | $settings->jwtSecretKey = Craft::$app->getSecurity()->generateRandomString(32); 155 | } 156 | 157 | $fieldsServices = Craft::$app->getFields(); 158 | $fields = $fieldsServices->getAllFields(); 159 | 160 | $this->renderTemplate('graphql-authentication/settings', compact( 161 | 'settings', 162 | 'namespace', 163 | 'fullPageForm', 164 | 'crumbs', 165 | 'tabs', 166 | 'settings', 167 | 'userOptions', 168 | 'siteOptions', 169 | 'schemaOptions', 170 | 'entryQueries', 171 | 'entryMutations', 172 | 'assetQueries', 173 | 'assetMutations', 174 | 'fields' 175 | )); 176 | } 177 | 178 | protected function _getSchemaPermissions(GqlSchema $schema) 179 | { 180 | $entriesService = Craft::$app->getEntries(); 181 | $sections = $entriesService->getAllSections(); 182 | 183 | $volumesService = Craft::$app->getVolumes(); 184 | $volumes = $volumesService->getAllVolumes(); 185 | 186 | $entryQueries = []; 187 | $entryMutations = []; 188 | 189 | $scopes = array_filter($schema->scope, function($key) { 190 | return StringHelper::contains($key, 'sections'); 191 | }); 192 | 193 | foreach ($scopes as $scope) { 194 | $scopeId = explode(':', explode('.', $scope)[1])[0]; 195 | 196 | $section = array_values(array_filter($sections, function($type) use ($scopeId) { 197 | return $type['uid'] === $scopeId; 198 | }))[0] ?? null; 199 | 200 | if (!$section) { 201 | continue; 202 | } 203 | 204 | if ($section->type === 'single') { 205 | continue; 206 | } 207 | 208 | $name = $section->name; 209 | $handle = $section->handle; 210 | 211 | if (StringHelper::contains($scope, ':read')) { 212 | if (isset($entryQueries[$name])) { 213 | continue; 214 | } 215 | 216 | $entryQueries[$name] = [ 217 | 'label' => $name, 218 | 'handle' => $handle, 219 | ]; 220 | 221 | continue; 222 | } 223 | 224 | if (isset($entryMutations[$name])) { 225 | continue; 226 | } 227 | 228 | $entryMutations[$name] = [ 229 | 'label' => $name, 230 | 'handle' => $handle, 231 | ]; 232 | } 233 | 234 | $assetQueries = []; 235 | $assetMutations = []; 236 | 237 | $scopes = array_filter($schema->scope, function($key) { 238 | return StringHelper::contains($key, 'volumes'); 239 | }); 240 | 241 | foreach ($scopes as $scope) { 242 | $scopeId = explode(':', explode('.', $scope)[1])[0]; 243 | 244 | $volume = array_values(array_filter($volumes, function($type) use ($scopeId) { 245 | return $type['uid'] === $scopeId; 246 | }))[0] ?? null; 247 | 248 | if (!$volume) { 249 | continue; 250 | } 251 | 252 | $name = $volume->name; 253 | $handle = $volume->handle; 254 | 255 | if (StringHelper::contains($scope, ':read')) { 256 | if (isset($assetQueries[$name])) { 257 | continue; 258 | } 259 | 260 | $assetQueries[$name] = [ 261 | 'label' => $name, 262 | 'handle' => $handle, 263 | ]; 264 | 265 | continue; 266 | } 267 | 268 | if (isset($assetMutations[$name])) { 269 | continue; 270 | } 271 | 272 | $assetMutations[$name] = [ 273 | 'label' => $name, 274 | 'handle' => $handle, 275 | ]; 276 | } 277 | 278 | return compact( 279 | 'entryQueries', 280 | 'entryMutations', 281 | 'assetQueries', 282 | 'assetMutations' 283 | ); 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /src/elements/MagicCode.php: -------------------------------------------------------------------------------- 1 | '*', 28 | 'label' => Craft::t('graphql-authentication', 'Magic Codes'), 29 | 'criteria' => [], 30 | ], 31 | ]; 32 | } 33 | 34 | protected static function defineActions(string $source = null): array 35 | { 36 | $elementsService = Craft::$app->getElements(); 37 | 38 | return [ 39 | $elementsService->createAction([ 40 | 'type' => Delete::class, 41 | 'confirmationMessage' => Craft::t('app', 'Are you sure you want to delete the selected codes?'), 42 | 'successMessage' => Craft::t('app', 'Codes deleted.'), 43 | ]), 44 | ]; 45 | } 46 | 47 | protected static function defineTableAttributes(): array 48 | { 49 | return [ 50 | 'id' => Craft::t('graphql-authentication', 'ID'), 51 | 'code' => Craft::t('graphql-authentication', 'Code'), 52 | 'userId' => Craft::t('graphql-authentication', 'User'), 53 | 'schemaName' => Craft::t('graphql-authentication', 'Schema'), 54 | 'dateCreated' => Craft::t('graphql-authentication', 'Date Created'), 55 | 'expiryDate' => Craft::t('graphql-authentication', 'Expiry Date'), 56 | ]; 57 | } 58 | 59 | protected function attributeHtml(string $attribute): string 60 | { 61 | switch ($attribute) { 62 | case 'code': 63 | return $this->code; 64 | 65 | case 'userId': 66 | $usersService = Craft::$app->getUsers(); 67 | $user = $usersService->getUserById($this->userId); 68 | return $user ? Craft::$app->getView()->renderTemplate('_elements/element', ['element' => $user]) : ''; 69 | 70 | case 'schemaName': 71 | $gqlService = Craft::$app->getGql(); 72 | $schema = $gqlService->getSchemaById($this->schemaId); 73 | return $schema->name ?? ''; 74 | } 75 | 76 | return parent::attributeHtml($attribute); 77 | } 78 | 79 | /** 80 | * @param bool $isNew 81 | * 82 | * @throws \yii\db\Exception 83 | */ 84 | public function afterSave(bool $isNew): void 85 | { 86 | if ($isNew) { 87 | Craft::$app->db->createCommand() 88 | ->insert('{{%gql_magic_codes}}', [ 89 | 'id' => $this->id, 90 | 'code' => $this->code, 91 | 'userId' => $this->userId, 92 | 'schemaId' => $this->schemaId, 93 | 'expiryDate' => $this->expiryDate, 94 | ]) 95 | ->execute(); 96 | } else { 97 | Craft::$app->db->createCommand() 98 | ->update('{{%gql_magic_codes}}', [ 99 | 'code' => $this->code, 100 | 'userId' => $this->userId, 101 | 'schemaId' => $this->schemaId, 102 | 'expiryDate' => $this->expiryDate, 103 | ], ['id' => $this->id]) 104 | ->execute(); 105 | } 106 | 107 | parent::afterSave($isNew); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/elements/RefreshToken.php: -------------------------------------------------------------------------------- 1 | '*', 28 | 'label' => Craft::t('graphql-authentication', 'Refresh Tokens'), 29 | 'criteria' => [], 30 | ], 31 | ]; 32 | } 33 | 34 | protected static function defineActions(string $source = null): array 35 | { 36 | $elementsService = Craft::$app->getElements(); 37 | 38 | return [ 39 | $elementsService->createAction([ 40 | 'type' => Delete::class, 41 | 'confirmationMessage' => Craft::t('app', 'Are you sure you want to delete the selected tokens?'), 42 | 'successMessage' => Craft::t('app', 'Tokens deleted.'), 43 | ]), 44 | ]; 45 | } 46 | 47 | protected static function defineTableAttributes(): array 48 | { 49 | return [ 50 | 'id' => Craft::t('graphql-authentication', 'ID'), 51 | 'token' => Craft::t('graphql-authentication', 'Token'), 52 | 'userId' => Craft::t('graphql-authentication', 'User'), 53 | 'schemaName' => Craft::t('graphql-authentication', 'Schema'), 54 | 'dateCreated' => Craft::t('graphql-authentication', 'Date Created'), 55 | 'expiryDate' => Craft::t('graphql-authentication', 'Expiry Date'), 56 | ]; 57 | } 58 | 59 | protected function attributeHtml(string $attribute): string 60 | { 61 | switch ($attribute) { 62 | case 'token': 63 | return substr($this->token, 0, 10) . '...'; 64 | 65 | case 'userId': 66 | $usersService = Craft::$app->getUsers(); 67 | $user = $usersService->getUserById($this->userId); 68 | return $user ? Craft::$app->getView()->renderTemplate('_elements/element', ['element' => $user]) : ''; 69 | 70 | case 'schemaName': 71 | $gqlService = Craft::$app->getGql(); 72 | $schema = $gqlService->getSchemaById($this->schemaId); 73 | return $schema->name ?? ''; 74 | } 75 | 76 | return parent::attributeHtml($attribute); 77 | } 78 | 79 | /** 80 | * @param bool $isNew 81 | * 82 | * @throws \yii\db\Exception 83 | */ 84 | public function afterSave(bool $isNew): void 85 | { 86 | if ($isNew) { 87 | Craft::$app->db->createCommand() 88 | ->insert('{{%gql_refresh_tokens}}', [ 89 | 'id' => $this->id, 90 | 'token' => $this->token, 91 | 'userId' => $this->userId, 92 | 'schemaId' => $this->schemaId, 93 | 'expiryDate' => $this->expiryDate, 94 | ]) 95 | ->execute(); 96 | } else { 97 | Craft::$app->db->createCommand() 98 | ->update('{{%gql_refresh_tokens}}', [ 99 | 'token' => $this->token, 100 | 'userId' => $this->userId, 101 | 'schemaId' => $this->schemaId, 102 | 'expiryDate' => $this->expiryDate, 103 | ], ['id' => $this->id]) 104 | ->execute(); 105 | } 106 | 107 | parent::afterSave($isNew); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/elements/db/MagicCodeQuery.php: -------------------------------------------------------------------------------- 1 | code = $value; 18 | return $this; 19 | } 20 | 21 | public function userId($value) 22 | { 23 | $this->userId = $value; 24 | return $this; 25 | } 26 | 27 | public function schemaId($value) 28 | { 29 | $this->schemaId = $value; 30 | return $this; 31 | } 32 | 33 | public function expiryDate($value) 34 | { 35 | $this->expiryDate = $value; 36 | return $this; 37 | } 38 | 39 | protected function beforePrepare(): bool 40 | { 41 | $this->joinElementTable('gql_magic_codes'); 42 | 43 | $this->query->select([ 44 | 'gql_magic_codes.code', 45 | 'gql_magic_codes.userId', 46 | 'gql_magic_codes.schemaId', 47 | 'gql_magic_codes.expiryDate', 48 | ]); 49 | 50 | if ($this->code) { 51 | $this->subQuery->andWhere(Db::parseParam('gql_magic_codes.code', $this->code)); 52 | } 53 | 54 | if ($this->userId) { 55 | $this->subQuery->andWhere(Db::parseParam('gql_magic_codes.userId', $this->userId)); 56 | } 57 | 58 | if ($this->schemaId) { 59 | $this->subQuery->andWhere(Db::parseParam('gql_magic_codes.schemaId', $this->schemaId)); 60 | } 61 | 62 | if ($this->expiryDate) { 63 | $this->subQuery->andWhere(Db::parseParam('gql_magic_codes.expiryDate', $this->expiryDate)); 64 | } 65 | 66 | return parent::beforePrepare(); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/elements/db/RefreshTokenQuery.php: -------------------------------------------------------------------------------- 1 | token = $value; 18 | return $this; 19 | } 20 | 21 | public function userId($value) 22 | { 23 | $this->userId = $value; 24 | return $this; 25 | } 26 | 27 | public function schemaId($value) 28 | { 29 | $this->schemaId = $value; 30 | return $this; 31 | } 32 | 33 | public function expiryDate($value) 34 | { 35 | $this->expiryDate = $value; 36 | return $this; 37 | } 38 | 39 | protected function beforePrepare(): bool 40 | { 41 | $this->joinElementTable('gql_refresh_tokens'); 42 | 43 | $this->query->select([ 44 | 'gql_refresh_tokens.token', 45 | 'gql_refresh_tokens.userId', 46 | 'gql_refresh_tokens.schemaId', 47 | 'gql_refresh_tokens.expiryDate', 48 | ]); 49 | 50 | if ($this->token) { 51 | $this->subQuery->andWhere(Db::parseParam('gql_refresh_tokens.token', $this->token)); 52 | } 53 | 54 | if ($this->userId) { 55 | $this->subQuery->andWhere(Db::parseParam('gql_refresh_tokens.userId', $this->userId)); 56 | } 57 | 58 | if ($this->schemaId) { 59 | $this->subQuery->andWhere(Db::parseParam('gql_refresh_tokens.schemaId', $this->schemaId)); 60 | } 61 | 62 | if ($this->expiryDate) { 63 | $this->subQuery->andWhere(Db::parseParam('gql_refresh_tokens.expiryDate', $this->expiryDate)); 64 | } 65 | 66 | return parent::beforePrepare(); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/events/JwtCreateEvent.php: -------------------------------------------------------------------------------- 1 | static::getName(), 31 | 'fields' => [ 32 | 'user' => Type::getNullableType(User::getType()), 33 | 'schema' => Type::string(), 34 | 'jwt' => Type::string(), 35 | 'jwtExpiresAt' => Type::float(), 36 | 'refreshToken' => Type::string(), 37 | 'refreshTokenExpiresAt' => Type::float(), 38 | 'requiresTwoFactor' => Type::boolean(), 39 | ], 40 | ])); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/gql/Platform.php: -------------------------------------------------------------------------------- 1 | appleClientId && (bool) $settings->appleClientSecret) { 34 | $values['NATIVE'] = [ 35 | 'value' => 'native', 36 | ]; 37 | } 38 | 39 | if ((bool) $settings->appleServiceId && (bool) $settings->appleServiceSecret && (bool) $settings->appleRedirectUrl) { 40 | $values['WEB'] = [ 41 | 'value' => 'web', 42 | ]; 43 | } 44 | 45 | return GqlEntityRegistry::createEntity(static::getName(), new EnumType([ 46 | 'name' => static::getName(), 47 | 'values' => $values, 48 | ])); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/icon-mask.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 72 | -------------------------------------------------------------------------------- /src/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 72 | -------------------------------------------------------------------------------- /src/migrations/Install.php: -------------------------------------------------------------------------------- 1 | driver = Craft::$app->getConfig()->getDb()->driver; 34 | 35 | if ($this->createTables()) { 36 | $this->createIndexes(); 37 | $this->addForeignKeys(); 38 | // Refresh the db schema caches 39 | Craft::$app->db->schema->refresh(); 40 | $this->insertDefaultData(); 41 | } 42 | 43 | return true; 44 | } 45 | 46 | /** 47 | * This method contains the logic to be executed when removing this migration. 48 | * This method differs from [[down()]] in that the DB logic implemented here will 49 | * be enclosed within a DB transaction. 50 | * Child classes may implement this method instead of [[down()]] if the DB logic 51 | * needs to be within a transaction. 52 | * 53 | * @return bool return a false value to indicate the migration fails 54 | * and should not proceed further. All other return values mean the migration succeeds. 55 | */ 56 | public function safeDown() 57 | { 58 | $this->driver = Craft::$app->getConfig()->getDb()->driver; 59 | $this->removeTables(); 60 | 61 | return true; 62 | } 63 | 64 | // Protected Methods 65 | // ========================================================================= 66 | 67 | /** 68 | * Creates the tables. 69 | * 70 | * @return bool 71 | */ 72 | protected function createTables() 73 | { 74 | $tablesCreated = false; 75 | 76 | // gql_refresh_tokens table 77 | $tableSchema = Craft::$app->db->schema->getTableSchema('{{%gql_refresh_tokens}}'); 78 | 79 | if ($tableSchema === null) { 80 | $tablesCreated = true; 81 | 82 | $this->createTable( 83 | '{{%gql_refresh_tokens}}', 84 | [ 85 | 'id' => $this->integer()->notNull(), 86 | 'token' => $this->text()->notNull(), 87 | 'userId' => $this->integer()->notNull(), 88 | 'schemaId' => $this->integer()->notNull(), 89 | 'dateCreated' => $this->dateTime()->notNull(), 90 | 'dateUpdated' => $this->dateTime()->notNull(), 91 | 'expiryDate' => $this->dateTime()->notNull(), 92 | 'uid' => $this->uid(), 93 | 'PRIMARY KEY(id)', 94 | ] 95 | ); 96 | } 97 | 98 | // gql_magic_codes table 99 | $tableSchema = Craft::$app->db->schema->getTableSchema('{{%gql_magic_codes}}'); 100 | 101 | if ($tableSchema === null) { 102 | $tablesCreated = true; 103 | 104 | $this->createTable( 105 | '{{%gql_magic_codes}}', 106 | [ 107 | 'id' => $this->integer()->notNull(), 108 | 'code' => $this->integer()->notNull(), 109 | 'userId' => $this->integer()->notNull(), 110 | 'schemaId' => $this->integer()->notNull(), 111 | 'dateCreated' => $this->dateTime()->notNull(), 112 | 'dateUpdated' => $this->dateTime()->notNull(), 113 | 'expiryDate' => $this->dateTime()->notNull(), 114 | 'uid' => $this->uid(), 115 | 'PRIMARY KEY(id)', 116 | ] 117 | ); 118 | } 119 | 120 | return $tablesCreated; 121 | } 122 | 123 | /** 124 | * Creates the indexes. 125 | */ 126 | public function createIndexes() 127 | { 128 | } 129 | 130 | /** 131 | * Creates the foreign keys. 132 | * 133 | * @return void 134 | */ 135 | protected function addForeignKeys() 136 | { 137 | // gql_refresh_tokens table 138 | $this->addForeignKey( 139 | null, 140 | '{{%gql_refresh_tokens}}', 141 | 'id', 142 | '{{%elements}}', 143 | 'id', 144 | 'CASCADE', 145 | null 146 | ); 147 | 148 | $this->addForeignKey( 149 | null, 150 | '{{%gql_refresh_tokens}}', 151 | 'userId', 152 | '{{%elements}}', 153 | 'id', 154 | 'CASCADE', 155 | null 156 | ); 157 | 158 | // gql_magic_codes table 159 | $this->addForeignKey( 160 | null, 161 | '{{%gql_magic_codes}}', 162 | 'id', 163 | '{{%elements}}', 164 | 'id', 165 | 'CASCADE', 166 | null 167 | ); 168 | 169 | $this->addForeignKey( 170 | null, 171 | '{{%gql_magic_codes}}', 172 | 'userId', 173 | '{{%elements}}', 174 | 'id', 175 | 'CASCADE', 176 | null 177 | ); 178 | } 179 | 180 | /** 181 | * Populates the DB with the default data. 182 | * 183 | * @return void 184 | */ 185 | protected function insertDefaultData() 186 | { 187 | } 188 | 189 | /** 190 | * Removes the tables needed for the Records used by the plugin. 191 | * 192 | * @return void 193 | */ 194 | protected function removeTables() 195 | { 196 | // gql_refresh_tokens table 197 | $this->dropTableIfExists('{{%gql_refresh_tokens}}'); 198 | 199 | // gql_magic_codes table 200 | $this->dropTableIfExists('{{%gql_magic_codes}}'); 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/migrations/m201129_224453_create_refresh_tokens.php: -------------------------------------------------------------------------------- 1 | driver = Craft::$app->getConfig()->getDb()->driver; 37 | 38 | if ($this->createTables()) { 39 | $this->createIndexes(); 40 | $this->addForeignKeys(); 41 | // Refresh the db schema caches 42 | Craft::$app->db->schema->refresh(); 43 | $this->insertDefaultData(); 44 | } 45 | 46 | return true; 47 | } 48 | 49 | /** 50 | * This method contains the logic to be executed when removing this migration. 51 | * This method differs from [[down()]] in that the DB logic implemented here will 52 | * be enclosed within a DB transaction. 53 | * Child classes may implement this method instead of [[down()]] if the DB logic 54 | * needs to be within a transaction. 55 | * 56 | * @return bool return a false value to indicate the migration fails 57 | * and should not proceed further. All other return values mean the migration succeeds. 58 | */ 59 | public function safeDown() 60 | { 61 | $this->driver = Craft::$app->getConfig()->getDb()->driver; 62 | $this->removeTables(); 63 | 64 | return true; 65 | } 66 | 67 | // Protected Methods 68 | // ========================================================================= 69 | 70 | /** 71 | * Creates the tables. 72 | * 73 | * @return bool 74 | */ 75 | protected function createTables() 76 | { 77 | $tablesCreated = false; 78 | 79 | // gql_refresh_tokens table 80 | $tableSchema = Craft::$app->db->schema->getTableSchema('{{%gql_refresh_tokens}}'); 81 | 82 | if ($tableSchema === null) { 83 | $tablesCreated = true; 84 | 85 | $this->createTable( 86 | '{{%gql_refresh_tokens}}', 87 | [ 88 | 'id' => $this->integer()->notNull(), 89 | 'token' => $this->text()->notNull(), 90 | 'userId' => $this->integer()->notNull(), 91 | 'schemaId' => $this->integer()->notNull(), 92 | 'dateCreated' => $this->dateTime()->notNull(), 93 | 'dateUpdated' => $this->dateTime()->notNull(), 94 | 'expiryDate' => $this->dateTime()->notNull(), 95 | 'uid' => $this->uid(), 96 | 'PRIMARY KEY(id)', 97 | ] 98 | ); 99 | } 100 | 101 | return $tablesCreated; 102 | } 103 | 104 | /** 105 | * Creates the indexes. 106 | */ 107 | public function createIndexes() 108 | { 109 | } 110 | 111 | /** 112 | * Creates the foreign keys. 113 | * 114 | * @return void 115 | */ 116 | protected function addForeignKeys() 117 | { 118 | // gql_refresh_tokens table 119 | $this->addForeignKey( 120 | null, 121 | '{{%gql_refresh_tokens}}', 122 | 'id', 123 | '{{%elements}}', 124 | 'id', 125 | 'CASCADE', 126 | null 127 | ); 128 | 129 | $this->addForeignKey( 130 | null, 131 | '{{%gql_refresh_tokens}}', 132 | 'userId', 133 | '{{%elements}}', 134 | 'id', 135 | 'CASCADE', 136 | null 137 | ); 138 | } 139 | 140 | /** 141 | * Populates the DB with the default data. 142 | * 143 | * @return void 144 | */ 145 | protected function insertDefaultData() 146 | { 147 | } 148 | 149 | /** 150 | * Removes the tables needed for the Records used by the plugin. 151 | * 152 | * @return void 153 | */ 154 | protected function removeTables() 155 | { 156 | // gql_refresh_tokens table 157 | $this->dropTableIfExists('{{%gql_refresh_tokens}}'); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/migrations/m211014_234909_schema_id_to_schema_name.php: -------------------------------------------------------------------------------- 1 | projectConfig; 21 | 22 | // Don’t make the same config changes twice 23 | $schemaVersion = $projectConfig->get('plugins.graphql-authentication.schemaVersion', true); 24 | 25 | if (version_compare($schemaVersion, '1.2.0', '<')) { 26 | $plugins = Craft::$app->getPlugins(); 27 | $settings = GraphqlAuthentication::$settings; 28 | 29 | if ($settings->schemaId) { 30 | $schemaName = GqlSchemaRecord::find()->select(['name'])->where(['id' => $settings->schemaId])->scalar(); 31 | 32 | $plugins->savePluginSettings(GraphqlAuthentication::$plugin, [ 33 | 'schemaId' => null, 34 | 'schemaName' => $schemaName, 35 | ]); 36 | } 37 | 38 | if (count($settings->granularSchemas ?? [])) { 39 | $granularSchemas = $settings->granularSchemas; 40 | 41 | foreach ($granularSchemas as &$schema) { 42 | if (array_key_exists('schemaId', $schema)) { 43 | $schemaName = GqlSchemaRecord::find()->select(['name'])->where(['id' => $schema['schemaId']])->scalar(); 44 | unset($schema['schemaId']); 45 | $schema['schemaName'] = $schemaName; 46 | } 47 | } 48 | 49 | $plugins->savePluginSettings(GraphqlAuthentication::$plugin, ['granularSchemas' => $granularSchemas]); 50 | } 51 | } 52 | 53 | return true; 54 | } 55 | 56 | public function safeDown() 57 | { 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/migrations/m230116_000217_create_magic_codes.php: -------------------------------------------------------------------------------- 1 | driver = Craft::$app->getConfig()->getDb()->driver; 37 | 38 | if ($this->createTables()) { 39 | $this->createIndexes(); 40 | $this->addForeignKeys(); 41 | // Refresh the db schema caches 42 | Craft::$app->db->schema->refresh(); 43 | $this->insertDefaultData(); 44 | } 45 | 46 | return true; 47 | } 48 | 49 | /** 50 | * This method contains the logic to be executed when removing this migration. 51 | * This method differs from [[down()]] in that the DB logic implemented here will 52 | * be enclosed within a DB transaction. 53 | * Child classes may implement this method instead of [[down()]] if the DB logic 54 | * needs to be within a transaction. 55 | * 56 | * @return bool return a false value to indicate the migration fails 57 | * and should not proceed further. All other return values mean the migration succeeds. 58 | */ 59 | public function safeDown() 60 | { 61 | $this->driver = Craft::$app->getConfig()->getDb()->driver; 62 | $this->removeTables(); 63 | 64 | return true; 65 | } 66 | 67 | // Protected Methods 68 | // ========================================================================= 69 | 70 | /** 71 | * Creates the tables. 72 | * 73 | * @return bool 74 | */ 75 | protected function createTables() 76 | { 77 | $tablesCreated = false; 78 | 79 | // gql_magic_codes table 80 | $tableSchema = Craft::$app->db->schema->getTableSchema('{{%gql_magic_codes}}'); 81 | 82 | if ($tableSchema === null) { 83 | $tablesCreated = true; 84 | 85 | $this->createTable( 86 | '{{%gql_magic_codes}}', 87 | [ 88 | 'id' => $this->integer()->notNull(), 89 | 'code' => $this->integer()->notNull(), 90 | 'userId' => $this->integer()->notNull(), 91 | 'schemaId' => $this->integer()->notNull(), 92 | 'dateCreated' => $this->dateTime()->notNull(), 93 | 'dateUpdated' => $this->dateTime()->notNull(), 94 | 'expiryDate' => $this->dateTime()->notNull(), 95 | 'uid' => $this->uid(), 96 | 'PRIMARY KEY(id)', 97 | ] 98 | ); 99 | } 100 | 101 | return $tablesCreated; 102 | } 103 | 104 | /** 105 | * Creates the indexes. 106 | */ 107 | public function createIndexes() 108 | { 109 | } 110 | 111 | /** 112 | * Creates the foreign keys. 113 | * 114 | * @return void 115 | */ 116 | protected function addForeignKeys() 117 | { 118 | // gql_magic_codes table 119 | $this->addForeignKey( 120 | null, 121 | '{{%gql_magic_codes}}', 122 | 'id', 123 | '{{%elements}}', 124 | 'id', 125 | 'CASCADE', 126 | null 127 | ); 128 | 129 | $this->addForeignKey( 130 | null, 131 | '{{%gql_magic_codes}}', 132 | 'userId', 133 | '{{%elements}}', 134 | 'id', 135 | 'CASCADE', 136 | null 137 | ); 138 | } 139 | 140 | /** 141 | * Populates the DB with the default data. 142 | * 143 | * @return void 144 | */ 145 | protected function insertDefaultData() 146 | { 147 | } 148 | 149 | /** 150 | * Removes the tables needed for the Records used by the plugin. 151 | * 152 | * @return void 153 | */ 154 | protected function removeTables() 155 | { 156 | // gql_magic_codes table 157 | $this->dropTableIfExists('{{%gql_magic_codes}}'); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/models/Settings.php: -------------------------------------------------------------------------------- 1 | 21 | * @since 3.3.0 22 | */ 23 | class Asset extends ElementResolver 24 | { 25 | /** 26 | * @inheritdoc 27 | */ 28 | public static function prepareQuery(mixed $source, array $arguments, ?string $fieldName = null): mixed 29 | { 30 | // If this is the beginning of a resolver chain, start fresh 31 | if ($source === null) { 32 | $query = AssetElement::find(); 33 | // If not, get the prepared element query 34 | } else { 35 | $query = $source->$fieldName; 36 | } 37 | 38 | // If it's preloaded, it's preloaded. 39 | if (!$query instanceof ElementQuery) { 40 | return $query; 41 | } 42 | 43 | $restrictionService = GraphqlAuthentication::$restrictionService; 44 | 45 | if ($restrictionService->shouldRestrictRequests()) { 46 | $user = GraphqlAuthentication::$tokenService->getUserFromToken(); 47 | 48 | if (isset($arguments['volume']) || isset($arguments['volumeId'])) { 49 | $authorOnlyVolumes = $restrictionService->getAuthorOnlyVolumes($user, 'query'); 50 | 51 | $volumesService = Craft::$app->getVolumes(); 52 | 53 | foreach ($authorOnlyVolumes as $volume) { 54 | if (isset($arguments['volume']) && trim($arguments['volume'][0]) !== $volume) { 55 | continue; 56 | } 57 | 58 | if (isset($arguments['volumeId'])) { 59 | /** @var Volume $volume */ 60 | $volume = $volumesService->getVolumeByHandle($volume); 61 | if (trim((string) $arguments['volumeId'][0]) != $volume->id) { 62 | continue; 63 | } 64 | } 65 | 66 | $arguments['uploader'] = $user->id; 67 | } 68 | } else { 69 | $arguments['uploader'] = $user->id; 70 | } 71 | } 72 | 73 | foreach ($arguments as $key => $value) { 74 | try { 75 | $query->$key($value); 76 | } catch (UnknownMethodException $e) { 77 | if ($value !== null) { 78 | throw $e; 79 | } 80 | } 81 | } 82 | 83 | $pairs = GqlHelper::extractAllowedEntitiesFromSchema('read'); 84 | 85 | if (!GqlHelper::canQueryAssets()) { 86 | return ElementCollection::empty(); 87 | } 88 | 89 | $volumesService = Craft::$app->getVolumes(); 90 | $volumeIds = array_filter(array_map(function(string $uid) use ($volumesService) { 91 | $volume = $volumesService->getVolumeByUid($uid); 92 | return $volume->id ?? null; 93 | }, $pairs['volumes'])); 94 | 95 | $query->andWhere(['in', 'assets.volumeId', $volumeIds]); 96 | 97 | return $query; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/resolvers/Entry.php: -------------------------------------------------------------------------------- 1 | 21 | * @since 3.3.0 22 | */ 23 | class Entry extends ElementResolver 24 | { 25 | /** 26 | * @inheritdoc 27 | */ 28 | public static function prepareQuery(mixed $source, array $arguments, ?string $fieldName = null): mixed 29 | { 30 | // If this is the beginning of a resolver chain, start fresh 31 | if ($source === null) { 32 | $query = EntryElement::find(); 33 | $pairs = GqlHelper::extractAllowedEntitiesFromSchema('read'); 34 | $condition = []; 35 | 36 | if (isset($pairs['sections'])) { 37 | $entriesService = Craft::$app->getEntries(); 38 | $sectionIds = array_filter(array_map( 39 | fn(string $uid) => $entriesService->getSectionByUid($uid)?->id, 40 | $pairs['sections'], 41 | )); 42 | if (!empty($sectionIds)) { 43 | $condition[] = ['in', 'entries.sectionId', $sectionIds]; 44 | } 45 | } 46 | 47 | if (isset($pairs['nestedentryfields'])) { 48 | $fieldsService = Craft::$app->getFields(); 49 | $types = array_flip($fieldsService->getNestedEntryFieldTypes()); 50 | $fieldIds = array_filter(array_map(function(string $uid) use ($fieldsService, $types) { 51 | $field = $fieldsService->getFieldByUid($uid); 52 | return $field && isset($types[$field::class]) ? $field->id : null; 53 | }, $pairs['nestedentryfields'])); 54 | if (!empty($fieldIds)) { 55 | $condition[] = ['in', 'entries.fieldId', $fieldIds]; 56 | } 57 | } 58 | 59 | if (empty($condition)) { 60 | return ElementCollection::empty(); 61 | } 62 | 63 | $query->andWhere(['or', ...$condition]); 64 | // If not, get the prepared element query 65 | } else { 66 | $query = $source->$fieldName; 67 | } 68 | 69 | // If it's preloaded, it's preloaded. 70 | if (!$query instanceof ElementQuery) { 71 | return $query; 72 | } 73 | 74 | $restrictionService = GraphqlAuthentication::$restrictionService; 75 | 76 | if ($restrictionService->shouldRestrictRequests()) { 77 | $user = GraphqlAuthentication::$tokenService->getUserFromToken(); 78 | 79 | if (isset($arguments['section']) || isset($arguments['sectionId'])) { 80 | $authorOnlySections = $user ? $restrictionService->getAuthorOnlySections($user, 'query') : []; 81 | 82 | $entriesService = Craft::$app->getEntries(); 83 | 84 | foreach ($authorOnlySections as $section) { 85 | if (isset($arguments['section']) && trim($arguments['section'][0]) !== $section) { 86 | continue; 87 | } 88 | 89 | if (isset($arguments['sectionId']) && trim((string) $arguments['sectionId'][0]) !== $entriesService->getSectionByHandle($section)->id) { 90 | continue; 91 | } 92 | 93 | $arguments['authorId'] = $user->id; 94 | } 95 | 96 | $settings = GraphqlAuthentication::$settings; 97 | $siteId = null; 98 | 99 | if ($settings->permissionType === 'single') { 100 | $siteId = $settings->siteId ?? null; 101 | } else { 102 | $userGroup = $user ? ($user->getGroups()[0]->id ?? null) : null; 103 | 104 | if ($userGroup) { 105 | $siteId = $settings->granularSchemas["group-$userGroup"]['siteId'] ?? null; 106 | } 107 | } 108 | 109 | if ($siteId) { 110 | $arguments['siteId'] = $siteId; 111 | } 112 | } elseif ($user) { 113 | $arguments['authorId'] = $user->id; 114 | } 115 | } 116 | 117 | foreach ($arguments as $key => $value) { 118 | try { 119 | $query->$key($value); 120 | } catch (UnknownMethodException $e) { 121 | if ($value !== null) { 122 | throw $e; 123 | } 124 | } 125 | } 126 | 127 | return $query; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/resolvers/GlobalSet.php: -------------------------------------------------------------------------------- 1 | 19 | * @since 3.3.0 20 | */ 21 | class GlobalSet extends ElementResolver 22 | { 23 | /** 24 | * @inheritdoc 25 | */ 26 | public static function prepareQuery(mixed $source, array $arguments, ?string $fieldName = null): mixed 27 | { 28 | $query = GlobalSetElement::find(); 29 | 30 | if (GraphqlAuthentication::$restrictionService->shouldRestrictRequests()) { 31 | $settings = GraphqlAuthentication::$settings; 32 | $siteId = null; 33 | 34 | if ($settings->permissionType === 'single') { 35 | $siteId = $settings->siteId ?? null; 36 | } else { 37 | $user = GraphqlAuthentication::$tokenService->getUserFromToken(); 38 | $userGroup = $user->getGroups()[0]->id ?? null; 39 | 40 | if ($userGroup) { 41 | $siteId = $settings->granularSchemas["group-$userGroup"]['siteId'] ?? null; 42 | } 43 | } 44 | 45 | if ($siteId) { 46 | $arguments['siteId'] = $siteId; 47 | } 48 | } 49 | 50 | foreach ($arguments as $key => $value) { 51 | try { 52 | $query->$key($value); 53 | } catch (UnknownMethodException $e) { 54 | if ($value !== null) { 55 | throw $e; 56 | } 57 | } 58 | } 59 | 60 | $pairs = GqlHelper::extractAllowedEntitiesFromSchema('read'); 61 | 62 | if (!GqlHelper::canQueryGlobalSets()) { 63 | return ElementCollection::empty(); 64 | } 65 | 66 | $query->andWhere(['in', 'globalsets.uid', $pairs['globalsets']]); 67 | 68 | return $query; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/services/AppleService.php: -------------------------------------------------------------------------------- 1 | getHeaderToken()) { 53 | return; 54 | } 55 | 56 | if (!$this->_validateWebSettings()) { 57 | return; 58 | } 59 | 60 | $event->queries['appleOauthUrl'] = [ 61 | 'description' => 'Generates the Apple OAuth URL for allowing users to authenticate.', 62 | 'type' => Type::nonNull(Type::string()), 63 | 'args' => [], 64 | 'resolve' => function() { 65 | $settings = GraphqlAuthentication::$settings; 66 | 67 | $url = 'https://appleid.apple.com/auth/authorize?' . http_build_query([ 68 | 'response_type' => 'code', 69 | 'response_mode' => 'form_post', 70 | 'client_id' => GraphqlAuthentication::getInstance()->getSettingsData($settings->appleServiceId), 71 | 'redirect_uri' => GraphqlAuthentication::getInstance()->getSettingsData($settings->appleRedirectUrl), 72 | 'scope' => 'name email', 73 | ]); 74 | 75 | return $url; 76 | }, 77 | ]; 78 | } 79 | 80 | /** 81 | * Registers Sign in with Apple mutations 82 | * 83 | * @param RegisterGqlMutationsEvent $event 84 | */ 85 | public function registerGqlMutations(RegisterGqlMutationsEvent $event) 86 | { 87 | if (!$this->_validateNativeSettings() && !$this->_validateWebSettings()) { 88 | return; 89 | } 90 | 91 | $args = [ 92 | 'code' => Type::nonNull(Type::string()), 93 | ]; 94 | 95 | if ($this->_validateNativeSettings() && $this->_validateWebSettings()) { 96 | $args['platform'] = Platform::getType(); 97 | } 98 | 99 | $defaultPlatform = 'native'; 100 | 101 | if (!$this->_validateNativeSettings() && $this->_validateWebSettings()) { 102 | $defaultPlatform = 'web'; 103 | } 104 | 105 | switch (GraphqlAuthentication::$settings->permissionType) { 106 | case 'single': 107 | $event->mutations['appleSignIn'] = [ 108 | 'description' => 'Authenticates a user using an Apple Sign-In token. Returns user and token.', 109 | 'type' => Type::nonNull(Auth::getType()), 110 | 'args' => $args, 111 | 'resolve' => function($source, array $arguments) use ($defaultPlatform) { 112 | $settings = GraphqlAuthentication::$settings; 113 | $schemaId = GqlSchemaRecord::find()->select(['id'])->where(['name' => $settings->schemaName])->scalar(); 114 | 115 | if (!$schemaId) { 116 | GraphqlAuthentication::$errorService->throw($settings->invalidSchema); 117 | } 118 | 119 | $code = $arguments['code']; 120 | $platform = $arguments['platform'] ?? $defaultPlatform; 121 | $tokenUser = $this->_getUserFromToken($code, $platform); 122 | 123 | $user = GraphqlAuthentication::$socialService->authenticate($tokenUser, $schemaId); 124 | return $user; 125 | }, 126 | ]; 127 | break; 128 | 129 | case 'multiple': 130 | $userGroupsService = Craft::$app->getUserGroups(); 131 | $userGroups = $userGroupsService->getAllGroups(); 132 | 133 | foreach ($userGroups as $userGroup) { 134 | $handle = ucfirst($userGroup->handle); 135 | 136 | $event->mutations["appleSignIn{$handle}"] = [ 137 | 'description' => "Authenticates a {$userGroup->name} using an Apple Sign-In token. Returns user and token.", 138 | 'type' => Type::nonNull(Auth::getType()), 139 | 'args' => $args, 140 | 'resolve' => function($source, array $arguments) use ($userGroup, $defaultPlatform) { 141 | $settings = GraphqlAuthentication::$settings; 142 | $schemaName = $settings->granularSchemas['group-' . $userGroup->id]['schemaName'] ?? null; 143 | $schemaId = GqlSchemaRecord::find()->select(['id'])->where(['name' => $schemaName])->scalar(); 144 | 145 | if (!$schemaId) { 146 | GraphqlAuthentication::$errorService->throw($settings->invalidSchema); 147 | } 148 | 149 | $code = $arguments['code']; 150 | $platform = $arguments['platform'] ?? $defaultPlatform; 151 | $tokenUser = $this->_getUserFromToken($code, $platform); 152 | 153 | $user = GraphqlAuthentication::$socialService->authenticate($tokenUser, $schemaId, $userGroup->id); 154 | return $user; 155 | }, 156 | ]; 157 | } 158 | break; 159 | } 160 | } 161 | 162 | // Protected Methods 163 | // ========================================================================= 164 | 165 | /** 166 | * Ensures native settings are set 167 | * 168 | * @return bool 169 | */ 170 | protected function _validateNativeSettings(): bool 171 | { 172 | $settings = GraphqlAuthentication::$settings; 173 | return (bool) $settings->appleClientId && (bool) $settings->appleClientSecret; 174 | } 175 | 176 | /** 177 | * Ensures web settings are set 178 | * 179 | * @return bool 180 | */ 181 | protected function _validateWebSettings(): bool 182 | { 183 | $settings = GraphqlAuthentication::$settings; 184 | return (bool) $settings->appleServiceId && (bool) $settings->appleServiceSecret && (bool) $settings->appleRedirectUrl; 185 | } 186 | 187 | /** 188 | * Gets user details from Sign in with Apple token 189 | * 190 | * @param string $code 191 | * @param string $platform 192 | * @return array 193 | * @throws Error 194 | */ 195 | protected function _getUserFromToken(string $code, string $platform): array 196 | { 197 | $settings = GraphqlAuthentication::$settings; 198 | $errorService = GraphqlAuthentication::$errorService; 199 | $client = new Client(); 200 | 201 | $id = $settings->appleClientId; 202 | $secret = $settings->appleClientSecret; 203 | $redirect = null; 204 | 205 | if ($platform === 'web') { 206 | $id = $settings->appleServiceId; 207 | $secret = $settings->appleServiceSecret; 208 | $redirect = $settings->appleRedirectUrl; 209 | } 210 | 211 | $params = [ 212 | 'grant_type' => 'authorization_code', 213 | 'code' => $code, 214 | 'client_id' => GraphqlAuthentication::getInstance()->getSettingsData($id), 215 | 'client_secret' => GraphqlAuthentication::getInstance()->getSettingsData($secret), 216 | ]; 217 | 218 | if ($redirect) { 219 | $params['redirect_uri'] = GraphqlAuthentication::getInstance()->getSettingsData($redirect); 220 | } 221 | 222 | try { 223 | $response = json_decode($client->request('POST', 'https://appleid.apple.com/auth/token', [ 224 | 'form_params' => $params, 225 | ])->getBody()->getContents()); 226 | } catch (Throwable $e) { 227 | $errorService->throw($settings->invalidOauthToken); 228 | } 229 | 230 | $claims = explode('.', $response->id_token)[1]; 231 | $claims = json_decode(base64_decode($claims)); 232 | 233 | $email = $claims->email; 234 | 235 | if (!$email) { 236 | $errorService->throw($settings->emailNotInScope); 237 | } 238 | 239 | $fullName = $claims->name ?? ''; 240 | 241 | return compact( 242 | 'email', 243 | 'fullName' 244 | ); 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /src/services/ErrorService.php: -------------------------------------------------------------------------------- 1 | 'INVALID']); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/services/FacebookService.php: -------------------------------------------------------------------------------- 1 | getHeaderToken()) { 51 | return; 52 | } 53 | 54 | if (!$this->_validateSettings()) { 55 | return; 56 | } 57 | 58 | $event->queries['facebookOauthUrl'] = [ 59 | 'description' => 'Generates the Facebook OAuth URL for allowing users to authenticate.', 60 | 'type' => Type::nonNull(Type::string()), 61 | 'args' => [], 62 | 'resolve' => function() { 63 | $settings = GraphqlAuthentication::$settings; 64 | 65 | $client = new Facebook([ 66 | 'clientId' => GraphqlAuthentication::getInstance()->getSettingsData($settings->facebookAppId), 67 | 'clientSecret' => GraphqlAuthentication::getInstance()->getSettingsData($settings->facebookAppSecret), 68 | 'redirectUri' => GraphqlAuthentication::getInstance()->getSettingsData($settings->facebookRedirectUrl), 69 | 'graphApiVersion' => 'v2.10', 70 | ]); 71 | 72 | $url = $client->getAuthorizationUrl(['scope' => ['email']]); 73 | return $url; 74 | }, 75 | ]; 76 | } 77 | 78 | /** 79 | * Registers Facebook Login mutations 80 | * 81 | * @param RegisterGqlMutationsEvent $event 82 | */ 83 | public function registerGqlMutations(RegisterGqlMutationsEvent $event) 84 | { 85 | if (!$this->_validateSettings()) { 86 | return; 87 | } 88 | 89 | switch (GraphqlAuthentication::$settings->permissionType) { 90 | case 'single': 91 | $event->mutations['facebookSignIn'] = [ 92 | 'description' => 'Authenticates a user using a Facebook Sign-In token. Returns user and token.', 93 | 'type' => Type::nonNull(Auth::getType()), 94 | 'args' => [ 95 | 'code' => Type::nonNull(Type::string()), 96 | ], 97 | 'resolve' => function($source, array $arguments) { 98 | $settings = GraphqlAuthentication::$settings; 99 | $schemaId = GqlSchemaRecord::find()->select(['id'])->where(['name' => $settings->schemaName])->scalar(); 100 | 101 | if (!$schemaId) { 102 | GraphqlAuthentication::$errorService->throw($settings->invalidSchema); 103 | } 104 | 105 | $code = $arguments['code']; 106 | $tokenUser = $this->_getUserFromToken($code); 107 | 108 | $user = GraphqlAuthentication::$socialService->authenticate($tokenUser, $schemaId); 109 | return $user; 110 | }, 111 | ]; 112 | break; 113 | 114 | case 'multiple': 115 | $userGroupsService = Craft::$app->getUserGroups(); 116 | $userGroups = $userGroupsService->getAllGroups(); 117 | 118 | foreach ($userGroups as $userGroup) { 119 | $handle = ucfirst($userGroup->handle); 120 | 121 | $event->mutations["facebookSignIn{$handle}"] = [ 122 | 'description' => "Authenticates a {$userGroup->name} using a Facebook Sign-In token. Returns user and token.", 123 | 'type' => Type::nonNull(Auth::getType()), 124 | 'args' => [ 125 | 'code' => Type::nonNull(Type::string()), 126 | ], 127 | 'resolve' => function($source, array $arguments) use ($userGroup) { 128 | $settings = GraphqlAuthentication::$settings; 129 | $schemaName = $settings->granularSchemas['group-' . $userGroup->id]['schemaName'] ?? null; 130 | $schemaId = GqlSchemaRecord::find()->select(['id'])->where(['name' => $schemaName])->scalar(); 131 | 132 | if (!$schemaId) { 133 | GraphqlAuthentication::$errorService->throw($settings->invalidSchema); 134 | } 135 | 136 | $code = $arguments['code']; 137 | $tokenUser = $this->_getUserFromToken($code); 138 | 139 | $user = GraphqlAuthentication::$socialService->authenticate($tokenUser, $schemaId, $userGroup->id); 140 | return $user; 141 | }, 142 | ]; 143 | } 144 | break; 145 | } 146 | } 147 | 148 | // Protected Methods 149 | // ========================================================================= 150 | 151 | /** 152 | * Ensures settings are set 153 | * 154 | * @return bool 155 | */ 156 | protected function _validateSettings(): bool 157 | { 158 | $settings = GraphqlAuthentication::$settings; 159 | return (bool) $settings->facebookAppId && (bool) $settings->facebookAppSecret && (bool) $settings->facebookRedirectUrl; 160 | } 161 | 162 | /** 163 | * Gets user details from Facebook Login token 164 | * 165 | * @param string $code 166 | * @return array 167 | * @throws Error 168 | */ 169 | protected function _getUserFromToken(string $code): array 170 | { 171 | $settings = GraphqlAuthentication::$settings; 172 | $errorService = GraphqlAuthentication::$errorService; 173 | 174 | $client = new Facebook([ 175 | 'clientId' => GraphqlAuthentication::getInstance()->getSettingsData($settings->facebookAppId), 176 | 'clientSecret' => GraphqlAuthentication::getInstance()->getSettingsData($settings->facebookAppSecret), 177 | 'redirectUri' => GraphqlAuthentication::getInstance()->getSettingsData($settings->facebookRedirectUrl), 178 | 'graphApiVersion' => 'v2.10', 179 | ]); 180 | 181 | $accessToken = $client->getAccessToken('authorization_code', [ 182 | 'code' => $code, 183 | ]); 184 | 185 | $user = $client->getResourceOwner($accessToken); 186 | $email = $user->getEmail(); 187 | 188 | if (!$email) { 189 | $errorService->throw($settings->emailNotInScope); 190 | } 191 | 192 | if ($settings->allowedFacebookDomains) { 193 | GraphqlAuthentication::$socialService->verifyEmailDomain( 194 | $email, 195 | $settings->allowedFacebookDomains, 196 | $settings->facebookEmailMismatch 197 | ); 198 | } 199 | 200 | $fullName = $user->getName() ?? ''; 201 | 202 | return compact( 203 | 'email', 204 | 'fullName' 205 | ); 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/services/GoogleService.php: -------------------------------------------------------------------------------- 1 | getHeaderToken()) { 44 | return; 45 | } 46 | 47 | if (!$this->_validateSettings()) { 48 | return; 49 | } 50 | 51 | switch (GraphqlAuthentication::$settings->permissionType) { 52 | case 'single': 53 | $event->mutations['googleSignIn'] = [ 54 | 'description' => 'Authenticates a user using a Google Sign-In ID token. Returns user and token.', 55 | 'type' => Type::nonNull(Auth::getType()), 56 | 'args' => [ 57 | 'idToken' => Type::nonNull(Type::string()), 58 | ], 59 | 'resolve' => function($source, array $arguments) { 60 | $settings = GraphqlAuthentication::$settings; 61 | $schemaId = GqlSchemaRecord::find()->select(['id'])->where(['name' => $settings->schemaName])->scalar(); 62 | 63 | if (!$schemaId) { 64 | GraphqlAuthentication::$errorService->throw($settings->invalidSchema); 65 | } 66 | 67 | $idToken = $arguments['idToken']; 68 | $tokenUser = $this->_getUserFromToken($idToken); 69 | 70 | $user = GraphqlAuthentication::$socialService->authenticate($tokenUser, $schemaId); 71 | return $user; 72 | }, 73 | ]; 74 | break; 75 | 76 | case 'multiple': 77 | $userGroupsService = Craft::$app->getUserGroups(); 78 | $userGroups = $userGroupsService->getAllGroups(); 79 | 80 | foreach ($userGroups as $userGroup) { 81 | $handle = ucfirst($userGroup->handle); 82 | 83 | $event->mutations["googleSignIn{$handle}"] = [ 84 | 'description' => "Authenticates a {$userGroup->name} using a Google Sign-In ID token. Returns user and token.", 85 | 'type' => Type::nonNull(Auth::getType()), 86 | 'args' => [ 87 | 'idToken' => Type::nonNull(Type::string()), 88 | ], 89 | 'resolve' => function($source, array $arguments) use ($userGroup) { 90 | $settings = GraphqlAuthentication::$settings; 91 | $schemaName = $settings->granularSchemas['group-' . $userGroup->id]['schemaName'] ?? null; 92 | $schemaId = GqlSchemaRecord::find()->select(['id'])->where(['name' => $schemaName])->scalar(); 93 | 94 | if (!$schemaId) { 95 | GraphqlAuthentication::$errorService->throw($settings->invalidSchema); 96 | } 97 | 98 | $idToken = $arguments['idToken']; 99 | $tokenUser = $this->_getUserFromToken($idToken); 100 | 101 | $user = GraphqlAuthentication::$socialService->authenticate($tokenUser, $schemaId, $userGroup->id); 102 | return $user; 103 | }, 104 | ]; 105 | } 106 | break; 107 | } 108 | } 109 | 110 | // Protected Methods 111 | // ========================================================================= 112 | 113 | /** 114 | * Ensures settings are set 115 | * 116 | * @return bool 117 | */ 118 | protected function _validateSettings(): bool 119 | { 120 | $settings = GraphqlAuthentication::$settings; 121 | return (bool) $settings->googleClientId; 122 | } 123 | 124 | /** 125 | * Gets user details from Google Sign-In token 126 | * 127 | * @param string $idToken 128 | * @return array 129 | * @throws Error 130 | */ 131 | protected function _getUserFromToken(string $idToken): array 132 | { 133 | $settings = GraphqlAuthentication::$settings; 134 | $errorService = GraphqlAuthentication::$errorService; 135 | 136 | $client = new Google_Client([ 137 | 'client_id' => GraphqlAuthentication::getInstance()->getSettingsData($settings->googleClientId), 138 | ]); 139 | 140 | $payload = $client->verifyIdToken($idToken); 141 | 142 | if (!$payload) { 143 | $errorService->throw($settings->googleTokenIdInvalid); 144 | } 145 | 146 | $email = $payload['email']; 147 | 148 | if (!$email) { 149 | $errorService->throw($settings->emailNotInScope); 150 | } 151 | 152 | if ($settings->allowedGoogleDomains) { 153 | GraphqlAuthentication::$socialService->verifyEmailDomain( 154 | $email, 155 | $settings->allowedGoogleDomains, 156 | $settings->googleEmailMismatch 157 | ); 158 | } 159 | 160 | $fullName = $payload['name'] ?? ''; 161 | 162 | return compact( 163 | 'email', 164 | 'fullName' 165 | ); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/services/MagicService.php: -------------------------------------------------------------------------------- 1 | allowMagicAuthentication) { 48 | return; 49 | } 50 | 51 | $event->messages[] = [ 52 | 'key' => 'magic_link', 53 | 'heading' => 'Magic Link Authentication', 54 | 'subject' => 'Open this link to log in to {{systemName}}', 55 | 'body' => "Hey {{user.friendlyName|e}},\r\n\r\nUse the following link to sign in to your account: {{siteUrl}}auth?magicCode={{code}}\r\n\r\nOr, use the following code to sign in: {{code}}.\r\n\r\nThe link and code will expire in 15 minutes.", 56 | ]; 57 | } 58 | 59 | /** 60 | * Registers magic authentication mutations 61 | * 62 | * @param RegisterGqlMutationsEvent $event 63 | */ 64 | public function registerGqlMutations(RegisterGqlMutationsEvent $event) 65 | { 66 | $settings = GraphqlAuthentication::$settings; 67 | 68 | if (!$settings->allowMagicAuthentication) { 69 | return; 70 | } 71 | 72 | $userService = GraphqlAuthentication::$userService; 73 | $tokenService = GraphqlAuthentication::$tokenService; 74 | $errorService = GraphqlAuthentication::$errorService; 75 | 76 | $elementsService = Craft::$app->getElements(); 77 | $usersService = Craft::$app->getUsers(); 78 | $mailerService = Craft::$app->getMailer(); 79 | 80 | $event->mutations['sendMagicLink'] = [ 81 | 'description' => 'Sends magic log in link. Returns string.', 82 | 'type' => Type::nonNull(Type::string()), 83 | 'args' => [ 84 | 'email' => Type::nonNull(Type::string()), 85 | ], 86 | 'resolve' => function($source, array $arguments) use ($settings, $elementsService, $usersService, $mailerService, $errorService) { 87 | $email = $arguments['email']; 88 | $user = $usersService->getUserByUsernameOrEmail($email); 89 | $message = $settings->magicLinkSent; 90 | 91 | if (!$user) { 92 | return $message; 93 | } 94 | 95 | $schemaId = GqlSchemaRecord::find()->select(['id'])->where(['name' => $settings->schemaName])->scalar(); 96 | 97 | if ($settings->permissionType === 'multiple') { 98 | $userGroup = $user->getGroups()[0] ?? null; 99 | 100 | if ($userGroup) { 101 | $schemaName = $settings->granularSchemas['group-' . $userGroup->id]['schemaName'] ?? null; 102 | $schemaId = GqlSchemaRecord::find()->select(['id'])->where(['name' => $schemaName])->scalar(); 103 | } 104 | } 105 | 106 | if (!$schemaId) { 107 | $errorService->throw($settings->invalidSchema); 108 | } 109 | 110 | $code = str_pad(random_int(0, 999999), 6, 0, STR_PAD_LEFT); 111 | 112 | $magicCodeElement = new MagicCode([ 113 | 'code' => $code, 114 | 'userId' => $user->id, 115 | 'schemaId' => $schemaId, 116 | 'expiryDate' => (new DateTime())->modify('+15 mins')->format('Y-m-d H:i:s'), 117 | ]); 118 | 119 | if (!$elementsService->saveElement($magicCodeElement)) { 120 | $errors = $magicCodeElement->getErrors(); 121 | $errorService->throw($errors[key($errors)][0]); 122 | } 123 | 124 | $mailerService 125 | ->composeFromKey('magic_link', [ 126 | 'code' => $code, 127 | 'user' => $user, 128 | ]) 129 | ->setTo($user) 130 | ->send(); 131 | 132 | return $message; 133 | }, 134 | ]; 135 | 136 | $event->mutations['verifyMagicCode'] = [ 137 | 'description' => 'Verifies magic log in link code. Returns user and token.', 138 | 'type' => Type::nonNull(Auth::getType()), 139 | 'args' => [ 140 | 'code' => Type::nonNull(Type::int()), 141 | 'email' => Type::nonNull(Type::string()), 142 | ], 143 | 'resolve' => function($source, array $arguments) use ($settings, $tokenService, $userService, $elementsService, $usersService, $errorService) { 144 | $code = $arguments['code']; 145 | $email = $arguments['email']; 146 | 147 | $this->_clearExpiredCodes(); 148 | /** @var MagicCode|null $magicCodeElement */ 149 | $magicCodeElement = MagicCode::find()->where(['[[code]]' => $code])->one(); 150 | 151 | if (!$magicCodeElement) { 152 | $errorService->throw($settings->invalidMagicCode); 153 | } 154 | 155 | $user = $usersService->getUserByUsernameOrEmail($email); 156 | 157 | if (!$user) { 158 | $errorService->throw($settings->invalidMagicCode); 159 | } 160 | 161 | if ($user->id !== $magicCodeElement->userId) { 162 | $errorService->throw($settings->invalidMagicCode); 163 | } 164 | 165 | $schemaId = $magicCodeElement->schemaId; 166 | 167 | if (!$schemaId) { 168 | $errorService->throw($settings->invalidSchema); 169 | } 170 | 171 | $elementsService->deleteElementById($magicCodeElement->id); 172 | $token = $tokenService->create($user, $schemaId); 173 | 174 | return $userService->getResponseFields($user, $schemaId, $token); 175 | }, 176 | ]; 177 | } 178 | 179 | /** 180 | * Clears expired magic codes 181 | */ 182 | protected function _clearExpiredCodes() 183 | { 184 | /** @var MagicCode[] $magicCodes */ 185 | $magicCodes = MagicCode::find()->where('[[expiryDate]] <= CURRENT_TIMESTAMP')->all(); 186 | 187 | $elementsService = Craft::$app->getElements(); 188 | 189 | foreach ($magicCodes as $magicCode) { 190 | $elementsService->deleteElementById($magicCode->id, null, null, true); 191 | } 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/services/MicrosoftService.php: -------------------------------------------------------------------------------- 1 | getHeaderToken()) { 53 | return; 54 | } 55 | 56 | if (!$this->_validateSettings()) { 57 | return; 58 | } 59 | 60 | $event->queries['microsoftOauthUrl'] = [ 61 | 'description' => 'Generates the Microsoft OAuth URL for allowing users to authenticate.', 62 | 'type' => Type::nonNull(Type::string()), 63 | 'args' => [], 64 | 'resolve' => function() { 65 | $settings = GraphqlAuthentication::$settings; 66 | 67 | $provider = new Azure([ 68 | 'clientId' => GraphqlAuthentication::getInstance()->getSettingsData($settings->microsoftAppId), 69 | 'clientSecret' => GraphqlAuthentication::getInstance()->getSettingsData($settings->microsoftAppSecret), 70 | 'redirectUri' => GraphqlAuthentication::getInstance()->getSettingsData($settings->microsoftRedirectUrl), 71 | ]); 72 | 73 | $state = Craft::$app->getSecurity()->generateRandomString(); 74 | $sessionService = Craft::$app->getSession(); 75 | $sessionService->set('state', $state); 76 | 77 | $url = $provider->getAuthorizationUrl([ 78 | 'scope' => ['offline_access', 'profile', 'user', 'email'], 79 | 'state' => $state, 80 | ]); 81 | 82 | return $url; 83 | }, 84 | ]; 85 | } 86 | 87 | /** 88 | * Registers Login with Microsoft mutations 89 | * 90 | * @param RegisterGqlMutationsEvent $event 91 | */ 92 | public function registerGqlMutations(RegisterGqlMutationsEvent $event) 93 | { 94 | if (!$this->_validateSettings()) { 95 | return; 96 | } 97 | 98 | switch (GraphqlAuthentication::$settings->permissionType) { 99 | case 'single': 100 | $event->mutations['microsoftSignIn'] = [ 101 | 'description' => 'Authenticates a user using a Login with Microsoft token. Returns user and token.', 102 | 'type' => Type::nonNull(Auth::getType()), 103 | 'args' => [ 104 | 'code' => Type::nonNull(Type::string()), 105 | 'state' => Type::nonNull(Type::string()), 106 | ], 107 | 'resolve' => function($source, array $arguments) { 108 | $settings = GraphqlAuthentication::$settings; 109 | $schemaId = GqlSchemaRecord::find()->select(['id'])->where(['name' => $settings->schemaName])->scalar(); 110 | 111 | if (!$schemaId) { 112 | GraphqlAuthentication::$errorService->throw($settings->invalidSchema); 113 | } 114 | 115 | $code = $arguments['code']; 116 | $state = $arguments['state']; 117 | $tokenUser = $this->_getUserFromToken($code, $state); 118 | 119 | $user = GraphqlAuthentication::$socialService->authenticate($tokenUser, $schemaId); 120 | return $user; 121 | }, 122 | ]; 123 | break; 124 | 125 | case 'multiple': 126 | $userGroupsService = Craft::$app->getUserGroups(); 127 | $userGroups = $userGroupsService->getAllGroups(); 128 | 129 | foreach ($userGroups as $userGroup) { 130 | $handle = ucfirst($userGroup->handle); 131 | 132 | $event->mutations["microsoftSignIn{$handle}"] = [ 133 | 'description' => "Authenticates a {$userGroup->name} using a Login with Microsoft token. Returns user and token.", 134 | 'type' => Type::nonNull(Auth::getType()), 135 | 'args' => [ 136 | 'code' => Type::nonNull(Type::string()), 137 | 'state' => Type::nonNull(Type::string()), 138 | ], 139 | 'resolve' => function($source, array $arguments) use ($userGroup) { 140 | $settings = GraphqlAuthentication::$settings; 141 | $schemaName = $settings->granularSchemas['group-' . $userGroup->id]['schemaName'] ?? null; 142 | $schemaId = GqlSchemaRecord::find()->select(['id'])->where(['name' => $schemaName])->scalar(); 143 | 144 | if (!$schemaId) { 145 | GraphqlAuthentication::$errorService->throw($settings->invalidSchema); 146 | } 147 | 148 | $code = $arguments['code']; 149 | $state = $arguments['state']; 150 | $tokenUser = $this->_getUserFromToken($code, $state); 151 | 152 | $user = GraphqlAuthentication::$socialService->authenticate($tokenUser, $schemaId, $userGroup->id); 153 | return $user; 154 | }, 155 | ]; 156 | } 157 | break; 158 | } 159 | } 160 | 161 | // Protected Methods 162 | // ========================================================================= 163 | 164 | /** 165 | * Ensures settings are set 166 | * 167 | * @return bool 168 | */ 169 | protected function _validateSettings(): bool 170 | { 171 | $settings = GraphqlAuthentication::$settings; 172 | return (bool) $settings->microsoftAppId && (bool) $settings->microsoftAppSecret && (bool) $settings->microsoftRedirectUrl; 173 | } 174 | 175 | /** 176 | * Gets user details from Login with Microsoft token 177 | * 178 | * @param string $code 179 | * @param string $state 180 | * @return array 181 | * @throws Error 182 | */ 183 | protected function _getUserFromToken(string $code, string $state): array 184 | { 185 | $settings = GraphqlAuthentication::$settings; 186 | $errorService = GraphqlAuthentication::$errorService; 187 | 188 | try { 189 | $provider = new Azure([ 190 | 'clientId' => GraphqlAuthentication::getInstance()->getSettingsData($settings->microsoftAppId), 191 | 'clientSecret' => GraphqlAuthentication::getInstance()->getSettingsData($settings->microsoftAppSecret), 192 | 'redirectUri' => GraphqlAuthentication::getInstance()->getSettingsData($settings->microsoftRedirectUrl), 193 | ]); 194 | 195 | $accessToken = $provider->getAccessToken('authorization_code', [ 196 | 'code' => $code, 197 | ]); 198 | 199 | /** @var AzureResourceOwner $user */ 200 | $user = $provider->getResourceOwner($accessToken); 201 | $email = $user->claim('email') ?? $user->claim('upn'); 202 | 203 | if (!$email) { 204 | $errorService->throw($settings->emailNotInScope); 205 | } 206 | 207 | if ($settings->allowedMicrosoftDomains) { 208 | GraphqlAuthentication::$socialService->verifyEmailDomain( 209 | $email, 210 | $settings->allowedMicrosoftDomains, 211 | $settings->microsoftEmailMismatch 212 | ); 213 | } 214 | 215 | $firstName = $user->claim('given_name') ?? ''; 216 | $lastName = $user->claim('family_name') ?? ''; 217 | 218 | return compact( 219 | 'email', 220 | 'firstName', 221 | 'lastName' 222 | ); 223 | } catch (Throwable $e) { 224 | $errorService->throw($e->getMessage()); 225 | } 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/services/RestrictionService.php: -------------------------------------------------------------------------------- 1 | restrictMutationFields($event); 68 | $this->ensureEntryMutationAllowed($event); 69 | } 70 | ); 71 | 72 | Event::on( 73 | Entry::class, 74 | Entry::EVENT_BEFORE_DELETE, 75 | [$this, 'ensureEntryMutationAllowed'] 76 | ); 77 | 78 | Event::on( 79 | Asset::class, 80 | Asset::EVENT_BEFORE_SAVE, 81 | function(ModelEvent $event) { 82 | $this->restrictMutationFields($event); 83 | $this->ensureAssetMutationAllowed($event); 84 | } 85 | ); 86 | 87 | Event::on( 88 | Asset::class, 89 | Asset::EVENT_BEFORE_DELETE, 90 | [$this, 'ensureAssetMutationAllowed'] 91 | ); 92 | } 93 | 94 | /** 95 | * Overwrites default Craft resolvers with plugin's restriction-enabled ones from /resolvers 96 | * 97 | * @param RegisterGqlQueriesEvent $event 98 | */ 99 | public function registerGqlQueries(RegisterGqlQueriesEvent $event) 100 | { 101 | if (!GraphqlAuthentication::$tokenService->getHeaderToken()) { 102 | return; 103 | } 104 | 105 | $resolvers = [ 106 | 'entries' => EntryResolver::class . '::resolve', 107 | 'entry' => EntryResolver::class . '::resolveOne', 108 | 'entryCount' => EntryResolver::class . '::resolveCount', 109 | 'assets' => AssetResolver::class . '::resolve', 110 | 'asset' => AssetResolver::class . '::resolveOne', 111 | 'assetCount' => AssetResolver::class . '::resolveCount', 112 | 'globalSets' => GlobalSetResolver::class . '::resolve', 113 | 'globalSet' => GlobalSetResolver::class . '::resolveOne', 114 | ]; 115 | 116 | foreach (Craft::$app->getEntries()->getAllSections() as $section) { 117 | // "Entries" was added in Craft 5.6.5 118 | $resolvers[$section->handle] = EntryResolver::class . '::resolve'; 119 | $resolvers["{$section->handle}Entries"] = EntryResolver::class . '::resolve'; 120 | } 121 | 122 | foreach ($resolvers as $name => $resolver) { 123 | if (isset($event->queries[$name])) { 124 | $event->queries[$name]['resolver'] = $resolver; 125 | } 126 | } 127 | } 128 | 129 | /** 130 | * Ensures plugin should be adding user restrictions 131 | * 132 | * @return bool 133 | */ 134 | public function shouldRestrictRequests(): bool 135 | { 136 | if (Craft::$app->getRequest()->isConsoleRequest) { 137 | return false; 138 | } 139 | 140 | return (bool) GraphqlAuthentication::$tokenService->getHeaderToken(); 141 | } 142 | 143 | /** 144 | * Ensures plugin should be adding schema restrictions 145 | * 146 | * @return bool 147 | */ 148 | public function shouldRestrictFields(): bool 149 | { 150 | return Craft::$app->requestedRoute === 'graphql/api'; 151 | } 152 | 153 | /** 154 | * Ensures the correct schema is returned 155 | * 156 | * @return GqlSchema 157 | */ 158 | public function getSchema(): GqlSchema 159 | { 160 | if (GraphqlAuthentication::$tokenService->getHeaderToken()) { 161 | return GraphqlAuthentication::$tokenService->getSchemaFromToken(); 162 | } 163 | 164 | $gqlService = Craft::$app->getGql(); 165 | $schema = $gqlService->getPublicSchema(); 166 | 167 | $requestHeaders = Craft::$app->getRequest()->getHeaders(); 168 | $authHeaders = $requestHeaders->get('authorization', [], false); 169 | 170 | foreach ($authHeaders as $authHeader) { 171 | $authValues = array_map('trim', explode(',', $authHeader)); 172 | 173 | foreach ($authValues as $authValue) { 174 | if (preg_match('/^Bearer\s+(.+)$/i', $authValue, $matches)) { 175 | try { 176 | $token = $gqlService->getTokenByAccessToken($matches[1]); 177 | $schema = $token->getSchema(); 178 | } catch (InvalidArgumentException) { 179 | } 180 | 181 | break 2; 182 | } 183 | } 184 | } 185 | 186 | return $schema; 187 | } 188 | 189 | 190 | /** 191 | * Restricts private fields from being accessed, based on the schema grabbed from the auth token 192 | * 193 | * @param ExecuteGqlQueryEvent $event 194 | */ 195 | public function restrictForbiddenFields(ExecuteGqlQueryEvent $event) 196 | { 197 | if (!$this->shouldRestrictFields()) { 198 | return; 199 | } 200 | 201 | $settings = GraphqlAuthentication::$settings; 202 | $fieldRestrictions = $settings->fieldRestrictions ?? []; 203 | 204 | if (!count($fieldRestrictions)) { 205 | return; 206 | } 207 | 208 | /** @var OperationDefinitionNode[] $definitions */ 209 | /** @phpstan-ignore-next-line */ 210 | $definitions = Parser::parse($event->query)->definitions ?? []; 211 | 212 | if (!count($definitions)) { 213 | return; 214 | } 215 | 216 | $queries = []; 217 | $introspectionQueries = []; 218 | 219 | foreach ($definitions as $definition) { 220 | /** @phpstan-ignore-next-line */ 221 | foreach ($definition->selectionSet->selections ?? [] as $selectionSet) { 222 | /** @var FieldNode $selectionSet */ 223 | $queries[] = $selectionSet; 224 | 225 | /** @phpstan-ignore-next-line */ 226 | if (StringHelper::containsAny($selectionSet->name->value ?? '', ['__schema', '__type'])) { 227 | $introspectionQueries[] = $selectionSet; 228 | } 229 | } 230 | } 231 | 232 | if (count($introspectionQueries) === count($queries)) { 233 | return; 234 | } 235 | 236 | $schema = $this->getSchema(); 237 | $schemaCode = $schema->isPublic ? $schema->id : $schema->name; 238 | 239 | $fieldPermissions = $fieldRestrictions['schema-' . $schemaCode] ?? []; 240 | 241 | if (!count($fieldPermissions)) { 242 | return; 243 | } 244 | 245 | $errorService = GraphqlAuthentication::$errorService; 246 | 247 | $queryFields = array_keys(array_filter($fieldPermissions, function($permission) { 248 | return $permission === 'query'; 249 | })); 250 | 251 | $privateFields = array_keys(array_filter($fieldPermissions, function($permission) { 252 | return $permission === 'private'; 253 | })); 254 | 255 | foreach ($definitions as $definition) { 256 | /** @phpstan-ignore-next-line */ 257 | if (!isset($definition->operation)) { 258 | continue; 259 | } 260 | 261 | if ($definition->operation === 'query') { 262 | $forbiddenArguments = $privateFields; 263 | } else { 264 | $forbiddenArguments = array_merge($queryFields, $privateFields); 265 | } 266 | 267 | /** @phpstan-ignore-next-line */ 268 | foreach ($definition->selectionSet->selections ?? [] as $selectionSet) { 269 | // loop through arguments 270 | foreach ($selectionSet->arguments ?? [] as $argument) { 271 | if (in_array($argument->name->value ?? '', $forbiddenArguments)) { 272 | $errorService->throw($settings->forbiddenField, true); 273 | } 274 | } 275 | 276 | // loop through field selections 277 | $this->_ensureValidFields($selectionSet, $privateFields); 278 | } 279 | } 280 | } 281 | 282 | /** 283 | * Loops through mutation fields and checks them against validators 284 | * 285 | * @param ModelEvent $event 286 | * @throws Error 287 | */ 288 | public function restrictMutationFields(ModelEvent $event) 289 | { 290 | if (!$this->shouldRestrictFields()) { 291 | return; 292 | } 293 | 294 | /** @var Entry|Asset $element */ 295 | $element = $event->sender; 296 | $this->restrictMutationFieldsForElement($element); 297 | } 298 | 299 | private function restrictMutationFieldsForElement(ElementInterface $element): void 300 | { 301 | foreach ($element->getFieldValues() as $fieldValue) { 302 | if (!$fieldValue instanceof ElementQuery) { 303 | continue; 304 | } 305 | 306 | foreach ($fieldValue->all() as $e) { 307 | if ($e instanceof NestedElementInterface && $e->getOwnerId()) { 308 | $this->restrictMutationFieldsForElement($e); 309 | } elseif ($e instanceof Entry) { 310 | $this->_ensureValidEntry($e->id, $element->siteId); 311 | } elseif ($e instanceof Asset) { 312 | $this->_ensureValidAsset($e->id); 313 | } 314 | } 315 | } 316 | } 317 | 318 | /** 319 | * Ensures user isn't trying to mutate a private entry 320 | * 321 | * @param ModelEvent $event 322 | * @return bool 323 | * @throws Error 324 | */ 325 | public function ensureEntryMutationAllowed(ModelEvent $event): bool 326 | { 327 | if (!$this->shouldRestrictRequests()) { 328 | return true; 329 | } 330 | 331 | /** @var Entry $entry */ 332 | $entry = $event->sender; 333 | $user = GraphqlAuthentication::$tokenService->getUserFromToken(); 334 | 335 | if ($user && $event->isNew && !$entry->authorId) { 336 | $entry->authorId = $user->id; 337 | } 338 | 339 | $authorOnlySections = $user ? $this->getAuthorOnlySections($user, 'mutation') : []; 340 | 341 | $entriesService = Craft::$app->getEntries(); 342 | $sectionIdToTest = $entry->sectionId ? $entry->sectionId : $entry->getRootOwner()->sectionId; 343 | $entrySection = $entriesService->getSectionById($sectionIdToTest)->handle; 344 | 345 | if (!in_array($entrySection, $authorOnlySections)) { 346 | return true; 347 | } 348 | 349 | if (!$user || $entry->authorId != $user->id) { 350 | GraphqlAuthentication::$errorService->throw(GraphqlAuthentication::$settings->forbiddenMutation); 351 | } 352 | 353 | return true; 354 | } 355 | 356 | /** 357 | * Ensures user isn't trying to mutate a private asset 358 | * 359 | * @param ModelEvent $event 360 | * @return bool 361 | * @throws Error 362 | */ 363 | public function ensureAssetMutationAllowed(ModelEvent $event): bool 364 | { 365 | if (!$this->shouldRestrictRequests()) { 366 | return true; 367 | } 368 | 369 | /** @var Asset $asset */ 370 | $asset = $event->sender; 371 | $user = GraphqlAuthentication::$tokenService->getUserFromToken(); 372 | 373 | if ($user && $event->isNew && !$asset->uploaderId) { 374 | $asset->uploaderId = $user->id; 375 | return true; 376 | } 377 | 378 | $authorOnlyVolumes = $user ? $this->getAuthorOnlyVolumes($user, 'mutation') : []; 379 | 380 | $volumesService = Craft::$app->getVolumes(); 381 | $assetVolume = $volumesService->getVolumeById($asset->volumeId)->handle; 382 | 383 | if (!in_array($assetVolume, $authorOnlyVolumes)) { 384 | return true; 385 | } 386 | 387 | if (!$user || $asset->uploaderId != $user->id) { 388 | GraphqlAuthentication::$errorService->throw(GraphqlAuthentication::$settings->forbiddenMutation); 389 | } 390 | 391 | return true; 392 | } 393 | 394 | /** 395 | * Gets author-only sections from plugin settings 396 | * 397 | * @param User $user 398 | * @param string $type 399 | * @return array 400 | */ 401 | public function getAuthorOnlySections(User $user, $type): array 402 | { 403 | $settings = GraphqlAuthentication::$settings; 404 | 405 | if ($type === 'query') { 406 | $authorOnlySections = $settings->entryQueries ?? []; 407 | } else { 408 | $authorOnlySections = $settings->entryMutations ?? []; 409 | } 410 | 411 | if ($settings->permissionType === 'multiple') { 412 | $userGroup = $user->getGroups()[0] ?? null; 413 | 414 | if ($userGroup) { 415 | $permissions = $settings->granularSchemas["group-{$userGroup->id}"]; 416 | 417 | if ($type === 'query') { 418 | $authorOnlySections = $permissions['entryQueries'] ?? []; 419 | } else { 420 | $authorOnlySections = $permissions['entryMutations'] ?? []; 421 | } 422 | } 423 | } 424 | 425 | $authorOnlySections = array_keys(array_filter($authorOnlySections, function($section) { 426 | return (bool) $section; 427 | })); 428 | 429 | return $authorOnlySections; 430 | } 431 | 432 | /** 433 | * Gets author-only volumes from plugin settings 434 | * 435 | * @param User $user 436 | * @param string $type 437 | * @return array 438 | */ 439 | public function getAuthorOnlyVolumes($user, $type): array 440 | { 441 | $settings = GraphqlAuthentication::$settings; 442 | $authorOnlyVolumes = []; 443 | 444 | if ($type === 'query') { 445 | $authorOnlyVolumes = $settings->assetQueries ?? []; 446 | } else { 447 | $authorOnlyVolumes = $settings->assetMutations ?? []; 448 | } 449 | 450 | if ($settings->permissionType === 'multiple') { 451 | $userGroup = $user->getGroups()[0] ?? null; 452 | 453 | if ($userGroup) { 454 | $permissions = $settings->granularSchemas["group-{$userGroup->id}"]; 455 | 456 | if ($type === 'query') { 457 | $authorOnlyVolumes = $permissions['assetQueries'] ?? []; 458 | } else { 459 | $authorOnlyVolumes = $permissions['assetMutations'] ?? []; 460 | } 461 | } 462 | } 463 | 464 | $authorOnlyVolumes = array_keys(array_filter($authorOnlyVolumes, function($section) { 465 | return (bool) $section; 466 | })); 467 | 468 | return $authorOnlyVolumes; 469 | } 470 | 471 | // Protected Methods 472 | // ========================================================================= 473 | 474 | /** 475 | * Recurses through query and mutation field selections, ensuring they're queryable 476 | * 477 | * @param FieldNode $selectionSet 478 | * @param array $fields 479 | * @throws Error 480 | */ 481 | private function _ensureValidFields($selectionSet, array $fields) 482 | { 483 | $errorService = GraphqlAuthentication::$errorService; 484 | $settings = GraphqlAuthentication::$settings; 485 | 486 | foreach ($selectionSet->selectionSet->selections ?? [] as $field) { 487 | if (in_array($field->name->value ?? '', $fields)) { 488 | $errorService->throw($settings->forbiddenField, true); 489 | } 490 | 491 | if (count($field->selectionSet->selections ?? [])) { 492 | $this->_ensureValidFields($field, $fields); 493 | } 494 | } 495 | } 496 | 497 | /** 498 | * Ensures entry being accessed isn't private 499 | * 500 | * @param int $id 501 | * @param int $siteId 502 | * @return bool 503 | * @throws Error 504 | */ 505 | protected function _ensureValidEntry(int $id, int $siteId): bool 506 | { 507 | $settings = GraphqlAuthentication::$settings; 508 | $errorService = GraphqlAuthentication::$errorService; 509 | 510 | $entriesService = Craft::$app->getEntries(); 511 | $entry = $entriesService->getEntryById($id, $siteId); 512 | 513 | if (!$entry) { 514 | $errorService->throw($settings->entryNotFound); 515 | } 516 | 517 | if (!$entry->authorId) { 518 | return true; 519 | } 520 | 521 | $tokenService = GraphqlAuthentication::$tokenService; 522 | $user = null; 523 | 524 | if ($tokenService->getHeaderToken()) { 525 | $user = $tokenService->getUserFromToken(); 526 | 527 | if ($user && $entry->authorId == $user->id) { 528 | return true; 529 | } 530 | } 531 | 532 | $scope = $this->getSchema()->scope; 533 | 534 | if (!in_array("sections.{$entry->section->uid}:read", $scope)) { 535 | $errorService->throw($settings->forbiddenMutation); 536 | } 537 | 538 | $authorOnlySections = $user ? $this->getAuthorOnlySections($user, 'mutation') : []; 539 | 540 | $entrySection = $entriesService->getSectionById($entry->sectionId)->handle; 541 | 542 | if (in_array($entrySection, $authorOnlySections)) { 543 | $errorService->throw($settings->forbiddenMutation); 544 | } 545 | 546 | return true; 547 | } 548 | 549 | /** 550 | * Ensures asset being accessed isn't private 551 | * 552 | * @param int $id 553 | * @return bool 554 | * @throws Error 555 | */ 556 | protected function _ensureValidAsset(int $id): bool 557 | { 558 | $settings = GraphqlAuthentication::$settings; 559 | $errorService = GraphqlAuthentication::$errorService; 560 | 561 | $assetsService = Craft::$app->getAssets(); 562 | $asset = $assetsService->getAssetById($id); 563 | 564 | if (!$asset) { 565 | $errorService->throw($settings->assetNotFound); 566 | } 567 | 568 | if (!$asset->uploaderId) { 569 | return true; 570 | } 571 | 572 | $tokenService = GraphqlAuthentication::$tokenService; 573 | $user = null; 574 | 575 | if ($tokenService->getHeaderToken()) { 576 | $user = $tokenService->getUserFromToken(); 577 | 578 | if ((string) $asset->uploaderId === (string) $user->id) { 579 | return true; 580 | } 581 | } 582 | 583 | $scope = $this->getSchema()->scope; 584 | 585 | if (!in_array("volumes.{$asset->volume->uid}:read", $scope)) { 586 | $errorService->throw($settings->forbiddenMutation); 587 | } 588 | 589 | $authorOnlyVolumes = $user ? $this->getAuthorOnlyVolumes($user, 'mutation') : []; 590 | 591 | $volumesService = Craft::$app->getVolumes(); 592 | $assetVolume = $volumesService->getVolumeById($asset->volumeId)->handle; 593 | 594 | if (in_array($assetVolume, $authorOnlyVolumes)) { 595 | $errorService->throw($settings->forbiddenMutation); 596 | } 597 | 598 | return true; 599 | } 600 | } 601 | -------------------------------------------------------------------------------- /src/services/SocialService.php: -------------------------------------------------------------------------------- 1 | throw(GraphqlAuthentication::$settings->invalidEmailAddress); 31 | } 32 | 33 | $domain = explode('@', $email)[1]; 34 | $domains = explode(',', str_replace(['http://', 'https://', 'www.', ' ', '/'], '', $domains)); 35 | 36 | if (!in_array($domain, $domains)) { 37 | $errorService->throw($error); 38 | } 39 | 40 | return true; 41 | } 42 | 43 | /** 44 | * Authenticates a user through social sign-in 45 | * 46 | * @param array $tokenUser 47 | * @param int $schemaId 48 | * @param int $userGroupId 49 | * @return array 50 | * @throws Error 51 | */ 52 | public function authenticate(array $tokenUser, int $schemaId, int $userGroupId = null): array 53 | { 54 | $settings = GraphqlAuthentication::$settings; 55 | $userService = GraphqlAuthentication::$userService; 56 | $errorService = GraphqlAuthentication::$errorService; 57 | 58 | $usersService = Craft::$app->getUsers(); 59 | $user = $usersService->getUserByUsernameOrEmail($tokenUser['email']); 60 | 61 | if (!$user) { 62 | if (!$userGroupId && !$settings->allowRegistration) { 63 | $errorService->throw($settings->userNotFound); 64 | } 65 | 66 | if ($userGroupId && !($settings->granularSchemas["group-{$userGroupId}"]['allowRegistration'] ?? false)) { 67 | $errorService->throw($settings->userNotFound); 68 | } 69 | 70 | $user = $userService->create([ 71 | 'email' => $tokenUser['email'], 72 | 'password' => '', 73 | 'fullName' => $tokenUser['fullName'], 74 | ], $userGroupId ?? $settings->userGroup, true); 75 | } 76 | 77 | if ($userGroupId) { 78 | $assignedGroups = array_column($user->groups, 'id'); 79 | 80 | if (!in_array($userGroupId, $assignedGroups)) { 81 | $errorService->throw($settings->forbiddenMutation); 82 | } 83 | } 84 | 85 | $token = GraphqlAuthentication::$tokenService->create($user, $schemaId); 86 | return $userService->getResponseFields($user, $schemaId, $token); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/services/TokenService.php: -------------------------------------------------------------------------------- 1 | builder; 51 | * $user = $event->user; 52 | * $builder->withClaim('customClaim', 'customValue'); 53 | * } 54 | * ); 55 | * ``` 56 | */ 57 | public const EVENT_BEFORE_CREATE_JWT = 'beforeCreateJwt'; 58 | 59 | /** 60 | * @event JwtValidateEvent The event that is triggered before validating a JWT. 61 | * 62 | * Plugins get a chance to add additional validators to the JWT verification. 63 | * 64 | * --- 65 | * ```php 66 | * use jamesedmonston\graphqlauthentication\events\JwtValidateEvent; 67 | * use jamesedmonston\graphqlauthentication\services\TokenService; 68 | * use Lcobucci\JWT\Validation\Constraint\IssuedBy; 69 | * use yii\base\Event; 70 | * 71 | * Event::on( 72 | * TokenService::class, 73 | * TokenService::EVENT_BEFORE_VALIDATE_JWT, 74 | * function(JwtValidateEvent $event) { 75 | * $config = $event->config; 76 | * $validator = new IssuedBy('Custom Validator'); 77 | * $config->setValidationConstraints($validator); 78 | * } 79 | * ); 80 | * ``` 81 | */ 82 | public const EVENT_BEFORE_VALIDATE_JWT = 'beforeValidateJwt'; 83 | 84 | // Public Methods 85 | // ========================================================================= 86 | 87 | /** 88 | * @inheritdoc 89 | */ 90 | public function init(): void 91 | { 92 | parent::init(); 93 | 94 | Event::on( 95 | Gql::class, 96 | Gql::EVENT_REGISTER_GQL_MUTATIONS, 97 | [$this, 'registerGqlMutations'] 98 | ); 99 | 100 | Event::on( 101 | Gql::class, 102 | Gql::EVENT_BEFORE_EXECUTE_GQL_QUERY, 103 | [$this, 'setActiveSchema'] 104 | ); 105 | } 106 | 107 | /** 108 | * Registers token management mutations 109 | * 110 | * @param RegisterGqlMutationsEvent $event 111 | */ 112 | public function registerGqlMutations(RegisterGqlMutationsEvent $event) 113 | { 114 | $settings = GraphqlAuthentication::$settings; 115 | $errorService = GraphqlAuthentication::$errorService; 116 | 117 | $event->mutations['refreshToken'] = [ 118 | 'description' => "Refreshes a user's JWT. Checks for the occurrence of the `gql_refreshToken` cookie, and falls back to `refreshToken` argument.", 119 | 'type' => Type::nonNull(Auth::getType()), 120 | 'args' => [ 121 | 'refreshToken' => Type::string(), 122 | ], 123 | 'resolve' => function($source, array $arguments) use ($settings, $errorService) { 124 | $refreshToken = $_COOKIE['gql_refreshToken'] ?? $arguments['refreshToken'] ?? null; 125 | 126 | if (!$refreshToken) { 127 | $errorService->throw($settings->invalidRefreshToken); 128 | } 129 | 130 | $this->_clearExpiredTokens(); 131 | /** @var RefreshToken|null $refreshTokenElement */ 132 | $refreshTokenElement = RefreshToken::find()->where(['[[token]]' => $refreshToken])->one(); 133 | 134 | if (!$refreshTokenElement) { 135 | $errorService->throw($settings->invalidRefreshToken); 136 | } 137 | 138 | $usersService = Craft::$app->getUsers(); 139 | $user = $usersService->getUserById($refreshTokenElement->userId); 140 | 141 | if (!$user) { 142 | $errorService->throw($settings->userNotFound); 143 | } 144 | 145 | $schemaId = $refreshTokenElement->schemaId; 146 | 147 | if (!$schemaId) { 148 | $errorService->throw($settings->invalidSchema); 149 | } 150 | 151 | $elementsService = Craft::$app->getElements(); 152 | $elementsService->deleteElementById($refreshTokenElement->id); 153 | $token = $this->create($user, $schemaId); 154 | 155 | return GraphqlAuthentication::$userService->getResponseFields($user, $schemaId, $token); 156 | }, 157 | ]; 158 | 159 | $event->mutations['deleteRefreshToken'] = [ 160 | 'description' => 'Deletes authenticated user refresh token. Useful for logging out of current device. Returns boolean.', 161 | 'type' => Type::nonNull(Type::boolean()), 162 | 'args' => [ 163 | 'refreshToken' => Type::string(), 164 | ], 165 | 'resolve' => function($source, array $arguments) use ($settings, $errorService) { 166 | if (!$this->getUserFromToken()) { 167 | $errorService->throw($settings->tokenNotFound); 168 | } 169 | 170 | $refreshToken = $_COOKIE['gql_refreshToken'] ?? $arguments['refreshToken'] ?? null; 171 | 172 | if (!$refreshToken) { 173 | $errorService->throw($settings->invalidRefreshToken); 174 | } 175 | 176 | GraphqlAuthentication::$tokenService->deleteRefreshToken($refreshToken); 177 | 178 | return true; 179 | }, 180 | ]; 181 | 182 | $event->mutations['deleteRefreshTokens'] = [ 183 | 'description' => 'Deletes all refresh tokens belonging to the authenticated user. Useful for logging out of all devices. Returns boolean.', 184 | 'type' => Type::nonNull(Type::boolean()), 185 | 'args' => [], 186 | 'resolve' => function() use ($settings, $errorService) { 187 | if (!$user = $this->getUserFromToken()) { 188 | $errorService->throw($settings->tokenNotFound); 189 | } 190 | 191 | GraphqlAuthentication::$tokenService->deleteRefreshTokens($user); 192 | 193 | return true; 194 | }, 195 | ]; 196 | } 197 | 198 | /** 199 | * Grabs the token from the `Authorization` header 200 | * 201 | * @return Token 202 | * @throws Error 203 | */ 204 | public function getHeaderToken(): ?Token 205 | { 206 | $requestHeaders = Craft::$app->getRequest()->getHeaders(); 207 | $authHeaders = $requestHeaders->get('authorization', [], false); 208 | 209 | if (empty($authHeaders)) { 210 | return null; 211 | } 212 | 213 | $settings = GraphqlAuthentication::$settings; 214 | $errorService = GraphqlAuthentication::$errorService; 215 | 216 | $token = null; 217 | 218 | foreach ($authHeaders as $authHeader) { 219 | $authValues = array_map('trim', explode(',', $authHeader)); 220 | 221 | foreach ($authValues as $authValue) { 222 | if (!preg_match('/^JWT\s+(.+)$/i', $authValue, $matches)) { 223 | continue; 224 | } 225 | 226 | if (!preg_match("/^[a-zA-Z0-9\-_]+?\.[a-zA-Z0-9\-_]+?\.([a-zA-Z0-9\-_]+)?$/", $matches[1])) { 227 | $errorService->throw($settings->invalidHeader); 228 | } 229 | 230 | $token = $this->parseToken($matches[1]); 231 | break 2; 232 | } 233 | } 234 | 235 | if (!$token) { 236 | return null; 237 | } 238 | 239 | $this->_validateToken($token); 240 | return $token; 241 | } 242 | 243 | /** 244 | * Sets the active schema to the one encoded into the JWT 245 | * 246 | * @param ExecuteGqlQueryEvent $event 247 | */ 248 | public function setActiveSchema(ExecuteGqlQueryEvent $event) 249 | { 250 | /** @var UnencryptedToken|null $token */ 251 | $token = $this->getHeaderToken(); 252 | 253 | if (!$token) { 254 | return; 255 | } 256 | 257 | if (isset($event->variables['gql_cacheKey'])) { 258 | return; 259 | } 260 | 261 | $gqlService = Craft::$app->getGql(); 262 | $schema = $this->getSchemaFromToken(); 263 | 264 | // Insert user-specific cache key 265 | $event->variables['gql_cacheKey'] = 'user-' . $token->claims()->get('sub'); 266 | 267 | $event->result = $gqlService->executeQuery( 268 | $schema, 269 | $event->query, 270 | $event->variables, 271 | $event->operationName, 272 | YII_DEBUG 273 | ); 274 | } 275 | 276 | /** 277 | * Returns the schema linked to a token 278 | * 279 | * @return GqlSchema 280 | */ 281 | public function getSchemaFromToken(): GqlSchema 282 | { 283 | $settings = GraphqlAuthentication::$settings; 284 | $errorService = GraphqlAuthentication::$errorService; 285 | 286 | /** @var UnencryptedToken|null $token */ 287 | $token = $this->getHeaderToken(); 288 | 289 | if (!$token) { 290 | $errorService->throw($settings->invalidHeader); 291 | } 292 | 293 | $gqlService = Craft::$app->getGql(); 294 | $schemaId = $token->claims()->get('schemaId') ?? null; 295 | 296 | if (!$schemaId) { 297 | $errorService->throw($settings->invalidHeader); 298 | } 299 | 300 | if (!$schema = $gqlService->getSchemaById($schemaId)) { 301 | $errorService->throw($settings->invalidHeader); 302 | } 303 | 304 | return $schema; 305 | } 306 | 307 | /** 308 | * Returns the user entity linked to a token 309 | * 310 | * @param Token|null $token 311 | * @return ?User 312 | * @throws Error 313 | */ 314 | public function getUserFromToken(?Token $token = null): ?User 315 | { 316 | if (!$token) { 317 | $token = $this->getHeaderToken(); 318 | } 319 | 320 | if (!$token) { 321 | GraphqlAuthentication::$errorService->throw(GraphqlAuthentication::$settings->invalidHeader); 322 | } 323 | 324 | /** @var UnencryptedToken $token */ 325 | $id = $token->claims()->get('sub'); 326 | 327 | $usersService = Craft::$app->getUsers(); 328 | return $usersService->getUserById($id); 329 | } 330 | 331 | /** 332 | * Creates a JWT and refresh token. Sends refresh token as a cookie in response 333 | * 334 | * @param User $user 335 | * @param Int $schemaId 336 | * @return array 337 | * @throws Error 338 | */ 339 | public function create(User $user, int $schemaId) 340 | { 341 | $settings = GraphqlAuthentication::$settings; 342 | $errorService = GraphqlAuthentication::$errorService; 343 | 344 | if (!$jwtSecretKey = GraphqlAuthentication::getInstance()->getSettingsData($settings->jwtSecretKey)) { 345 | $errorService->throw($settings->invalidJwtSecretKey); 346 | } 347 | 348 | $jwtConfig = Configuration::forSymmetricSigner( 349 | new Sha256(), 350 | InMemory::plainText($jwtSecretKey) 351 | ); 352 | 353 | $gqlService = Craft::$app->getGql(); 354 | $now = new DateTimeImmutable(); 355 | 356 | $builder = $jwtConfig->builder() 357 | ->issuedBy(Craft::$app->id ?? UrlHelper::cpUrl()) 358 | ->issuedAt($now) 359 | ->expiresAt($now->modify($settings->jwtExpiration)) 360 | ->relatedTo($user->id) 361 | ->withClaim('fullName', $user->fullName) 362 | ->withClaim('email', $user->email) 363 | ->withClaim('groups', array_column($user->getGroups(), 'name')) 364 | ->withClaim('schema', $gqlService->getSchemaById($schemaId)->name) 365 | ->withClaim('schemaId', $schemaId) 366 | ->withClaim('admin', $user->admin); 367 | 368 | $event = new JwtCreateEvent([ 369 | 'builder' => $builder, 370 | 'user' => $user, 371 | ]); 372 | 373 | $this->trigger(self::EVENT_BEFORE_CREATE_JWT, $event); 374 | 375 | $jwt = $event->builder->getToken($jwtConfig->signer(), $jwtConfig->signingKey()); 376 | $jwtExpiration = date_create(date('Y-m-d H:i:s'))->modify("+ {$settings->jwtExpiration}"); 377 | $refreshToken = Craft::$app->getSecurity()->generateRandomString(32); 378 | $refreshTokenExpiration = date_create(date('Y-m-d H:i:s'))->modify("+ {$settings->jwtRefreshExpiration}"); 379 | 380 | $refreshTokenElement = new RefreshToken([ 381 | 'token' => $refreshToken, 382 | 'userId' => $user->id, 383 | 'schemaId' => $schemaId, 384 | 'expiryDate' => $refreshTokenExpiration->format('Y-m-d H:i:s'), 385 | ]); 386 | 387 | $elementsService = Craft::$app->getElements(); 388 | 389 | if (!$elementsService->saveElement($refreshTokenElement)) { 390 | $errors = $refreshTokenElement->getErrors(); 391 | $errorService->throw($errors[key($errors)][0]); 392 | } 393 | 394 | $this->_setCookie('gql_refreshToken', $refreshToken, $settings->jwtRefreshExpiration); 395 | 396 | return [ 397 | 'jwt' => $jwt->toString(), 398 | 'jwtExpiresAt' => $jwtExpiration->getTimestamp() * 1000, 399 | 'refreshToken' => $refreshToken, 400 | 'refreshTokenExpiresAt' => $refreshTokenExpiration->getTimestamp() * 1000, 401 | ]; 402 | } 403 | 404 | /** 405 | * Deletes specific refresh token 406 | * 407 | * @param string $refreshToken 408 | */ 409 | public function deleteRefreshToken(string $refreshToken) 410 | { 411 | $refreshToken = RefreshToken::find()->where(['[[token]]' => $refreshToken])->one(); 412 | 413 | if (!$refreshToken) { 414 | GraphqlAuthentication::$errorService->throw(GraphqlAuthentication::$settings->invalidRefreshToken); 415 | } 416 | 417 | $elementsService = Craft::$app->getElements(); 418 | $elementsService->deleteElementById($refreshToken->id); 419 | } 420 | 421 | /** 422 | * Deletes refresh tokens linked to user 423 | * 424 | * @param User $user 425 | */ 426 | public function deleteRefreshTokens(User $user) 427 | { 428 | /** @var RefreshToken[] $refreshTokens */ 429 | $refreshTokens = RefreshToken::find()->where(['[[userId]]' => $user->id])->all(); 430 | 431 | $elementsService = Craft::$app->getElements(); 432 | 433 | foreach ($refreshTokens as $refreshToken) { 434 | $elementsService->deleteElementById($refreshToken->id); 435 | } 436 | } 437 | 438 | /** 439 | * @param string $token 440 | * @return Token 441 | */ 442 | public function parseToken(string $token): Token 443 | { 444 | /** @var GraphqlAuthentication $plugin Suppress NPE warning as this cannot happen here */ 445 | $plugin = GraphqlAuthentication::getInstance(); 446 | 447 | $settings = GraphqlAuthentication::$settings; 448 | 449 | $jwtSecretKey = $plugin->getSettingsData($settings->jwtSecretKey); 450 | 451 | $jwtConfig = Configuration::forSymmetricSigner( 452 | new Sha256(), 453 | InMemory::plainText($jwtSecretKey) 454 | ); 455 | 456 | $validator = new SignedWith( 457 | new Sha256(), 458 | InMemory::plainText($jwtSecretKey) 459 | ); 460 | 461 | $jwtConfig->setValidationConstraints($validator); 462 | $constraints = $jwtConfig->validationConstraints(); 463 | 464 | $errorService = GraphqlAuthentication::$errorService; 465 | 466 | try { 467 | $jwt = $jwtConfig->parser()->parse($token); 468 | } catch (InvalidArgumentException $e) { 469 | $errorService->throw($e->getMessage()); 470 | } 471 | 472 | $event = new JwtValidateEvent([ 473 | 'config' => $jwtConfig, 474 | ]); 475 | 476 | $this->trigger(self::EVENT_BEFORE_VALIDATE_JWT, $event); 477 | 478 | try { 479 | $jwtConfig->validator()->assert($jwt, ...$constraints, ...$event->config->validationConstraints()); 480 | } catch (RequiredConstraintsViolated $e) { 481 | $errorService->throw($settings->invalidHeader); 482 | } 483 | 484 | return $jwt; 485 | } 486 | 487 | // Protected Methods 488 | // ========================================================================= 489 | 490 | /** 491 | * Sends a cookie with a response 492 | * 493 | * @param string $name 494 | * @param string $token 495 | * @param string $expiration 496 | * @return bool 497 | */ 498 | protected function _setCookie(string $name, string $token, string $expiration = null): bool 499 | { 500 | $settings = GraphqlAuthentication::$settings; 501 | $expiry = 0; 502 | 503 | if ($expiration) { 504 | $expiry = strtotime((new DateTime())->modify("+ {$expiration}")->format('Y-m-d H:i:s')); 505 | } 506 | 507 | return setcookie($name, $token, [ 508 | 'expires' => $expiry, 509 | 'path' => '/', 510 | 'domain' => '', 511 | 'secure' => true, 512 | 'httponly' => true, 513 | 'samesite' => $settings->sameSitePolicy, 514 | ]); 515 | } 516 | 517 | /** 518 | * Validates token and user activation state 519 | * 520 | * @param Token $token 521 | * @throws Error 522 | */ 523 | protected function _validateToken(Token $token) 524 | { 525 | $settings = GraphqlAuthentication::$settings; 526 | $errorService = GraphqlAuthentication::$errorService; 527 | 528 | /** @var UnencryptedToken|null $token */ 529 | /** @var DateTimeImmutable $expiry */ 530 | $expiry = $token->claims()->get('exp'); 531 | 532 | if (DateTimeHelper::isInThePast($expiry->format('Y-m-d H:i:s'))) { 533 | $errorService->throw($settings->invalidHeader, true); 534 | } 535 | 536 | if (!$user = $this->getUserFromToken($token)) { 537 | $errorService->throw($settings->invalidHeader); 538 | } 539 | 540 | if ($user->status !== 'active' && !$settings->skipActivatedCheck) { 541 | $errorService->throw($settings->userNotActivated, true); 542 | } 543 | } 544 | 545 | /** 546 | * Clears expired refresh tokens 547 | */ 548 | protected function _clearExpiredTokens() 549 | { 550 | /** @var RefreshToken[] $refreshTokens */ 551 | $refreshTokens = RefreshToken::find()->where('[[expiryDate]] <= CURRENT_TIMESTAMP')->all(); 552 | 553 | $elementsService = Craft::$app->getElements(); 554 | 555 | foreach ($refreshTokens as $refreshToken) { 556 | $elementsService->deleteElementById($refreshToken->id, null, null, true); 557 | } 558 | } 559 | } 560 | -------------------------------------------------------------------------------- /src/services/TwitterService.php: -------------------------------------------------------------------------------- 1 | getHeaderToken()) { 51 | return; 52 | } 53 | 54 | if (!$this->_validateSettings()) { 55 | return; 56 | } 57 | 58 | $event->queries['twitterOauthUrl'] = [ 59 | 'description' => 'Generates the Twitter OAuth URL for allowing users to authenticate.', 60 | 'type' => Type::nonNull(Type::string()), 61 | 'args' => [], 62 | 'resolve' => function() { 63 | $settings = GraphqlAuthentication::$settings; 64 | 65 | $client = new TwitterOAuth( 66 | GraphqlAuthentication::getInstance()->getSettingsData($settings->twitterApiKey), 67 | GraphqlAuthentication::getInstance()->getSettingsData($settings->twitterApiKeySecret) 68 | ); 69 | 70 | $requestToken = $client->oauth( 71 | 'oauth/request_token', 72 | [ 73 | 'oauth_callback' => 74 | GraphqlAuthentication::getInstance()->getSettingsData($settings->twitterRedirectUrl), 75 | ] 76 | ); 77 | 78 | $oauthToken = $requestToken['oauth_token']; 79 | $oauthTokenSecret = $requestToken['oauth_token_secret']; 80 | 81 | $sessionService = Craft::$app->getSession(); 82 | $sessionService->set('oauthToken', $oauthToken); 83 | $sessionService->set('oauthTokenSecret', $oauthTokenSecret); 84 | 85 | $url = $client->url('oauth/authorize', ['oauth_token' => $oauthToken]); 86 | return $url; 87 | }, 88 | ]; 89 | } 90 | 91 | /** 92 | * Registers Login with Twitter mutations 93 | * 94 | * @param RegisterGqlMutationsEvent $event 95 | */ 96 | public function registerGqlMutations(RegisterGqlMutationsEvent $event) 97 | { 98 | if (!$this->_validateSettings()) { 99 | return; 100 | } 101 | 102 | switch (GraphqlAuthentication::$settings->permissionType) { 103 | case 'single': 104 | $event->mutations['twitterSignIn'] = [ 105 | 'description' => 'Authenticates a user using a Twitter Sign-In token. Returns user and token.', 106 | 'type' => Type::nonNull(Auth::getType()), 107 | 'args' => [ 108 | 'oauthToken' => Type::nonNull(Type::string()), 109 | 'oauthVerifier' => Type::nonNull(Type::string()), 110 | ], 111 | 'resolve' => function($source, array $arguments) { 112 | $settings = GraphqlAuthentication::$settings; 113 | $schemaId = GqlSchemaRecord::find()->select(['id'])->where(['name' => $settings->schemaName])->scalar(); 114 | 115 | if (!$schemaId) { 116 | GraphqlAuthentication::$errorService->throw($settings->invalidSchema); 117 | } 118 | 119 | $oauthToken = $arguments['oauthToken']; 120 | $oauthVerifier = $arguments['oauthVerifier']; 121 | $tokenUser = $this->_getUserFromToken($oauthToken, $oauthVerifier); 122 | 123 | $user = GraphqlAuthentication::$socialService->authenticate($tokenUser, $schemaId); 124 | return $user; 125 | }, 126 | ]; 127 | break; 128 | 129 | case 'multiple': 130 | $userGroupsService = Craft::$app->getUserGroups(); 131 | $userGroups = $userGroupsService->getAllGroups(); 132 | 133 | foreach ($userGroups as $userGroup) { 134 | $handle = ucfirst($userGroup->handle); 135 | 136 | $event->mutations["twitterSignIn{$handle}"] = [ 137 | 'description' => "Authenticates a {$userGroup->name} using a Twitter Sign-In token. Returns user and token.", 138 | 'type' => Type::nonNull(Auth::getType()), 139 | 'args' => [ 140 | 'oauthToken' => Type::nonNull(Type::string()), 141 | 'oauthVerifier' => Type::nonNull(Type::string()), 142 | ], 143 | 'resolve' => function($source, array $arguments) use ($userGroup) { 144 | $settings = GraphqlAuthentication::$settings; 145 | $schemaName = $settings->granularSchemas['group-' . $userGroup->id]['schemaName'] ?? null; 146 | $schemaId = GqlSchemaRecord::find()->select(['id'])->where(['name' => $schemaName])->scalar(); 147 | 148 | if (!$schemaId) { 149 | GraphqlAuthentication::$errorService->throw($settings->invalidSchema); 150 | } 151 | 152 | $oauthToken = $arguments['oauthToken']; 153 | $oauthVerifier = $arguments['oauthVerifier']; 154 | $tokenUser = $this->_getUserFromToken($oauthToken, $oauthVerifier); 155 | 156 | $user = GraphqlAuthentication::$socialService->authenticate($tokenUser, $schemaId, $userGroup->id); 157 | return $user; 158 | }, 159 | ]; 160 | } 161 | break; 162 | } 163 | } 164 | 165 | // Protected Methods 166 | // ========================================================================= 167 | 168 | /** 169 | * Ensures settings are set 170 | * 171 | * @return bool 172 | */ 173 | protected function _validateSettings(): bool 174 | { 175 | $settings = GraphqlAuthentication::$settings; 176 | return (bool) $settings->twitterApiKey && (bool) $settings->twitterApiKeySecret && (bool) $settings->twitterRedirectUrl; 177 | } 178 | 179 | /** 180 | * Gets user details from Login with Twitter token 181 | * 182 | * @param string $oauthToken 183 | * @param string $oauthVerifier 184 | * @return array 185 | * @throws Error 186 | */ 187 | protected function _getUserFromToken(string $oauthToken, string $oauthVerifier): array 188 | { 189 | $settings = GraphqlAuthentication::$settings; 190 | $errorService = GraphqlAuthentication::$errorService; 191 | 192 | $sessionService = Craft::$app->getSession(); 193 | $sessionOauthToken = $sessionService->get('oauthToken'); 194 | $sessionOauthTokenSecret = $sessionService->get('oauthTokenSecret'); 195 | 196 | if ($oauthToken !== $sessionOauthToken) { 197 | $errorService->throw($settings->invalidOauthToken); 198 | } 199 | 200 | $client = new TwitterOAuth( 201 | GraphqlAuthentication::getInstance()->getSettingsData($settings->twitterApiKey), 202 | GraphqlAuthentication::getInstance()->getSettingsData($settings->twitterApiKeySecret), 203 | $sessionOauthToken, 204 | $sessionOauthTokenSecret 205 | ); 206 | 207 | $accessToken = $client->oauth('oauth/access_token', ['oauth_verifier' => $oauthVerifier]); 208 | 209 | $client = new TwitterOAuth( 210 | GraphqlAuthentication::getInstance()->getSettingsData($settings->twitterApiKey), 211 | GraphqlAuthentication::getInstance()->getSettingsData($settings->twitterApiKeySecret), 212 | $accessToken['oauth_token'], 213 | $accessToken['oauth_token_secret'] 214 | ); 215 | 216 | $user = $client->get('account/verify_credentials', ['include_email' => true, 'entities' => false, 'skip_status' => true]); 217 | /** @phpstan-ignore-next-line */ 218 | $email = $user->email; 219 | 220 | if (!$email || !isset($email)) { 221 | $errorService->throw($settings->emailNotInScope); 222 | } 223 | 224 | if ($settings->allowedTwitterDomains) { 225 | GraphqlAuthentication::$socialService->verifyEmailDomain( 226 | $email, 227 | $settings->allowedTwitterDomains, 228 | $settings->twitterEmailMismatch 229 | ); 230 | } 231 | 232 | $fullName = $user->name ?? ''; 233 | 234 | $sessionService->remove('oauthToken'); 235 | $sessionService->remove('oauthTokenSecret'); 236 | 237 | return compact( 238 | 'email', 239 | 'fullName' 240 | ); 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /src/services/TwoFactorService.php: -------------------------------------------------------------------------------- 1 | getHeaderToken()) { 66 | return; 67 | } 68 | 69 | $settings = GraphqlAuthentication::$settings; 70 | 71 | if (!$settings->allowTwoFactorAuthentication) { 72 | return; 73 | } 74 | 75 | $tokenService = GraphqlAuthentication::$tokenService; 76 | 77 | $event->queries['twoFactorEnabled'] = [ 78 | 'description' => 'Checks if user has Two-Factor enabled. Returns boolean.', 79 | 'type' => Type::nonNull(Type::boolean()), 80 | 'args' => [], 81 | 'resolve' => function($source, array $arguments) use ($tokenService) { 82 | $user = $tokenService->getUserFromToken(); 83 | return $this->twoFactorEnabled($user); 84 | }, 85 | ]; 86 | } 87 | 88 | /** 89 | * Registers Two-Factor mutations 90 | * 91 | * @param RegisterGqlMutationsEvent $event 92 | */ 93 | public function registerGqlMutations(RegisterGqlMutationsEvent $event) 94 | { 95 | if (!GraphqlAuthentication::$tokenService->getHeaderToken()) { 96 | return; 97 | } 98 | 99 | $settings = GraphqlAuthentication::$settings; 100 | 101 | if (!$settings->allowTwoFactorAuthentication) { 102 | return; 103 | } 104 | 105 | $tokenService = GraphqlAuthentication::$tokenService; 106 | $errorService = GraphqlAuthentication::$errorService; 107 | 108 | $usersService = Craft::$app->getUsers(); 109 | $permissionsService = Craft::$app->getUserPermissions(); 110 | 111 | $event->mutations['generateTwoFactorQrCode'] = [ 112 | 'description' => 'Generates Two-Factor QR Code data URI. Returns string.', 113 | 'type' => Type::nonNull(Type::string()), 114 | 'args' => [], 115 | 'resolve' => function($source, array $arguments) use ($tokenService) { 116 | $user = $tokenService->getUserFromToken(); 117 | return $this->generateQrCode($user); 118 | }, 119 | ]; 120 | 121 | $event->mutations['generateTwoFactorSecretCode'] = [ 122 | 'description' => 'Generates Two-Factor secret code. Returns string.', 123 | 'type' => Type::nonNull(Type::string()), 124 | 'args' => [], 125 | 'resolve' => function($source, array $arguments) use ($tokenService) { 126 | $user = $tokenService->getUserFromToken(); 127 | return $this->secret($user); 128 | }, 129 | ]; 130 | 131 | $event->mutations['verifyTwoFactor'] = [ 132 | 'description' => 'Verifies Two-Factor code. Returns user and token.', 133 | 'type' => Type::nonNull(Auth::getType()), 134 | 'args' => [ 135 | 'email' => Type::nonNull(Type::string()), 136 | 'password' => Type::nonNull(Type::string()), 137 | 'code' => Type::nonNull(Type::string()), 138 | ], 139 | 'resolve' => function($source, array $arguments) use ($settings, $tokenService, $errorService, $usersService, $permissionsService) { 140 | $email = $arguments['email']; 141 | $password = $arguments['password']; 142 | $code = $arguments['code']; 143 | 144 | if (!$user = $usersService->getUserByUsernameOrEmail($email)) { 145 | $errorService->throw($settings->invalidLogin); 146 | } 147 | 148 | if (!$this->verify($code, $user)) { 149 | $errorService->throw($settings->invalidLogin); 150 | } 151 | 152 | if ($user->status !== 'active') { 153 | $errorService->throw($settings->userNotActivated); 154 | } 155 | 156 | $userPermissions = $permissionsService->getPermissionsByUserId($user->id); 157 | 158 | if (!in_array('accessCp', $userPermissions)) { 159 | $permissionsService->saveUserPermissions($user->id, array_merge($userPermissions, ['accessCp'])); 160 | } 161 | 162 | if (!$user->authenticate($password)) { 163 | $permissionsService->saveUserPermissions($user->id, $userPermissions); 164 | 165 | switch ($user->authError) { 166 | case User::AUTH_PASSWORD_RESET_REQUIRED: 167 | $usersService->sendPasswordResetEmail($user); 168 | $errorService->throw($settings->passwordResetRequired, true); 169 | 170 | // no break 171 | case User::AUTH_ACCOUNT_LOCKED: 172 | $errorService->throw($settings->accountLocked, true); 173 | 174 | // no break 175 | case User::AUTH_ACCOUNT_COOLDOWN: 176 | $errorService->throw($settings->accountCooldown, true); 177 | 178 | // no break 179 | default: 180 | $errorService->throw($settings->invalidLogin); 181 | } 182 | } 183 | 184 | $permissionsService->saveUserPermissions($user->id, $userPermissions); 185 | $schemaId = GqlSchemaRecord::find()->select(['id'])->where(['name' => $settings->schemaName])->scalar(); 186 | 187 | if ($settings->permissionType === 'multiple') { 188 | $userGroup = $user->getGroups()[0] ?? null; 189 | 190 | if ($userGroup) { 191 | $schemaName = $settings->granularSchemas['group-' . $userGroup->id]['schemaName'] ?? null; 192 | $schemaId = GqlSchemaRecord::find()->select(['id'])->where(['name' => $schemaName])->scalar(); 193 | } 194 | } 195 | 196 | if (!$schemaId) { 197 | $errorService->throw($settings->invalidSchema); 198 | } 199 | 200 | $usersService->handleValidLogin($user); 201 | $token = $tokenService->create($user, $schemaId); 202 | 203 | return GraphqlAuthentication::$userService->getResponseFields($user, $schemaId, $token); 204 | }, 205 | ]; 206 | 207 | $event->mutations['disableTwoFactor'] = [ 208 | 'description' => 'Disables Two-Factor. Returns boolean.', 209 | 'type' => Type::nonNull(Type::boolean()), 210 | 'args' => [ 211 | 'password' => Type::nonNull(Type::string()), 212 | 'confirmPassword' => Type::nonNull(Type::string()), 213 | ], 214 | 'resolve' => function($source, array $arguments) use ($settings, $tokenService, $errorService, $usersService, $permissionsService) { 215 | $user = $tokenService->getUserFromToken(); 216 | $user = $usersService->getUserByUsernameOrEmail($user->email); 217 | 218 | $password = $arguments['password']; 219 | $confirmPassword = $arguments['confirmPassword']; 220 | 221 | if ($password !== $confirmPassword) { 222 | $errorService->throw($settings->invalidPasswordMatch); 223 | } 224 | 225 | $userPermissions = $permissionsService->getPermissionsByUserId($user->id); 226 | 227 | if (!in_array('accessCp', $userPermissions)) { 228 | $permissionsService->saveUserPermissions($user->id, array_merge($userPermissions, ['accessCp'])); 229 | } 230 | 231 | if (!$user->authenticate($password)) { 232 | $permissionsService->saveUserPermissions($user->id, $userPermissions); 233 | $errorService->throw($settings->invalidLogin); 234 | } 235 | 236 | $this->disableTwoFactor($user); 237 | return true; 238 | }, 239 | ]; 240 | } 241 | 242 | /** 243 | * Methods adapted from /vendor/craftcms/cms/src/auth/methods/TOTP.php 244 | */ 245 | 246 | /** 247 | * Checks user's 2FA status. 248 | * 249 | * @param User $user 250 | */ 251 | private function twoFactorEnabled(User $user) { 252 | $secret = $this->secretFromDb($user); 253 | return (bool) $secret; 254 | } 255 | 256 | /** 257 | * Disables user's 2FA. 258 | * 259 | * @param User $user 260 | */ 261 | private function disableTwoFactor(User $user) { 262 | AuthenticatorRecord::deleteAll([ 263 | 'userId' => $user->id, 264 | ]); 265 | } 266 | 267 | /** 268 | * Returns user's 2FA secret from the database. 269 | * 270 | * @param User $user 271 | * @return string|null 272 | */ 273 | private function secretFromDb(User $user) { 274 | if (!isset($this->secretParam)) { 275 | $stateKeyPrefix = md5(sprintf('Craft.%s.%s.%s', Session::class, Craft::$app->id, $user->id)); 276 | $this->secretParam = sprintf('%s__secret', $stateKeyPrefix); 277 | } 278 | 279 | $record = AuthenticatorRecord::find() 280 | ->select(['auth2faSecret']) 281 | ->where(['userId' => $user->id]) 282 | ->one(); 283 | 284 | $secret = $record ? $record['auth2faSecret'] : null; 285 | return $secret; 286 | } 287 | 288 | /** 289 | * Returns user's 2FA secret from the database. 290 | * 291 | * @param User $user 292 | * @return string|null 293 | */ 294 | private function secret(User $user) { 295 | $google2fa = new Google2FA(); 296 | $secret = self::secretFromDb($user); 297 | 298 | if (empty($secret)) { 299 | try { 300 | $secret = $google2fa->generateSecretKey(32); 301 | Craft::$app->getSession()->set($this->secretParam, $secret); 302 | } catch (\Exception $e) { 303 | Craft::$app->getErrorHandler()->logException($e); 304 | } 305 | } 306 | 307 | return rtrim(chunk_split($secret, 4, ' ')); 308 | } 309 | 310 | /** 311 | * Generates and returns a QR code based on given 2fa secret. 312 | * 313 | * @param User $user 314 | * @return string 315 | */ 316 | private function generateQrCode(User $user) { 317 | $secret = $this->secret($user); 318 | 319 | $qrCodeUrl = (new Google2FA())->getQRCodeUrl( 320 | Craft::$app->getSystemName(), 321 | $user->email, 322 | $secret, 323 | ); 324 | 325 | $renderer = new ImageRenderer( 326 | new RendererStyle(150, 0), 327 | new SvgImageBackEnd() 328 | ); 329 | 330 | return (new Writer($renderer))->writeString($qrCodeUrl); 331 | } 332 | 333 | /** 334 | * Stores user's 2fa secret in the database. 335 | * 336 | * @param User $user 337 | * @param string $secret 338 | * @return void 339 | * @throws ForbiddenHttpException 340 | */ 341 | private function storeSecret(User $user, string $secret): void 342 | { 343 | /** @var AuthenticatorRecord|null $record */ 344 | $record = AuthenticatorRecord::find() 345 | ->where(['userId' => $user->id]) 346 | ->one(); 347 | 348 | if (!$record) { 349 | $record = new AuthenticatorRecord(); 350 | $record->userId = $user->id; 351 | } 352 | 353 | $record->auth2faSecret = $secret; 354 | // whenever we store the secret, we should ensure the oldTimestamp is accurate too 355 | $record->oldTimestamp = (new Google2FA())->getTimestamp(); 356 | $record->save(); 357 | } 358 | 359 | /** 360 | * Returns the totp's old timestamp. 361 | * 362 | * @param User $user 363 | * @return int|null 364 | */ 365 | private function lastUsedTimestamp(User $user): ?int 366 | { 367 | $record = AuthenticatorRecord::find() 368 | ->select(['oldTimestamp']) 369 | ->where(['userId' => $user->id]) 370 | ->one(); 371 | 372 | if (!$record) { 373 | return null; 374 | } 375 | 376 | // old timestamp is the current Unix Timestamp divided by the $keyRegeneration period 377 | // so we store it as int and don't mess with it 378 | return $record['oldTimestamp']; 379 | } 380 | 381 | /** 382 | * Saves totp's old timestamp. 383 | * 384 | * @param User $user 385 | * @param int $timestamp 386 | * @return void 387 | */ 388 | private function storeLastUsedTimestamp(User $user, int $timestamp): void 389 | { 390 | /** @var AuthenticatorRecord|null $record */ 391 | $record = AuthenticatorRecord::find() 392 | ->where(['userId' => $user->id]) 393 | ->one(); 394 | 395 | if (!$record) { 396 | // you shouldn't be able to get here without having a record, so let's throw an exception 397 | throw new Exception('Couldn\'t find authenticator record.'); 398 | } 399 | 400 | $record->oldTimestamp = $timestamp; 401 | $record->save(); 402 | } 403 | 404 | /** 405 | * Verifies user's 2FA code. 406 | * 407 | * @param string $code 408 | * @param User $user 409 | */ 410 | public function verify(string $code, User $user): bool 411 | { 412 | if (!$code) { 413 | return false; 414 | } 415 | 416 | $storedSecret = self::secretFromDb($user); 417 | $secret = $storedSecret ?? Craft::$app->getSession()->get($this->secretParam); 418 | 419 | if (!$secret) { 420 | return false; 421 | } 422 | 423 | $google2fa = new Google2FA(); 424 | try { 425 | $lastUsedTimestamp = $this->lastUsedTimestamp($user); 426 | $verified = $google2fa->verifyKeyNewer($secret, $code, $lastUsedTimestamp); 427 | } catch (Google2FAException) { 428 | return false; 429 | } 430 | 431 | if (!$verified) { 432 | return false; 433 | } 434 | 435 | if (!$storedSecret) { 436 | $this->storeSecret($user, $secret); 437 | Craft::$app->getSession()->remove($this->secretParam); 438 | } else { 439 | $this->storeLastUsedTimestamp($user, $verified === true ? $google2fa->getTimestamp() : $verified); 440 | } 441 | 442 | return true; 443 | } 444 | } 445 | -------------------------------------------------------------------------------- /src/templates/_sections/fields.twig: -------------------------------------------------------------------------------- 1 | {% import '_includes/forms' as forms %} 2 | 3 |

Fields

4 | 5 | {{ forms.field({ 6 | instructions: "Choose which fields are forbidden from being accessed through queries and mutations – per schema. Any attempts to access a restricted field will cause the request to throw.", 7 | first: true, 8 | }, '') }} 9 | 10 |
11 | 12 |
13 | 14 | 15 | 16 | 19 | 20 | {% for schema in schemaOptions %} 21 | 22 | {% endfor %} 23 | 24 | 25 | 26 | 27 | {% for field in fields %} 28 | {% set first = loop.first %} 29 | 30 | 31 | 37 | 38 | {% for schema in schemaOptions %} 39 | {% set schemaKey = 'schema-' ~ (schema.value != '' ? schema.value : '1') %} 40 | 41 | 52 | {% endfor %} 53 | 54 | {% endfor %} 55 | 56 |
17 | Field 18 | {{ schema.label == '-' ? 'Public' : schema.label }}
32 | {{ forms.field({ 33 | label: field.name, 34 | instructions: "(" ~ field.handle ~ ")", 35 | }, '') }} 36 | 42 | {{ forms.selectField({ 43 | name: 'fieldRestrictions[' ~ schemaKey ~ '][' ~ field.handle ~ ']', 44 | value: settings.fieldRestrictions[schemaKey][field.handle] ?? 'queryMutate', 45 | options: [ 46 | { value: 'queryMutate', label: 'Query & Mutate' }, 47 | { value: 'query', label: 'Query' }, 48 | { value: 'private', label: 'Private' }, 49 | ], 50 | }) }} 51 |
57 |
58 | -------------------------------------------------------------------------------- /src/templates/_sections/messages.twig: -------------------------------------------------------------------------------- 1 | {% import '_includes/forms' as forms %} 2 | 3 |

Response & Error Messages

4 | 5 | {{ forms.field({ 6 | instructions: "Here you can customize the response and error messages returned/thrown from queries and mutations.", 7 | first: true, 8 | }, '') }} 9 | 10 |
11 |

Responses

12 | 13 | {{ forms.textField({ 14 | label: 'Activation Email Sent', 15 | name: 'activationEmailSent', 16 | value: settings.activationEmailSent, 17 | required: true, 18 | }) }} 19 | 20 | {{ forms.textField({ 21 | label: 'User Not Activated', 22 | name: 'userNotActivated', 23 | value: settings.userNotActivated, 24 | required: true, 25 | }) }} 26 | 27 | {{ forms.textField({ 28 | label: 'User Activated', 29 | name: 'userActivated', 30 | value: settings.userActivated, 31 | required: true, 32 | }) }} 33 | 34 | {{ forms.textField({ 35 | label: 'User Has Password', 36 | name: 'userHasPassword', 37 | value: settings.userHasPassword, 38 | required: true, 39 | }) }} 40 | 41 | {{ forms.textField({ 42 | label: 'Magic Link Sent', 43 | name: 'magicLinkSent', 44 | value: settings.magicLinkSent, 45 | required: true, 46 | }) }} 47 | 48 | {{ forms.textField({ 49 | label: 'Password Saved', 50 | name: 'passwordSaved', 51 | value: settings.passwordSaved, 52 | required: true, 53 | }) }} 54 | 55 | {{ forms.textField({ 56 | label: 'Password Updated', 57 | name: 'passwordUpdated', 58 | value: settings.passwordUpdated, 59 | required: true, 60 | }) }} 61 | 62 | {{ forms.textField({ 63 | label: 'Password Reset Sent', 64 | name: 'passwordResetSent', 65 | value: settings.passwordResetSent, 66 | required: true, 67 | }) }} 68 | 69 | {{ forms.textField({ 70 | label: 'Password Reset Required', 71 | name: 'passwordResetRequired', 72 | value: settings.passwordResetRequired, 73 | required: true, 74 | }) }} 75 | 76 | {{ forms.textField({ 77 | label: 'Account Locked', 78 | name: 'accountLocked', 79 | value: settings.accountLocked, 80 | required: true, 81 | }) }} 82 | 83 | {{ forms.textField({ 84 | label: 'Account Cooldown', 85 | name: 'accountCooldown', 86 | value: settings.accountCooldown, 87 | required: true, 88 | }) }} 89 | 90 | {{ forms.textField({ 91 | label: 'Account Deleted', 92 | name: 'accountDeleted', 93 | value: settings.accountDeleted, 94 | required: true, 95 | }) }} 96 | 97 |
98 |

Invalid

99 | 100 | {{ forms.textField({ 101 | label: 'Invalid Login', 102 | name: 'invalidLogin', 103 | value: settings.invalidLogin, 104 | required: true, 105 | }) }} 106 | 107 | {{ forms.textField({ 108 | label: 'Invalid Password Match', 109 | name: 'invalidPasswordMatch', 110 | value: settings.invalidPasswordMatch, 111 | required: true, 112 | }) }} 113 | 114 | {{ forms.textField({ 115 | label: 'Invalid Password Update', 116 | name: 'invalidPasswordUpdate', 117 | value: settings.invalidPasswordUpdate, 118 | required: true, 119 | }) }} 120 | 121 | {{ forms.textField({ 122 | label: 'Invalid User Update', 123 | name: 'invalidUserUpdate', 124 | value: settings.invalidUserUpdate, 125 | required: true, 126 | }) }} 127 | 128 | {{ forms.textField({ 129 | label: 'Invalid Magic Code', 130 | name: 'invalidMagicCode', 131 | value: settings.invalidMagicCode, 132 | required: true, 133 | }) }} 134 | 135 |
136 |

Forbidden

137 | 138 | {{ forms.textField({ 139 | label: 'Forbidden Mutation', 140 | name: 'forbiddenMutation', 141 | value: settings.forbiddenMutation, 142 | required: true, 143 | }) }} 144 | 145 | {{ forms.textField({ 146 | label: 'Forbidden Field', 147 | name: 'forbiddenField', 148 | value: settings.forbiddenField, 149 | required: true, 150 | }) }} 151 | 152 |
153 |

Google

154 | 155 | {{ forms.textField({ 156 | label: 'Google Email Mismatch', 157 | name: 'googleEmailMismatch', 158 | value: settings.googleEmailMismatch, 159 | required: true, 160 | }) }} 161 | 162 |
163 |

Facebook

164 | 165 | {{ forms.textField({ 166 | label: 'Facebook Email Mismatch', 167 | name: 'facebookEmailMismatch', 168 | value: settings.facebookEmailMismatch, 169 | required: true, 170 | }) }} 171 | 172 |
173 |

Twitter

174 | 175 | {{ forms.textField({ 176 | label: 'Twitter Email Mismatch', 177 | name: 'twitterEmailMismatch', 178 | value: settings.twitterEmailMismatch, 179 | required: true, 180 | }) }} 181 | 182 |
183 |

Microsoft

184 | 185 | {{ forms.textField({ 186 | label: 'Microsoft Email Mismatch', 187 | name: 'microsoftEmailMismatch', 188 | value: settings.microsoftEmailMismatch, 189 | required: true, 190 | }) }} 191 | -------------------------------------------------------------------------------- /src/templates/_sections/social.twig: -------------------------------------------------------------------------------- 1 | {% import '_includes/forms' as forms %} 2 | 3 |

Social Settings

4 | 5 | {{ forms.field({ 6 | instructions: "Separate mutations for each service will be available once you've filled in the appropriate details.", 7 | first: true, 8 | }, '') }} 9 | 10 |
11 | 12 | {{ forms.field({ 13 | label: 'Skip Activation', 14 | instructions: "Whether or not users will be automatically activated when registering via social mutations.", 15 | name: 'skipSocialActivation', 16 | }, forms.lightswitch({ 17 | name: 'skipSocialActivation', 18 | on: settings.skipSocialActivation, 19 | })) }} 20 | 21 |
22 |

Google

23 | 24 | {{ forms.field({ 25 | instructions: 'Use the `googleSignIn` mutation after filling in the `Client ID` field.', 26 | first: true, 27 | }, '') }} 28 | 29 | {{ forms.autosuggestField({ 30 | label: 'Client ID', 31 | instructions: 'The Client ID from your Google OAuth project.', 32 | name: 'googleClientId', 33 | value: settings.googleClientId, 34 | placeholder: 'xxxx-xxxx.apps.googleusercontent.com', 35 | required: false, 36 | suggestEnvVars: true, 37 | }) }} 38 | 39 | {{ forms.textField({ 40 | label: 'Allowed Email Domains', 41 | instructions: "The domains from which you'd like to allow Google Sign-In. Comma-delimited. Leave empty to allow any domain.", 42 | name: 'allowedGoogleDomains', 43 | value: settings.allowedGoogleDomains, 44 | placeholder: 'domain1.com, domain2.co.uk', 45 | required: false, 46 | }) }} 47 | 48 |
49 |

Facebook

50 | 51 | {{ forms.field({ 52 | instructions: 'Use the `facebookOauthUrl` query to get the authorization URL, then use the `facebookSignIn` mutation to authenticate a user, after filling in the all fields below.', 53 | first: true, 54 | }, '') }} 55 | 56 | {{ forms.autosuggestField({ 57 | label: 'App ID', 58 | instructions: 'The App ID from your Facebook app.', 59 | name: 'facebookAppId', 60 | value: settings.facebookAppId, 61 | placeholder: 'xxxxxxxxxxxxxxxx', 62 | required: false, 63 | suggestEnvVars: true, 64 | }) }} 65 | 66 | {{ forms.autosuggestField({ 67 | label: 'App Secret', 68 | instructions: 'The App Secret from your Facebook app.', 69 | name: 'facebookAppSecret', 70 | value: settings.facebookAppSecret, 71 | placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 72 | required: false, 73 | suggestEnvVars: true, 74 | }) }} 75 | 76 | {{ forms.autosuggestField({ 77 | label: 'Redirect URL', 78 | instructions: 'The URL to redirect to after authenticating.', 79 | name: 'facebookRedirectUrl', 80 | value: settings.facebookRedirectUrl, 81 | placeholder: 'https://yoursite.com/redirect', 82 | required: false, 83 | suggestEnvVars: true, 84 | }) }} 85 | 86 | {{ forms.textField({ 87 | label: 'Allowed Email Domains', 88 | instructions: "The domains from which you'd like to allow Facebook Login. Comma-delimited. Leave empty to allow any domain.", 89 | name: 'allowedFacebookDomains', 90 | value: settings.allowedFacebookDomains, 91 | placeholder: 'domain1.com, domain2.co.uk', 92 | required: false, 93 | }) }} 94 | 95 |
96 |

Twitter

97 | 98 | {{ forms.field({ 99 | instructions: 'Use the `twitterOauthUrl` query to get the authorization URL, then use the `twitterSignIn` mutation to authenticate a user, after filling in the all fields below.', 100 | first: true, 101 | }, '') }} 102 | 103 | {{ forms.autosuggestField({ 104 | label: 'API Key', 105 | instructions: 'The API Key from your Twitter app.', 106 | name: 'twitterApiKey', 107 | value: settings.twitterApiKey, 108 | placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxx', 109 | required: false, 110 | suggestEnvVars: true, 111 | }) }} 112 | 113 | {{ forms.autosuggestField({ 114 | label: 'API Key Secret', 115 | instructions: 'The API Key Secret from your Twitter app.', 116 | name: 'twitterApiKeySecret', 117 | value: settings.twitterApiKeySecret, 118 | placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 119 | required: false, 120 | suggestEnvVars: true, 121 | }) }} 122 | 123 | {{ forms.autosuggestField({ 124 | label: 'Redirect URL', 125 | instructions: 'The URL to redirect to after authenticating.', 126 | name: 'twitterRedirectUrl', 127 | value: settings.twitterRedirectUrl, 128 | placeholder: 'https://yoursite.com/redirect', 129 | required: false, 130 | suggestEnvVars: true, 131 | }) }} 132 | 133 | {{ forms.textField({ 134 | label: 'Allowed Email Domains', 135 | instructions: "The domains from which you'd like to allow Log in with Twitter. Comma-delimited. Leave empty to allow any domain.", 136 | name: 'allowedTwitterDomains', 137 | value: settings.allowedTwitterDomains, 138 | placeholder: 'domain1.com, domain2.co.uk', 139 | required: false, 140 | }) }} 141 | 142 |
143 |

Apple

144 | 145 |

iOS

146 | 147 | {{ forms.autosuggestField({ 148 | label: 'App Client ID', 149 | instructions: 'The App Client ID from your Apple app.', 150 | name: 'appleClientId', 151 | value: settings.appleClientId, 152 | placeholder: 'com.yourapp.id', 153 | required: false, 154 | suggestEnvVars: true, 155 | }) }} 156 | 157 | {{ forms.autosuggestField({ 158 | label: 'App Client Secret', 159 | instructions: 'The App Client Secret from your Apple app.', 160 | name: 'appleClientSecret', 161 | value: settings.appleClientSecret, 162 | placeholder: 'xxxxxxxxxx', 163 | required: false, 164 | suggestEnvVars: true, 165 | }) }} 166 | 167 |
168 |

Web

169 | 170 | {{ forms.field({ 171 | instructions: 'Use the `appleOauthUrl` query to get the authorization URL, then use the `appleSignIn` mutation to authenticate a user, after filling in the all fields below.', 172 | first: true, 173 | }, '') }} 174 | 175 | {{ forms.autosuggestField({ 176 | label: 'Service ID', 177 | instructions: 'The Service ID from your Apple app.', 178 | name: 'appleServiceId', 179 | value: settings.appleServiceId, 180 | placeholder: 'com.yourapp.id.web', 181 | required: false, 182 | suggestEnvVars: true, 183 | }) }} 184 | 185 | {{ forms.autosuggestField({ 186 | label: 'Service Client Secret', 187 | instructions: 'The Service Client Secret from your Apple app.', 188 | name: 'appleServiceSecret', 189 | value: settings.appleServiceSecret, 190 | placeholder: 'xxxxxxxxxx', 191 | required: false, 192 | suggestEnvVars: true, 193 | }) }} 194 | 195 | {{ forms.autosuggestField({ 196 | label: 'Redirect URL', 197 | instructions: 'The URL to redirect to after authenticating.', 198 | name: 'appleRedirectUrl', 199 | value: settings.appleRedirectUrl, 200 | placeholder: 'https://yoursite.com/redirect', 201 | required: false, 202 | }) }} 203 | 204 |
205 |

Microsoft

206 | 207 | {{ forms.field({ 208 | instructions: 'Use the `microsoftOauthUrl` query to get the authorization URL, then use the `microsoftSignIn` mutation to authenticate a user, after filling in the all fields below.', 209 | first: true, 210 | }, '') }} 211 | 212 | {{ forms.autosuggestField({ 213 | label: 'App ID', 214 | instructions: 'The App ID from your Microsoft app.', 215 | name: 'microsoftAppId', 216 | value: settings.microsoftAppId, 217 | placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxx', 218 | required: false, 219 | suggestEnvVars: true, 220 | }) }} 221 | 222 | {{ forms.autosuggestField({ 223 | label: 'App Secret', 224 | instructions: 'The App Secret from your Microsoft app.', 225 | name: 'microsoftAppSecret', 226 | value: settings.microsoftAppSecret, 227 | placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 228 | required: false, 229 | suggestEnvVars: true, 230 | }) }} 231 | 232 | {{ forms.autosuggestField({ 233 | label: 'Redirect URL', 234 | instructions: 'The URL to redirect to after authenticating.', 235 | name: 'microsoftRedirectUrl', 236 | value: settings.microsoftRedirectUrl, 237 | placeholder: 'https://yoursite.com/redirect', 238 | required: false, 239 | suggestEnvVars: true, 240 | }) }} 241 | 242 | {{ forms.textField({ 243 | label: 'Allowed Email Domains', 244 | instructions: "The domains from which you'd like to allow Log in with Microsoft. Comma-delimited. Leave empty to allow any domain.", 245 | name: 'allowedMicrosoftDomains', 246 | value: settings.allowedMicrosoftDomains, 247 | placeholder: 'domain1.com, domain2.co.uk', 248 | required: false, 249 | }) }} 250 | -------------------------------------------------------------------------------- /src/templates/_sections/tokens.twig: -------------------------------------------------------------------------------- 1 | {% import '_includes/forms' as forms %} 2 | 3 |

Token Settings

4 | 5 | {{ forms.selectField({ 6 | label: 'JWT Expiration', 7 | instructions: 'The length of time before JWTs expire.', 8 | name: 'jwtExpiration', 9 | value: settings.jwtExpiration, 10 | options: [ 11 | { value: '15 minutes', label: '15 minutes' }, 12 | { value: '30 minutes', label: '30 minutes' }, 13 | { value: '1 hour', label: '1 hour' }, 14 | { value: '1 day', label: '1 day' }, 15 | { value: '1 week', label: '1 week' }, 16 | ], 17 | }) }} 18 | 19 | {{ forms.selectField({ 20 | label: 'Refresh Token Expiration', 21 | instructions: 'The length of time before refresh tokens expire.', 22 | name: 'jwtRefreshExpiration', 23 | value: settings.jwtRefreshExpiration, 24 | options: [ 25 | { value: '1 week', label: '1 week' }, 26 | { value: '1 month', label: '1 month' }, 27 | { value: '3 months', label: '3 months' }, 28 | { value: '6 months', label: '6 months' }, 29 | { value: '1 year', label: '1 year' }, 30 | ], 31 | }) }} 32 | 33 | {{ forms.autosuggestField({ 34 | label: 'JWT Secret Key', 35 | instructions: 'The secret key used to sign JWTs (this is randomly generated, but feel free to use your own).', 36 | name: 'jwtSecretKey', 37 | value: settings.jwtSecretKey, 38 | suggestEnvVars: true, 39 | }) }} 40 | 41 | {{ forms.selectField({ 42 | label: 'SameSite Cookie Policy', 43 | instructions: 'The `SameSite` policy to use on cookies.', 44 | name: 'sameSitePolicy', 45 | value: settings.sameSitePolicy, 46 | options: [ 47 | { value: 'strict', label: 'Strict' }, 48 | { value: 'lax', label: 'Lax' }, 49 | { value: 'none', label: 'None' }, 50 | ], 51 | }) }} 52 | -------------------------------------------------------------------------------- /src/templates/_sections/users.twig: -------------------------------------------------------------------------------- 1 | {% import '_includes/forms' as forms %} 2 | 3 |

User Settings

4 | 5 | {{ forms.selectField({ 6 | label: 'Permission Type', 7 | instructions: "Choose whether to have one schema for all users, or a schema per user group.", 8 | name: 'permissionType', 9 | id: 'permissionType', 10 | value: settings.permissionType, 11 | options: [ 12 | { label: 'Single Schema', value: 'single' }, 13 | { label: 'Multiple Schemas', value: 'multiple' }, 14 | ], 15 | required: true, 16 | }) }} 17 | 18 | {{ forms.field({ 19 | label: 'Allow Magic Authentication', 20 | instructions: "Whether or not the Magic Code mutations should be available.", 21 | name: 'allowMagicAuthentication', 22 | }, forms.lightswitch({ 23 | name: 'allowMagicAuthentication', 24 | on: settings.allowMagicAuthentication, 25 | })) }} 26 | 27 | {% if craft.app.plugins.isPluginEnabled('two-factor-authentication') %} 28 | {{ forms.field({ 29 | label: 'Allow Two-Factor Authentication', 30 | instructions: "Whether or not the Two-Factor mutations should be available.", 31 | name: 'allowTwoFactorAuthentication', 32 | }, forms.lightswitch({ 33 | name: 'allowTwoFactorAuthentication', 34 | on: settings.allowTwoFactorAuthentication, 35 | })) }} 36 | {% endif %} 37 | 38 | {{ forms.field({ 39 | label: 'Allow Passwordless Account Deletion', 40 | instructions: "When enabled, the plugin will no longer validate a user's password when using the `deleteAccount` mutation.", 41 | name: 'allowPasswordlessDelete', 42 | }, forms.lightswitch({ 43 | name: 'allowPasswordlessDelete', 44 | on: settings.allowPasswordlessDelete, 45 | })) }} 46 | 47 | {{ forms.field({ 48 | label: 'Skip Activated Check', 49 | instructions: "When enabled, the plugin will no longer validate a user's activation status on incoming requests.", 50 | name: 'skipActivatedCheck', 51 | }, forms.lightswitch({ 52 | name: 'skipActivatedCheck', 53 | on: settings.skipActivatedCheck, 54 | })) }} 55 | 56 | {% if settings.permissionType == 'single' %} 57 |
58 | 59 | {% set schemaInput = schemaOptions ? forms.selectField({ 60 | name: 'schemaName', 61 | value: settings.schemaName, 62 | options: schemaOptions, 63 | }) : tag('p', { 64 | class: ['warning', 'with-icon'], 65 | text: 'No schemas exist yet.'|t('app'), 66 | }) 67 | %} 68 | 69 | {{ forms.field({ 70 | label: 'GraphQL Schema', 71 | instructions: 'The schema that JWTs will be assigned to through the `authenticate` and `register` mutations.', 72 | name: 'schemaName', 73 | required: true, 74 | }, schemaInput) }} 75 | 76 | {{ forms.field({ 77 | label: 'Allow Registration', 78 | instructions: "Whether or not the `register` mutation should be available.", 79 | name: 'allowRegistration', 80 | }, forms.lightswitch({ 81 | name: 'allowRegistration', 82 | on: settings.allowRegistration, 83 | })) }} 84 | 85 | {{ forms.selectField({ 86 | label: 'User Group', 87 | instructions: 'The user group that users will be assigned to when created through the `register` mutation.', 88 | name: 'userGroup', 89 | value: settings.userGroup, 90 | options: userOptions, 91 | }) }} 92 | 93 | 94 | {% if siteOptions|length > 2 %} 95 | {{ forms.selectField({ 96 | label: 'Site', 97 | instructions: 'The site users will be restricted to querying/mutating.', 98 | name: 'siteId', 99 | value: settings.siteId, 100 | options: siteOptions, 101 | }) }} 102 | {% endif %} 103 | 104 | {% if not settings.schemaName %} 105 | {{ forms.field({ 106 | label: 'User Permissions', 107 | instructions: 'Select your desired schema and save to modify user permissions.', 108 | }, '') }} 109 | {% else %} 110 | {% if entryQueries %} 111 |
112 |
113 | 114 | 115 |
116 |

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

117 |
118 |
119 | 120 |
121 | {% for query in entryQueries %} 122 |
123 | {{ forms.checkbox({ 124 | label: query.label, 125 | name: 'entryQueries[' ~ query.handle ~ ']', 126 | value: true, 127 | checked: settings.entryQueries[query.handle] ?? false, 128 | }) }} 129 |
130 | {% endfor %} 131 |
132 |
133 | {% endif %} 134 | 135 | {% if entryMutations %} 136 |
137 |
138 | 139 | 140 |
141 |

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

142 |
143 |
144 | 145 |
146 | {% for mutation in entryMutations %} 147 |
148 | {{ forms.checkbox({ 149 | label: mutation.label, 150 | name: 'entryMutations[' ~ mutation.handle ~ ']', 151 | value: true, 152 | checked: settings.entryMutations[mutation.handle] ?? false, 153 | }) }} 154 |
155 | {% endfor %} 156 |
157 |
158 | {% endif %} 159 | 160 | {% if assetQueries %} 161 |
162 |
163 | 164 | 165 |
166 |

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

167 |
168 |
169 | 170 |
171 | {% for query in assetQueries %} 172 |
173 | {{ forms.checkbox({ 174 | label: query.label, 175 | name: 'assetQueries[' ~ query.handle ~ ']', 176 | value: true, 177 | checked: settings.assetQueries[query.handle] ?? false, 178 | }) }} 179 |
180 | {% endfor %} 181 |
182 |
183 | {% endif %} 184 | 185 | {% if assetMutations %} 186 |
187 |
188 | 189 | 190 |
191 |

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

192 |
193 |
194 | 195 |
196 | {% for mutation in assetMutations %} 197 |
198 | {{ forms.checkbox({ 199 | label: mutation.label, 200 | name: 'assetMutations[' ~ mutation.handle ~ ']', 201 | value: true, 202 | checked: settings.assetMutations[mutation.handle] ?? false, 203 | }) }} 204 |
205 | {% endfor %} 206 |
207 |
208 | {% endif %} 209 | {% endif %} 210 | {% else %} 211 | {% for userGroup in userOptions %} 212 | {% if userGroup.label != '-' %} 213 |
214 |

{{ userGroup.label }}

215 | 216 | {% set groupKey = 'group-' ~ userGroup.value %} 217 | 218 | {% set schemaInput = schemaOptions ? forms.selectField({ 219 | name: 'granularSchemas[' ~ groupKey ~ '][schemaName]', 220 | value: settings.granularSchemas[groupKey].schemaName ?? null, 221 | options: schemaOptions, 222 | }) : tag('p', { 223 | class: ['warning', 'with-icon'], 224 | text: 'No schemas exist yet.'|t('app'), 225 | }) 226 | %} 227 | 228 | {{ forms.field({ 229 | label: 'GraphQL Schema', 230 | instructions: 'The schema that JWTs will be assigned to through the `authenticate` and `register` mutations.', 231 | name: 'schemaName', 232 | }, schemaInput) }} 233 | 234 | {{ forms.field({ 235 | label: 'Allow Registration', 236 | instructions: "Whether or not the `register` mutation should be available.", 237 | name: 'granularSchemas[' ~ groupKey ~ '][allowRegistration]', 238 | }, forms.lightswitch({ 239 | name: 'granularSchemas[' ~ groupKey ~ '][allowRegistration]', 240 | on: settings.granularSchemas[groupKey].allowRegistration ?? null, 241 | })) }} 242 | 243 | {% if siteOptions|length > 2 %} 244 | {{ forms.selectField({ 245 | label: 'Site', 246 | instructions: 'The site that this user group will be restricted to querying/mutating.', 247 | name: 'granularSchemas[' ~ groupKey ~ '][siteId]', 248 | value: settings.granularSchemas[groupKey].siteId ?? null, 249 | options: siteOptions, 250 | }) }} 251 | {% endif %} 252 | 253 | {% if not settings.granularSchemas[groupKey].schemaName ?? null %} 254 | {{ forms.field({ 255 | label: 'User Permissions', 256 | instructions: 'Select your desired schema and save to modify user permissions.', 257 | }, '') }} 258 | {% else %} 259 | {% if entryQueries[groupKey] is defined %} 260 |
261 |
262 | 263 | 264 |
265 |

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

266 |
267 |
268 | 269 |
270 | {% for query in entryQueries[groupKey] %} 271 |
272 | {{ forms.checkbox({ 273 | label: query.label, 274 | name: 'granularSchemas[' ~ groupKey ~ '][entryQueries][' ~ query.handle ~ ']', 275 | value: true, 276 | checked: settings.granularSchemas[groupKey].entryQueries[query.handle] ?? null, 277 | }) }} 278 |
279 | {% endfor %} 280 |
281 |
282 | {% endif %} 283 | 284 | {% if entryMutations[groupKey] is defined %} 285 |
286 |
287 | 288 | 289 |
290 |

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

291 |
292 |
293 | 294 |
295 | {% for mutation in entryMutations[groupKey] %} 296 |
297 | {{ forms.checkbox({ 298 | label: mutation.label, 299 | name: 'granularSchemas[' ~ groupKey ~ '][entryMutations][' ~ mutation.handle ~ ']', 300 | value: true, 301 | checked: settings.granularSchemas[groupKey].entryMutations[mutation.handle] ?? null, 302 | }) }} 303 |
304 | {% endfor %} 305 |
306 |
307 | {% endif %} 308 | 309 | {% if assetQueries[groupKey] is defined %} 310 |
311 |
312 | 313 | 314 |
315 |

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

316 |
317 |
318 | 319 |
320 | {% for query in assetQueries[groupKey] %} 321 |
322 | {{ forms.checkbox({ 323 | label: query.label, 324 | name: 'granularSchemas[' ~ groupKey ~ '][assetQueries][' ~ query.handle ~ ']', 325 | value: true, 326 | checked: settings.granularSchemas[groupKey].assetQueries[query.handle] ?? null, 327 | }) }} 328 |
329 | {% endfor %} 330 |
331 |
332 | {% endif %} 333 | 334 | {% if assetMutations[groupKey] is defined %} 335 |
336 |
337 | 338 | 339 |
340 |

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

341 |
342 |
343 | 344 |
345 | {% for mutation in assetMutations[groupKey] %} 346 |
347 | {{ forms.checkbox({ 348 | label: mutation.label, 349 | name: 'granularSchemas[' ~ groupKey ~ '][assetMutations][' ~ mutation.handle ~ ']', 350 | value: true, 351 | checked: settings.granularSchemas[groupKey].assetMutations[mutation.handle] ?? null, 352 | }) }} 353 |
354 | {% endfor %} 355 |
356 |
357 | {% endif %} 358 | {% endif %} 359 | {% endif %} 360 | {% endfor %} 361 | {% endif %} 362 | 363 | 368 | -------------------------------------------------------------------------------- /src/templates/magic-codes.twig: -------------------------------------------------------------------------------- 1 | {% extends '_layouts/elementindex' %} 2 | {% set title = 'JWT Magic Codes'|t('app') %} 3 | {% set elementType = 'jamesedmonston\\graphqlauthentication\\elements\\MagicCode' %} 4 | -------------------------------------------------------------------------------- /src/templates/refresh-tokens.twig: -------------------------------------------------------------------------------- 1 | {% extends '_layouts/elementindex' %} 2 | {% set title = 'JWT Refresh Tokens'|t('app') %} 3 | {% set elementType = 'jamesedmonston\\graphqlauthentication\\elements\\RefreshToken' %} 4 | -------------------------------------------------------------------------------- /src/templates/settings.twig: -------------------------------------------------------------------------------- 1 | {% extends '_layouts/cp' %} 2 | {% set title = 'GraphQL Authentication' %} 3 | 4 | {% block content %} 5 | 6 | 7 | 8 | {% namespace namespace %} 9 |
10 | {% include 'graphql-authentication/_sections/users' %} 11 |
12 | 13 | 16 | 17 | 20 | 21 | 24 | 25 | 28 | {% endnamespace %} 29 | {% endblock %} 30 | --------------------------------------------------------------------------------