From 83f84738bf0c5db5885888a7e48986b15a4f2680 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 27 Apr 2025 12:23:25 +0300 Subject: [PATCH] Oauth2Server MVC Renane callback uri field to redirect uri Fetch access token --- .../resources/locales/en_US/core_server.po | 51 ++++ .../Controller/Oauth2ServersController.php | 184 ++++++++++++ .../src/Lib/Enum/Oauth2GrandTypesEnum.php | 39 +++ .../src/Model/Entity/Oauth2Server.php | 49 +++ .../src/Model/Table/Oauth2ServersTable.php | 278 ++++++++++++++++++ app/plugins/CoreServer/src/config/plugin.json | 20 +- .../templates/Oauth2Servers/fields.inc | 97 ++++++ app/src/Lib/Traits/ValidationTrait.php | 25 +- 8 files changed, 739 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..6fdd6cca9 100644 --- a/app/plugins/CoreServer/resources/locales/en_US/core_server.po +++ b/app/plugins/CoreServer/resources/locales/en_US/core_server.po @@ -34,6 +34,9 @@ msgstr "{0,plural,=1{SMTP Server} other{SMTP Servers}}" msgid "controller.SqlServers" msgstr "{0,plural,=1{SQL Server} other{SQL Servers}}" +msgid "controller.Oauth2Servers" +msgstr "{0,plural,=1{Oauth2 Server} other{Oauth2 Servers}}" + msgid "enumeration.HttpAuthTypeEnum.BA" msgstr "Basic" @@ -43,6 +46,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 +70,21 @@ 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 "info.Oauth2Servers.token.ok" +msgstr "Access Token Obtained" + +msgid "info.Oauth2Servers.token.obtain" +msgstr "Obtain New Token" + msgid "field.auth_type" msgstr "Authentication Type" @@ -70,6 +94,33 @@ 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.redirect_uri" +msgstr "Redirect URI" + +msgid "field.Oauth2Servers.scope" +msgstr "Scopes" + +msgid "info.Oauth2Servers.access_token.ok" +msgstr "Access Token Obtained" + 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..e5ab958f5 --- /dev/null +++ b/app/plugins/CoreServer/src/Controller/Oauth2ServersController.php @@ -0,0 +1,184 @@ + [ + 'OauthServers.url' => 'asc' + ] + ]; + + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @since COmanage Registry v5.2.0 + */ + + public function beforeRender(EventInterface $event) + { + // Generate the Redirect URI + + if ($this->getRequest()->getParam('action') === 'edit') { + $id = $this->getRequest()->getParam('pass')[0] ?? null; // Assuming $id comes from passed arguments + $this->set('vv_redirect_uri', $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): void + { + $this->autoRender = false; + try { + $request = $this->getRequest(); + $code = $request->getQuery('code'); + $state = $request->getQuery('state'); + + if (empty($code) || empty($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 + + // Access session from the request object + $sessionId = $request->getSession()->id(); + + if ($state != hash('sha256', $sessionId)) { + throw new \RuntimeException(__d('core_server', 'error.Oauth2Servers.state')); + } + + $response = $this->Oauth2Servers->exchangeCode( + $id, + $code, + $this->Oauth2Servers->redirectUri((int)$id), + ); + + $this->Flash->success(__d('core_server', 'info.Oauth2Servers.access_token.ok')); + } catch (\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + $this->performRedirect(); + } + + /** + * Obtain an access token for a Oauth2Server. + * + * @since COmanage Registry v5.2.0 + * @param integer $id Oauth2Server ID + */ + + public function token($id): void + { + // Pull our configuration, initially to find out what type of grant type we need + $osrvr = $this->Oauth2Servers->get($id); + + if(!$osrvr) { + $this->Flash->error(__d('error', 'notfound', [__d('core_server', 'controller.Oauth2Servers')])); + $this->performRedirect(); + } + + try { + switch($osrvr->access_grant_type) { + case Oauth2GrandTypesEnum::AuthorizationCode: + // Issue a redirect to the server + $targetUrl = $osrvr->url + . '/authorize?response_type=code' + . '&client_id=' . $osrvr->clientid + . '&redirect_uri=' . urlencode($this->Oauth2Servers->redirectUri($id)) + . '&state=' . hash('sha256', session_id()); + // Scope is optional + if(!empty($osrvr->scope)) { + $targetUrl .= '&scope='. str_replace(' ', '%20', $osrvr->scope); + } + + $this->redirect($targetUrl); + break; + case Oauth2GrandTypesEnum::ClientCredentials: + // Make a direct call to the server + $this->Oauth2Servers->obtainToken((int)$id, 'client_credentials'); + $this->Flash->success(__d('core_server', 'info.Oauth2Servers.access_token.ok')); + break; + default: + // No other flows currently supported + throw new \LogicException('Not implemented yet.'); + } + } catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + $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..a82e51111 --- /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..d21953283 --- /dev/null +++ b/app/plugins/CoreServer/src/Model/Table/Oauth2ServersTable.php @@ -0,0 +1,278 @@ +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->setAllowLookupPrimaryLink(['token', 'callback']); + + $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'], + 'token' => ['platformAdmin', 'coAdmin'], + 'callback' => true, + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'], + ] + ]); + } + + /** + * Exchange an authorization code for an access and refresh token. + * + * @param int|string $id Oauth2Server ID + * @param string $code Access code returned by call to /oauth/authorize + * @param string $redirectUri Callback URL used for initial request + * @return mixed Object of data as returned by server, including access and refresh token + * @throws RuntimeException + *@since COmanage Registry v5.2.0 + */ + + public function exchangeCode(int|string $id, string $code, string $redirectUri, $store=true): mixed + { + return $this->obtainToken((int)$id, 'authorization_code', $code, $redirectUri, $store); + } + + /** + * Obtain an OAuth token. + * + * @param Integer $id Oauth2Server ID + * @param String $grantType OAuth grant type + * @param String|null $code Access code returned by call to /oauth/authorize, for authorization_code grant + * @param String|null $redirectUri Callback URL used for initial request, for authorization_code grant + * @param Boolean $store If true, store the retrieved tokens in the Oauth2Server configuration + * @return mixed Object of data as returned by server, including access and refresh token + * @throws RuntimeException + *@since COmanage Registry v5.2.0 + */ + + public function obtainToken(int $id, string $grantType, string $code=null, string $redirectUri=null, bool $store=true): mixed + { + // Pull our configuration + $srvr = $this->get($id); + + $httpClient = $this->createHttpClient($id); + + $postData = [ + 'client_id' => $srvr->clientid, + 'client_secret' => $srvr->client_secret, + 'grant_type' => $grantType + ]; + + if($grantType == 'refresh_token') { + $postData['refresh_token'] = $srvr->refresh_token; + $postData['format'] = 'json'; + } elseif($grantType == 'authorization_code' && $code) { + $postData['code'] = $code; + $postData['redirect_uri'] = $redirectUri; + } else { + $postData['scope'] = str_replace(' ', '%20', $srvr->scope); + } + + $postUrl = $srvr->url . "/token"; + + $results = $httpClient->post($postUrl, $postData); + + $json = json_decode($results->getStringBody()); + + if($results->getStatusCode() != 200) { + // There should be an error in the response + throw new \RuntimeException(__d('core_server', 'error.Oauth2Servers.token', [$json->error . ": " . $json->error_description])); + } + + if($store) { + // Save the fields we want to keep + $data = [ + 'id' => $id, + 'access_token' => $json->access_token, + // Store the raw result in case the server has added some custom attributes + 'token_response' => json_encode($json) + ]; + + // We shouldn't have a new refresh token on a refresh_token grant + // (which just gets us a new access token). Additionally, section + // 4.4.3 of RFC 6749 explains that the server should NOT return + // a refresh token for a client credentials grant. + if($grantType != 'refresh_token' && property_exists($json, 'refresh_token')) { + $data['refresh_token'] = $json->refresh_token; + } + + // If the Oauth2 server returned `expires_in` use it to set the + // access token expiration time. See section 5.1 of RFC 6749. + if(property_exists($json, 'expires_in')) { + $data['access_token_exp'] = time() + $json->expires_in; + } + + // Update the dataset + $srvr = $this->patchEntity($srvr, $data); + if (!$this->save($srvr)) { + throw new \RuntimeException(__d('error', 'save' [__d('core_server', 'field.Oauth2Servers.access_token')])); + } + } + + return $json; + } + + + /** + * 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(int|string $id): string + { + $callback = [ + 'plugin' => 'CoreServer', + 'controller' => 'Oauth2Servers', + 'action' => 'callback', + $id + ]; + + return Router::url($callback, true); + } + + + /** + * Refresh the OAuth access token using the stored refresh token. + * + * @param int|string $id The unique identifier of the OAuth2 server + * @return string The new access token + * @throws RuntimeException + * @since COmanage Registry v5.2.0 + */ + public function refreshToken(int|string $id):string + { + $json = $this->obtainToken((int)$id, 'refresh_token'); + + return $json->access_token; + } + + + /** + * 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->integer('access_token_exp') + ->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..580d27591 --- /dev/null +++ b/app/plugins/CoreServer/templates/Oauth2Servers/fields.inc @@ -0,0 +1,97 @@ +element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'redirect_uri', + 'fieldOptions' => [ + 'readOnly' => true, + 'default' => $vv_redirect_uri + ] + ]]); + +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', + ], + ]]); + +$generateLink = []; +if(!empty($vv_obj->id)) { + $generateLink = [ + 'url' => [ + 'plugin' => 'CoreServer', + 'controller' => 'Oauth2Servers', + 'action' => 'token', + $vv_obj->id + ], + 'label' => __d('core_server', 'info.Oauth2Servers.token.obtain'), + 'class' => 'provisionbutton nospin btn btn-primary btn-sm', + ]; +} + +print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'access_token', + 'status' => !empty($vv_obj->access_token) ? __d('enumeration', 'SetBooleanEnum.1') : __d('enumeration', 'SetBooleanEnum.0'), + 'link' => $generateLink, + 'labelIsTextOnly' => true + ] +]); \ 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. *