From 8d9f49ded2e16a2bcb0d8f9ef6b53b6180438fd7 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 27 Apr 2025 12:23:25 +0300 Subject: [PATCH] Oauth2Server MVC --- .../resources/locales/en_US/core_server.po | 39 +++++ .../Controller/Oauth2ServersController.php | 126 +++++++++++++ .../src/Lib/Enum/Oauth2GrandTypesEnum.php | 39 +++++ .../src/Model/Entity/Oauth2Server.php | 49 ++++++ .../src/Model/Table/Oauth2ServersTable.php | 165 ++++++++++++++++++ app/plugins/CoreServer/src/config/plugin.json | 20 ++- .../templates/Oauth2Servers/fields.inc | 80 +++++++++ app/src/Lib/Traits/ValidationTrait.php | 25 ++- 8 files changed, 539 insertions(+), 4 deletions(-) create mode 100644 app/plugins/CoreServer/src/Controller/Oauth2ServersController.php create mode 100644 app/plugins/CoreServer/src/Lib/Enum/Oauth2GrandTypesEnum.php create mode 100644 app/plugins/CoreServer/src/Model/Entity/Oauth2Server.php create mode 100644 app/plugins/CoreServer/src/Model/Table/Oauth2ServersTable.php create mode 100644 app/plugins/CoreServer/templates/Oauth2Servers/fields.inc diff --git a/app/plugins/CoreServer/resources/locales/en_US/core_server.po b/app/plugins/CoreServer/resources/locales/en_US/core_server.po index f16da9a10..c3452a102 100644 --- a/app/plugins/CoreServer/resources/locales/en_US/core_server.po +++ b/app/plugins/CoreServer/resources/locales/en_US/core_server.po @@ -43,6 +43,12 @@ msgstr "Bearer" msgid "enumeration.HttpAuthTypeEnum.X" msgstr "None" +msgid "enumeration.Oauth2GrandTypesEnum.AC" +msgstr "Authorization Code" + +msgid "enumeration.Oauth2GrandTypesEnum.CC" +msgstr "Client Credentials" + msgid "enumeration.RdbmsTypeEnum.LT" msgstr "SQLite" @@ -61,6 +67,15 @@ msgstr "Oracle" msgid "enumeration.RdbmsTypeEnum.PG" msgstr "Postgres" +msgid "error.Oauth2Servers.callback" +msgstr "Incorrect parameters in callback" + +msgid "error.Oauth2Servers.state" +msgstr "Invalid state received in callback" + +msgid "error.Oauth2Servers.token" +msgstr "Error obtaining access token: {0}" + msgid "field.auth_type" msgstr "Authentication Type" @@ -70,6 +85,30 @@ msgstr "Skip SSL Verification" msgid "field.MatchServers.api_endpoint" msgstr "API Endpoint" +msgid "field.Oauth2Servers.access_token" +msgstr "Access Token" + +msgid "field.Oauth2Servers.access_token.desc" +msgstr "Save any changes to the configuration before obtaining a new token" + +msgid "field.Oauth2Servers.clientid" +msgstr "Client ID" + +msgid "field.Oauth2Servers.client_secret" +msgstr "Client Secret" + +msgid "field.Oauth2Servers.access_grant_type" +msgstr "Access Token Grant Type" + +msgid "field.Oauth2Servers.url" +msgstr "Server URL" + +msgid "field.Oauth2Servers.callback_endpoint" +msgstr "Callback URI" + +msgid "field.Oauth2Servers.scope" +msgstr "Scopes" + msgid "field.SmtpServers.default_from" msgstr "Default From Address" diff --git a/app/plugins/CoreServer/src/Controller/Oauth2ServersController.php b/app/plugins/CoreServer/src/Controller/Oauth2ServersController.php new file mode 100644 index 000000000..ad79b2886 --- /dev/null +++ b/app/plugins/CoreServer/src/Controller/Oauth2ServersController.php @@ -0,0 +1,126 @@ + [ + 'OauthServers.url' => 'asc' + ] + ]; + + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @return Response|void + * @since COmanage Registry v5.2.0 + */ + + public function beforeRender(EventInterface $event) + { + // Generate the callback URL + + if ($this->getRequest()->getParam('action') === 'edit') { + $id = $this->getRequest()->getParam('pass')[0] ?? null; // Assuming $id comes from passed arguments + $this->set('vv_callback_endpoint', $this->Oauth2Servers->redirectUri($id)); + } + + return parent::beforeRender($event); + } + + /** + * OAuth callback. + * + * @param integer $id Oauth2Server ID + * @since COmanage Registry v5.2.0 + */ + + public function callback($id) + { + // We have to look in $_GET because what we get back isn't a Cake style named parameter + // (ie: code=foo, not code:foo) + + try { + if (empty($_GET['code']) || empty($_GET['state'])) { + throw new RuntimeException(__d('core_server', 'error.Oauth2Servers.callback')); + } + + // Verify that state is our hashed session ID, as per RFC6749 ยง10.12 + // recommendations to prevent CSRF. + // https://tools.ietf.org/html/rfc6749#section-10.12 + + if ($_GET['state'] != hash('sha256', session_id())) { + throw new RuntimeException(__d('core_server', 'error.Oauth2Servers.state')); + } + + $response = $this->Oauth2Servers->exchangeCode($id, $_GET['code'], $this->Oauth2Server->redirectUri($id)); + + $this->Flash->set(_txt('rs.server.oauth2.token.ok'), array('key' => 'success')); + } catch (Exception $e) { + $this->Flash->set($e->getMessage(), array('key' => 'error')); + } + + $this->performRedirect(); + } + + /** + * Perform a redirect back to the controller's default view. + * + * @since COmanage Registry v5.2.0 + */ + + function performRedirect(): void + { + $target = []; + $target['plugin'] = null; + + if (!empty($this->getRequest()->getParam('pass')[0])) { + $target['plugin'] = 'CoreServer'; + $target['controller'] = 'Oauth2Servers'; + $target['action'] = 'edit'; + $target[] = filter_var($this->getRequest()->getParam('pass')[0], FILTER_SANITIZE_SPECIAL_CHARS); + } else { + $target['controller'] = 'Servers'; + $target['action'] = 'index'; + $target['?'] = [ + 'co_id' => $this->getCOID() + ]; + + $this->redirect($target); + } + } +} diff --git a/app/plugins/CoreServer/src/Lib/Enum/Oauth2GrandTypesEnum.php b/app/plugins/CoreServer/src/Lib/Enum/Oauth2GrandTypesEnum.php new file mode 100644 index 000000000..a8bd939b2 --- /dev/null +++ b/app/plugins/CoreServer/src/Lib/Enum/Oauth2GrandTypesEnum.php @@ -0,0 +1,39 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreServer/src/Model/Table/Oauth2ServersTable.php b/app/plugins/CoreServer/src/Model/Table/Oauth2ServersTable.php new file mode 100644 index 000000000..a86ea0db2 --- /dev/null +++ b/app/plugins/CoreServer/src/Model/Table/Oauth2ServersTable.php @@ -0,0 +1,165 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + // Timestamp behavior handles created/modified updates + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Servers'); + + $this->setDisplayField('hostname'); + + $this->setPrimaryLink('server_id'); + $this->setRequiresCO(true); + + $this->setAutoViewVars([ + 'types' => [ + 'type' => 'enum', + 'class' => 'CoreServer.Oauth2GrandTypesEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + + /** + * Generate a redirect URI for the given server ID. + * + * @param int|string $id The unique identifier of the OAuth2 server + * @return string The full URL of the redirect URI + */ + public function redirectUri($id): string + { + $callback = [ + 'plugin' => 'CoreServer', + 'controller' => 'Oauth2Servers', + 'action' => 'callback', + $id + ]; + + return Router::url($callback, true); + } + + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('server_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('server_id'); + + $validator->add('access_grant_type', [ + 'content' => ['rule' => ['inList', Oauth2GrandTypesEnum::getConstValues()]] + ]); + $validator->notEmptyString('access_grant_type'); + + $validator->add('url', ['content' => ['rule' => 'url']]); + $validator->notEmptyString('url'); + + $validator->notEmptyString('clientid'); + $validator->notEmptyString('client_secret'); + + $validator->add('scope', [ + 'content' => [ + 'rule' => 'validateNotBlank', + 'provider' => 'table' + ] + ]); + $validator->allowEmptyString('scope'); + + $validator->add('refresh_token', [ + 'content' => [ + 'rule' => 'validateNotBlank', + 'provider' => 'table' + ] + ]); + $validator->allowEmptyString('refresh_token'); + + $validator->add('access_token', [ + 'content' => [ + 'rule' => 'validateNotBlank', + 'provider' => 'table' + ] + ]); + $validator->allowEmptyString('access_token'); + + $validator->add('access_token_exp', [ + 'content' => [ + 'rule' => 'validateNotBlank', + 'provider' => 'table' + ] + ]); + $validator->allowEmptyString('access_token_exp'); + + return $validator; + } +} diff --git a/app/plugins/CoreServer/src/config/plugin.json b/app/plugins/CoreServer/src/config/plugin.json index a1901134c..b9687eaf7 100644 --- a/app/plugins/CoreServer/src/config/plugin.json +++ b/app/plugins/CoreServer/src/config/plugin.json @@ -4,7 +4,8 @@ "HttpServers", "MatchServers", "SmtpServers", - "SqlServers" + "SqlServers", + "Oauth2Servers" ] }, "schema": { @@ -37,6 +38,23 @@ "match_servers_i1": { "columns": [ "server_id" ] } } }, + "oauth2_servers": { + "columns": { + "id": {}, + "server_id": { "type": "integer" }, + "url": { "type": "url" }, + "clientid": { "type": "string", "size": 120 }, + "client_secret": { "type": "string", "size": 80 }, + "access_grant_type": { "type": "string", "size": 2 }, + "scope": { "type": "string", "size": 256 }, + "refresh_token": { "type": "text" }, + "access_token": { "type": "text" }, + "access_token_exp": { "type": "boolean" } + }, + "indexes": { + "oauth2_servers_i1": { "columns": [ "server_id" ] } + } + }, "smtp_servers": { "columns": { "id": {}, diff --git a/app/plugins/CoreServer/templates/Oauth2Servers/fields.inc b/app/plugins/CoreServer/templates/Oauth2Servers/fields.inc new file mode 100644 index 000000000..ee6928f81 --- /dev/null +++ b/app/plugins/CoreServer/templates/Oauth2Servers/fields.inc @@ -0,0 +1,80 @@ +element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'callback_endpoint', + 'fieldOptions' => [ + 'readOnly' => true, + 'default' => $vv_callback_endpoint + ] + ]]); + +print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'url' + ]]); + +print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'access_grant_type', + 'fieldSelectOptions' => $types, + 'fieldType' => 'select', + 'fieldOptions' => [ + 'empty' => false, + ], + ]]); + +foreach ([ + "clientid", + "client_secret", + ] as $field) { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => $field + ]]); +} + +print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'scope', + 'fieldOptions' => [ + 'placeholder' => '/authenticate', + ], + ]]); + +print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'access_token' + ]]); \ No newline at end of file diff --git a/app/src/Lib/Traits/ValidationTrait.php b/app/src/Lib/Traits/ValidationTrait.php index 63e4992cc..0658af1a3 100644 --- a/app/src/Lib/Traits/ValidationTrait.php +++ b/app/src/Lib/Traits/ValidationTrait.php @@ -267,8 +267,9 @@ public function validateInput($value, array $context) { } // We require at least one non-whitespace character (CO-1551) - if(!preg_match('/\S/', $value)) { - return __d('error', 'input.blank'); + $notBlankValidation = $this->validateNotBlank($value, $context); + if ($notBlankValidation !== true) { + return $notBlankValidation; } } @@ -303,7 +304,25 @@ public function validateMaxLength(string $value, array $context): bool|string { return true; } - + + + /** + * Validate that the given value is not blank. + * + * @since COmanage Registry v5.2.0 + * @param mixed $value Value to validate + * @param array $context Validation context + * @return mixed True if $value validates, or an error string otherwise + */ + public function validateNotBlank(mixed $value, array $context): mixed + { + $regex = '/\S+/m'; + if (is_scalar($value) && preg_match($regex, $value)) { + return true; + } + return __d('error', 'input.blank'); + } + /** * Determine if a string submitted from a form is valid SQL identifier. *