diff --git a/app/plugins/OrcidSource/config/routes.php b/app/plugins/OrcidSource/config/routes.php index 322f5105d..0fe23ea2c 100644 --- a/app/plugins/OrcidSource/config/routes.php +++ b/app/plugins/OrcidSource/config/routes.php @@ -25,20 +25,34 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -use Cake\Routing\Route\DashedRoute; +declare(strict_types=1); -$routes->plugin( - 'OrcidSource', - ['path' => '/orcid-source/'], - function ($routes) { - $routes->setRouteClass(DashedRoute::class); +use Cake\Http\Middleware\BodyParserMiddleware; +use Cake\Routing\RouteBuilder; - $routes->get( - 'orcid-sources/callback', - [ - 'plugin' => 'OrcidSource', - 'controller' => 'OrcidSources', - 'action' => 'callback', - ]); - } -); \ No newline at end of file + +$routes->scope('/api/orcidsource', function (RouteBuilder $builder) { + // Register scoped middleware for in scopes. +// Do not enable CSRF for the REST API, it will break standard (non-AJAX) clients +// $builder->registerMiddleware('csrf', new CsrfProtectionMiddleware(['httponly' => true])); + // BodyParserMiddleware will automatically parse JSON bodies, but we only + // want that for API transactions, so we only apply it to the /api scope. + $builder->registerMiddleware('bodyparser', new BodyParserMiddleware()); + /* + * Apply a middleware to the current route scope. + * Requires middleware to be registered through `Application::routes()` with `registerMiddleware()` + */ +// Do not enable CSRF for the REST API, it will break standard (non-AJAX) clients +// $builder->applyMiddleware('csrf'); + $builder->setExtensions(['json']); + $builder->applyMiddleware('bodyparser'); + $builder->get( + '/v2/token/{orcid}/co/{coId}', + ['plugin' => 'OrcidSource', 'controller' => 'ApiV2', 'action' => 'get'] + ) + ->setPass(['orcid', 'coId']) + ->setPatterns([ + 'orcid' => '([0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{4})', + 'coId' => '[0-9]+', + ]); +}); \ No newline at end of file diff --git a/app/plugins/OrcidSource/src/Controller/ApiV2Controller.php b/app/plugins/OrcidSource/src/Controller/ApiV2Controller.php new file mode 100644 index 000000000..4d99c4b3c --- /dev/null +++ b/app/plugins/OrcidSource/src/Controller/ApiV2Controller.php @@ -0,0 +1,213 @@ +request->getParam('coId') ?? null; + } + + + public function initialize(): void { + parent::initialize(); + $this->OrcidSources = TableRegistry::getTableLocator()->get('OrcidSource.OrcidSources'); + $this->OrcidTokens = TableRegistry::getTableLocator()->get('OrcidSource.OrcidTokens'); + } + + /** + * Calculate authorization for the current request. + * + * @since COmanage Registry v5.2.0 + * @return bool True if the current request is permitted, false otherwise + */ + + public function calculatePermission(): bool { + $authUser = $this->RegistryAuth->getAuthenticatedUser(); + $coid = $this->calculateRequestedCOID(); + + return $authUser !== null + && $this->RegistryAuth->isApiUser() + && ($this->RegistryAuth->isPlatformAdmin() || $this->RegistryAuth->isCoAdmin($coid)); + } + + /** + * Handle a Get SOR Person Role request. + * + * @param string $orcid + * @param int $coId + * @since COmanage Registry v5.2.0 + */ + + public function get(string $orcid, int $coId) { + try { + $orcidSourcesRecords = $this->OrcidSources + ->find() + ->contain([ + 'Servers.Oauth2Servers' => function ($q) { + return $q->where(["LOWER(Oauth2Servers.url) LIKE" => '%orcid%']); + }, + 'ExternalIdentitySources', + ]) + ->innerJoinWith('Servers.Oauth2Servers', function ($q) { + return $q->where([ + "LOWER(Oauth2Servers.url) LIKE" => '%orcid%' + ]); + }) + ->innerJoinWith('ExternalIdentitySources') + ->where([ + 'Servers.plugin' => 'CoreServer.Oauth2Servers', + 'ExternalIdentitySources.co_id' => $coId, + 'ExternalIdentitySources.plugin' => 'OrcidSource.OrcidSources' + ]) + ->disableHydration() + ->all() + ->toArray(); + + // Extract OrcidSource IDs + $orcid_source_ids = Hash::extract($orcidSourcesRecords, '{n}.id'); + + // Find token records from the database + $tokens = $this->OrcidTokens->find() + ->where([ + 'OrcidTokens.orcid_identifier' => $orcid, + 'OrcidTokens.orcid_source_id IN' => $orcid_source_ids + ]) + ->disableHydration() + ->all() + ->toArray(); + + $columnsToDecrypt = [ + 'access_token', + 'id_token', + 'refresh_token' + ]; + + if (count($tokens) === 0) { + throw new RecordNotFoundException(__d('orcid_source', 'error.param.notfound', [__d('orcid_source', 'information.orcid_source.identifier')])); + } + + foreach ($tokens as $idx => $token) { + $orcidSourceIndex = array_search($token['orcid_source_id'], $orcid_source_ids); + $tokens[$idx]['scopes'] = $this->getOauth2ServerScopes( + $orcidSourcesRecords[$orcidSourceIndex]['server'], + $orcidSourcesRecords[$orcidSourceIndex] + ); + foreach ($columnsToDecrypt as $column) { + $value = $token[$column] ?? null; + $tokens[$idx][$column] = !empty($value) ? $this->OrcidTokens->getUnencrypted($value) : ''; + } + } + + // Return data in structured format + $this->set('orcid_tokens', $tokens); + $this->set('vv_model_name', 'OrcidTokens'); + $this->set('vv_table_name', 'orcid_tokens'); + } + catch(RecordNotFoundException $e) { + // Return 404 + $this->response = $this->response->withStatus( + HttpStatusCodesEnum::HTTP_NOT_FOUND, + $e->getMessage() + ); + $this->autoRender = false; + return; + } + catch(\Exception $e) { + // Return 400 + $this->response = $this->response->withStatus( + HttpStatusCodesEnum::HTTP_BAD_REQUEST, + $e->getMessage() + ); + $this->autoRender = false; + return; + } + + // Let the view render + $this->render('/Standard/api/v2/json/index'); + } + + /** + * Indicate whether this Controller will handle some or all authnz. + * + * @since COmanage Registry v5.2.0 + * @param EventInterface $event Cake event, ie: from beforeFilter + * @return string "no", "open", "authz", or "yes" + */ + + public function willHandleAuth(\Cake\Event\EventInterface $event): string { + // We always take over authz + return 'authz'; + } + + /** + * Get the scopes + * + * @param array $server Server Record + * @param array $orcidSource OrcidSource record + * + * @return string List of scopes + * @since COmanage Registry v5.2.0 + */ + + public function getOauth2ServerScopes(array $server, array $orcidSource): string + { + if(is_bool($orcidSource['scope_inherit']) && $orcidSource['scope_inherit']) { + $Oauth2ServersTable = TableRegistry::getTableLocator()->get('Oauth2Servers'); + $oauth2Server = $Oauth2ServersTable->find() + ->select(['scope']) + ->where(['server_id' => $server['id']]) + ->first(); + + if ($oauth2Server && !empty($oauth2Server->scope)) { + return $oauth2Server->scope; + } + } + + return OrcidSourceScopeEnum::DEFAULT_SCOPE; + } +} diff --git a/app/plugins/OrcidSource/src/Controller/OrcidTokensController.php b/app/plugins/OrcidSource/src/Controller/OrcidTokensController.php index 7942b5288..ab60d37f1 100644 --- a/app/plugins/OrcidSource/src/Controller/OrcidTokensController.php +++ b/app/plugins/OrcidSource/src/Controller/OrcidTokensController.php @@ -30,159 +30,7 @@ namespace OrcidSource\Controller; use App\Controller\StandardController; -use Cake\Event\EventInterface; -use Cake\ORM\TableRegistry; -use Cake\Utility\Hash; -use OrcidSource\Lib\Enum\OrcidSourceScopeEnum; -use \App\Lib\Enum\HttpStatusCodesEnum; class OrcidTokensController extends StandardController { - /** @var OrcidSource */ - protected $orcidSources; - - /** - * Callback run prior to the request action. - * - * @since COmanage Registry v5.2.0 - * @param EventInterface $event Cake Event - * @return \Cake\Http\Response HTTP Response - */ - - public function beforeFilter(\Cake\Event\EventInterface $event) - { - // $this->name = Models - $modelsName = $this->name; - - $coid = $this->request->getQuery('co_id'); - if (empty($coid)) { - $this->response = $this->response->withStatus( - HttpStatusCodesEnum::HTTP_BAD_REQUEST, - __d('orcid_source', 'error.param.notfound', [__d('controller', 'Cos')]) - ); - $this->response->send(); - $this->getEventManager()->off($event->getName()); // Prevent further event firing - $this->autoRender = false; - return; - } - - $orcid = $this->request->getQuery('orcid'); - if (empty($orcid)) { - $this->response = $this->response->withStatus( - HttpStatusCodesEnum::HTTP_BAD_REQUEST, - __d('orcid_source', 'error.param.notfound', [__d('orcid_source', 'information.orcid_source.identifier')]) - ); - $this->response->send(); - $this->getEventManager()->off($event->getName()); // Prevent further event firing - $this->autoRender = false; - return; - } - - $this->orcidSources = $this->OrcidSources - ->find() - ->contain([]) // No related records loaded - ->innerJoinWith('Oauth2Servers', function ($q) { - return $q->where([ - "LOWER(Oauth2Servers.url) LIKE" => '%orcid%' - ]); - }) - ->where([ - 'Servers.plugin' => 'CoreServer.Oauth2Servers', - 'ExternalIdentitySources.co_id' => $coid - ]) - ->disableHydration() - ->toArray(); - - return parent::beforeFilter(); - } - - - /** - * Retrieve ORCID tokens for a given ORCID identifier - * - * @return void - * @throws \Cake\Http\Exception\MethodNotAllowedException If request method is not allowed - * @since COmanage Registry v5.2.0 - */ - public function token() - { - // Allow only AJAX and GET requests - $this->request->allowMethod(['ajax', 'get']); - - // Set AJAX layout - $this->viewBuilder()->setLayout('ajax'); - - // Extract OrcidSource IDs - $orcid_source_ids = Hash::extract($this->orcidSources, '{n}.OrcidSource.id'); - - // Get ORCID identifier from query string - $orcid = $this->request->getQuery('orcid'); - - // Find token records from the database - $tokens = $this->OrcidTokens->find() - ->where([ - 'OrcidTokens.orcid_identifier' => $orcid, - 'OrcidTokens.orcid_source_id IN' => $orcid_source_ids - ]) - ->all(); - - $columnsToDecrypt = [ - 'access_token', - 'id_token', - 'refresh_token' - ]; - - $data = []; - if (!$tokens->isEmpty()) { - foreach ($tokens as $idx => $token) { - $data[$idx] = []; - $data[$idx]['orcid'] = $token->orcid_identifier; - $orcidSourceIndex = array_search($token->orcid_source_id, $orcid_source_ids); - $data[$idx]['scopes'] = $this->getOauth2ServerScopes( - $this->orcidSources[$orcidSourceIndex]['Server'], - $this->orcidSources[$orcidSourceIndex]['OrcidSource'] - ); - foreach ($columnsToDecrypt as $column) { - $value = $token->{$column} ?? null; - $data[$idx][$column] = !empty($value) ? $this->OrcidTokens->getUnencrypted($value) : ''; - } - } - } - - // Return data in structured format - $this->set('orcid_tokens', $data); - $this->set('vv_model_name', 'OrcidTokens'); - $this->set('vv_table_name', 'orcid_tokens'); - - // Let the view render - $this->render('/Standard/api/v2/json/index'); - } - - - /** - * Get the scopes - * - * @param array $server Server Record - * @param array $orcidSource OrcidSource record - * - * @return string List of scopes - * @since COmanage Registry v5.2.0 - */ - - public function getOauth2ServerScopes(array $server, array $orcidSource): string - { - if(is_bool($orcidSource['scope_inherit']) && $orcidSource['scope_inherit']) { - $Oauth2ServersTable = TableRegistry::getTableLocator()->get('Oauth2Servers'); - $oauth2Server = $Oauth2ServersTable->find() - ->select(['scope']) - ->where(['server_id' => $server['id']]) - ->first(); - - if ($oauth2Server) { - return $oauth2Server->scope; - } - } - - return OrcidSourceScopeEnum::DEFAULT_SCOPE; - } } diff --git a/app/plugins/OrcidSource/src/Model/Table/OrcidSourcesTable.php b/app/plugins/OrcidSource/src/Model/Table/OrcidSourcesTable.php index 7630cd94b..a72fa7a08 100644 --- a/app/plugins/OrcidSource/src/Model/Table/OrcidSourcesTable.php +++ b/app/plugins/OrcidSource/src/Model/Table/OrcidSourcesTable.php @@ -366,6 +366,14 @@ public function search( return []; } + // Turn the query string into an associative array + $queryString = $searchAttrs['q']; + if (!str_starts_with($searchAttrs['q'], 'q=')) { + $queryString = 'q=' . $searchAttrs['q']; + } + parse_str($queryString, $queryParts); + $searchAttrs = $queryParts; + // We just let search exceptions pop up the stack $this->httpClient = $this->orcidConnect($source); @@ -431,6 +439,17 @@ public function orcidRequest(string $urlPath, array $data=[], string $action="ge ] ]; + + // We do not need a token for public api and + if($this->orcidSource->api_type == OrcidSourceApiEnum::PUBLIC + && ( + $urlPath == '/v3.0/search/' + || preg_match('#v3\.0/([0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{4})/person#', $urlPath, $matches) + )) { + // No authentication is required for the public tier. Limited to 1000 requests per day. + unset($options['headers']['Authorization']); + } + $orcidUrlBase = $this->orcidUrl($this->orcidSource->api_type, $this->orcidSource->api_tier); $fullUrl = $orcidUrlBase . $urlPath; $response = $this->httpClient->$action( @@ -524,7 +543,7 @@ protected function orcidConnect( } // Since this is null, we will use the master access token stored in Oauth2Server Configuration - if ($orcidIdentifier !== null) { + if ($this->orcidSource->api_type !== OrcidSourceApiEnum::PUBLIC) { $this->orcidToken = $this->orcidTokensTable ->find() ->where([