diff --git a/app/plugins/OrcidSource/resources/locales/en_US/orcid_source.po b/app/plugins/OrcidSource/resources/locales/en_US/orcid_source.po index e8b00685c..cf2fab0d3 100644 --- a/app/plugins/OrcidSource/resources/locales/en_US/orcid_source.po +++ b/app/plugins/OrcidSource/resources/locales/en_US/orcid_source.po @@ -69,3 +69,6 @@ msgstr "API Tier" msgid "information.orcid_source.linked" msgstr "Obtained ORCID {0} via authenticated OAuth flow" + +msgid "information.orcid_source.identifier" +msgstr "ORCID Identifier" diff --git a/app/plugins/OrcidSource/src/Controller/OrcidSourceCollectorsController.php b/app/plugins/OrcidSource/src/Controller/OrcidSourceCollectorsController.php new file mode 100644 index 000000000..b824e9e31 --- /dev/null +++ b/app/plugins/OrcidSource/src/Controller/OrcidSourceCollectorsController.php @@ -0,0 +1,216 @@ + [ + 'OrcidSourceCollectors.id' => '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) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->OrcidSourceCollectors->EnrollmentFlowSteps->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->OrcidSourceCollectors->EnrollmentFlowSteps->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->OrcidSourceCollectors->EnrollmentFlowSteps->getPrimaryKey()); + } + + return parent::beforeRender($event); + } + + /** + * Dispatch an Enrollment Flow Step. + * + * @since COmanage Registry v5.2.0 + * @param string $id Env Source Collector ID + */ + + public function dispatch(string $id) { + $request = $this->getRequest(); + $session = $request->getSession(); + $username = $session->read('Auth.external.user'); + + $op = $this->requestParam('op'); + $code = $this->getRequest()->getQuery('code') ?? null; + $petition = $this->getPetition(); + + $this->set('vv_op', $op); + + $oricdSource = $this->OrcidSourceCollectors->get( + (int)$id, + [ + 'contain' => [ + 'ExternalIdentitySources' => ['OrcidSources' => ['Servers']] + ]] + ); + + $ServerModel = $oricdSource->external_identity_source->orcid_source->server->plugin; + $PluginServers = $this->getTableLocator()->get($ServerModel); + + $oauthServer = $this->Oauth2Servers->get($oricdSource->external_identity_source->orcid_source->server->id); + + $this->set('vv_config', $oricdSource); + $this->set('controller', $this); + + + try { + // Let's authenticate first + $this->authenticate($id, $oricdSource, $code); + + } + catch(\OverflowException $e) { + // The requested Source Key is already attached to an External Identity, so we throw + // an error now rather than wait until finalization + + // Flag the Petition as a duplicate + $Petitions = TableRegistry::getTableLocator()->get("Petitions"); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + // Fall through and let the form render + + $this->render('/Standard/dispatch'); + } + + + /** + * Authenticate using OAuth2 Authorization Code flow. + * + * This method handles the OAuth2 authentication process by: + * 1. Building callback URL for the OAuth2 redirect + * 2. Redirecting to authorization endpoint on first access + * 3. Exchanging authorization code for access token on callback + * + * @param string|int $id Orcid Source Collector ID + * @param EntityInterface $oscfg Configuration array containing OAuth2 settings and OrgIdentitySource details + * @param string|null $code Authorization code returned from OAuth2 server (optional) + * @return void + * @since COmanage Registry v5.2.0 + */ + protected function authenticate(string|int $id, EntityInterface $oscfg, ?string $code): void + { + // We need a different callback URL than what the Oauth2Server config will + // use, since we're basically creating a runtime Authorization Code flow + // (while the main config uses a Client Credentials flow). + + $callback = [ + 'plugin' => 'OrcidSource', + 'controller' => 'OrcidSourcesCollectors', + 'action' => 'dispatch', + $id, + '?' => ['osid' => $oscfg->id], + ]; + + // Build the redirect URI + $redirectUri = Router::url($callback, true); + + if (empty($code)) { + $scope = OrcidSourceScopeEnum::DEFAULT_SCOPE; + if (!empty($oscfg->scope_inherit)) { + $scope = $oscfg->scope_inherit; + } + + + // First time through, redirect to the "authorize" URL + + $url = $oscfg->url . '/authorize?'; + $url .= 'client_id=' . $oscfg->clientid; + $url .= '&response_type=code'; + $url .= '&scope=' . str_replace(' ', '%20', $scope); + $url .= '&redirect_uri=' . urlencode($redirectUri); + + $this->redirect($url); + } + +// $response = $this->Oauth2Server->exchangeCode($cfg['Oauth2Server']['id'], +// $this->request->query['code'], +// $redirectUri, +// false); + } + + /** + * 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 { + $request = $this->getRequest(); + $action = $request->getParam('action'); + + if($action == 'dispatch') { + // We need to perform special logic (vs StandardEnrollerController) + // to ensure that web server authentication is triggered. + // (This is the same logic as IdentifierCollectorsController.) +// XXX We could maybe move this into StandardEnrollerController with a flag like +// $this->alwaysAuthDispatch(true); + + // To start, we trigger the parent logic. This will return + // notauth: Some error occurred, we don't want to override this + // authz: No token in use + // yes: Token validated + + $auth = parent::willHandleAuth($event); + + // The only status we need to override is 'yes', since we always want authentication + // to run in order to be able to grab $REMOTE_USER. + + return ($auth == 'yes' ? 'authz' : $auth); + } + + return parent::willHandleAuth($event); + } +} diff --git a/app/plugins/OrcidSource/src/Controller/OrcidSourcesController.php b/app/plugins/OrcidSource/src/Controller/OrcidSourcesController.php index 41dfa9716..e114844da 100644 --- a/app/plugins/OrcidSource/src/Controller/OrcidSourcesController.php +++ b/app/plugins/OrcidSource/src/Controller/OrcidSourcesController.php @@ -21,7 +21,7 @@ * * @link https://www.internet2.edu/comanage COmanage Project * @package registry-plugins - * @since COmanage Registry v5.1.0 + * @since COmanage Registry v5.2.0 * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ @@ -61,8 +61,4 @@ public function beforeRender(EventInterface $event) { return parent::beforeRender($event); } - - public function callback() { - // dummy - } } diff --git a/app/plugins/OrcidSource/src/Controller/OrcidTokensController.php b/app/plugins/OrcidSource/src/Controller/OrcidTokensController.php new file mode 100644 index 000000000..7942b5288 --- /dev/null +++ b/app/plugins/OrcidSource/src/Controller/OrcidTokensController.php @@ -0,0 +1,188 @@ +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/Entity/OrcidToken.php b/app/plugins/OrcidSource/src/Model/Entity/OrcidToken.php new file mode 100644 index 000000000..833bfab2d --- /dev/null +++ b/app/plugins/OrcidSource/src/Model/Entity/OrcidToken.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/OrcidSource/src/Model/Table/OrcidSourceCollectorsTable.php b/app/plugins/OrcidSource/src/Model/Table/OrcidSourceCollectorsTable.php index 5a865dbfe..da9578c40 100644 --- a/app/plugins/OrcidSource/src/Model/Table/OrcidSourceCollectorsTable.php +++ b/app/plugins/OrcidSource/src/Model/Table/OrcidSourceCollectorsTable.php @@ -1,6 +1,6 @@ belongsTo('EnrollmentFlowSteps'); $this->belongsTo('ExternalIdentitySources'); - $this->hasMany('EnvSource.PetitionEnvIdentities') - ->setDependent(true) - ->setCascadeCallbacks(true); - $this->setDisplayField('id'); $this->setPrimaryLink('enrollment_flow_step_id'); @@ -84,14 +80,14 @@ public function initialize(array $config): void { $this->setTabsConfig( [ // Ordered list of Tabs - 'tabs' => ['EnrollmentFlowSteps', 'EnvSource.EnvSourceCollectors'], + 'tabs' => ['EnrollmentFlowSteps', 'OrcidSource.OrcidSourceCollectors'], // What actions will include the subnavigation header 'action' => [ // If a model renders in a subnavigation mode in edit/view mode, it cannot // render in index mode for the same use case/context // XXX edit should go first. 'EnrollmentFlowSteps' => ['edit', 'view'], - 'EnvSource.EnvSourceCollectors' => ['edit'], + 'OrcidSource.OrcidSourceCollectors' => ['edit'], ] ] ); @@ -100,7 +96,7 @@ public function initialize(array $config): void { 'externalIdentitySources' => [ 'type' => 'select', 'model' => 'ExternalIdentitySources', - 'where' => ['plugin' => 'EnvSource.EnvSources'] + 'where' => ['plugin' => 'OrcidSource.OrcidSources'] ] ]); @@ -121,39 +117,10 @@ public function initialize(array $config): void { ]); } - /** - * Check for an existing External Identity associated with the requested Source Key. - * - * @since COmanage Registry v5.1.0 - * @param int $eisId External Identity Source ID - * @param string $sourceKey Source Key - * @return bool true if the check passes and it is OK to proceed - * @throws OverflowException - */ - - protected function checkDuplicate(int $eisId, string $sourceKey): bool { - $EISRecords = TableRegistry::getTableLocator()->get('ExtIdentitySourceRecords'); - - $dupe = $EISRecords->find() - ->where([ - 'source_key' => $sourceKey, - 'external_identity_source_id' => $eisId - ]) - ->first(); - - if(!empty($dupe)) { - $this->llog('error', "Source Key $sourceKey is already attached to External Identity " . $dupe->external_identity_id . " for External Identity Source ID " . $eisId); - - throw new \OverflowException(__d('env_source', 'error.source_key.duplicate', [$sourceKey, $dupe->external_identity_id])); - } - - return true; - } - /** * Perform steps necessary to hydrate the Person record as part of Petition finalization. * - * @since COmanage Registry v5.1.0 + * @since COmanage Registry v5.2.0 * @param int $id Env Source Collector ID * @param Petition $petition Petition * @return bool true on success @@ -167,14 +134,14 @@ public function hydrate(int $id, \App\Model\Entity\Petition $petition) { $PetitionHistoryRecords = TableRegistry::getTableLocator()->get('PetitionHistoryRecords'); // At this point there is a Person record allocated and stored in the Petition. - // We need to sync the EnvSource Identity (which is cached in env_source_identities) + // We need to sync the OrcidSource Identity (which is cached in env_source_identities) // to the Enrollee Person. - // We need the Source Key to sync, which is available via the EnvSourceIdentity. + // We need the Source Key to sync, which is available via the OrcidSourceIdentity. $pei = $this->PetitionEnvIdentities->find() ->where(['petition_id' => $petition->id]) - ->contain(['EnvSourceIdentities']) + ->contain(['OrcidSourceIdentities']) ->first(); if(!empty($pei->env_source_identity->source_key)) { @@ -237,13 +204,13 @@ public function hydrate(int $id, \App\Model\Entity\Petition $petition) { /** * Parse the environment values as per the configuration. * - * @since COmanag Registry v5.1.0 - * @param EnvSource $envSource EnvSource configuration entity + * @since COmanag Registry v5.2.0 + * @param OrcidSource $envSource OrcidSource configuration entity * @return array Array of env variables and their parsed values * @throws InvalidArgumentException */ - public function parse(\EnvSource\Model\Entity\EnvSource $envSource): array { + public function parse(\OrcidSource\Model\Entity\OrcidSource $envSource): array { // The filtered set of variables to return $ret = []; @@ -264,7 +231,7 @@ public function parse(\EnvSource\Model\Entity\EnvSource $envSource): array { && !empty($envSource->$field) // This field is configured with an env var name && getenv($envSource->$field) // This env var is populated ) { - // Note we're using the EnvSource field name (eg: env_name_given) as the key + // Note we're using the OrcidSource field name (eg: env_name_given) as the key // and not the configured variable name (which might be something like SHIB_FIRST_NAME) $ret[$field] = getenv($envSource->$field); } @@ -273,44 +240,10 @@ public function parse(\EnvSource\Model\Entity\EnvSource $envSource): array { return $ret; } - /** - * Load environment variables from a lookaside file based on the given configuration. - * - * @param string $filename Path to the lookaside file - * @param \EnvSource\Model\Entity\EnvSource $envSource EnvSource configuration entity - * @return array Array of environment variables and their parsed values - * @throws InvalidArgumentException - *@since COmanage Registry v5.1.0 - */ - public function loadFromLookasideFile(string $filename, \EnvSource\Model\Entity\EnvSource $envSource): array { - $src = parse_ini_file($filename); - $ret = []; - - if(!$src) { - throw new \InvalidArgumentException(__d('env_source', 'error.lookaside_file', [$filename])); - } - - // We walk through our configuration and only copy the variables that were configured - foreach($envSource->getVisible() as $field) { - // We only want the fields starting env_ (except env_source_id, which is changelog metadata) - - if(strncmp($field, "env_", 4)==0 && $field != "env_source_id" - && !empty($envSource->$field) // This field is configured with an env var name - && isset($src[$envSource->$field]) // This env var is populated - ) { - // Note we're using the EnvSource field name (eg: env_name_given) as the key - // and not the configured variable name (which might be something like SHIB_FIRST_NAME) - $ret[$field] = $src[$envSource->$field]; - } - } - - return $ret; - } - /** * Insert or update a Petition Env Identity. * - * @since COmanage Registry v5.1.0 + * @since COmanage Registry v5.2.0 * @param int $id Env Source Collector ID * @param int $petitionId Petition ID * @param array $attributes Env Sounce Attributes @@ -321,97 +254,6 @@ public function loadFromLookasideFile(string $filename, \EnvSource\Model\Entity\ */ public function upsert(int $id, int $petitionId, array $attributes) { - if(empty($attributes['env_identifier_sourcekey'])) { - throw new \InvalidArgumentException(__d('env_source', 'error.source_key')); - } - - $sourceKey = $attributes['env_identifier_sourcekey']; - - // Pulling our configuration is a bit complicated because of the indirect relations - $envSourceCollector = $this->get($id); - - $EnvSources = TableRegistry::getTableLocator()->get('EnvSource.EnvSources'); - - $envSource = $EnvSources->find() - ->where(['external_identity_source_id' => $envSourceCollector->external_identity_source_id]) - ->firstOrFail(); - - // We first check that there is not an External Identity in this CO that - // already has this Source Key from this EnvSource instance. Technically we - // could wait until finalization and let the Pipeline implement this check, - // but it's a better user experience to detect the situation earlier in the flow. - - // Note it is OK if another Petition was started with the same Source Key, but only - // one such Petition can successfully complete - the other(s) will fail at finalize - // (if not sooner). This allows for abandoned enrollments, etc. - - // This will throw OverflowException on duplicate - $this->checkDuplicate($envSourceCollector->external_identity_source_id, $sourceKey); - - // We need to update two tables here because of constraints imposed by how - // EnvSource works. First we insert a record into EnvSourceIdentities, which - // is basically a cache of known identities. The Source Key must be unique - // (within the External Identity Source), so an existing record there is an - // error _unless_ there is not yet a corresponding External Identity. - // EnvSourceIdentities is the table used by EnvSource::retrieve in order to - // sync the Identity to a Person (since at that point there is no concept of - // a Petition). - - $EnvSourceIdentities = TableRegistry::getTableLocator()->get('EnvSource.EnvSourceIdentities'); - - $esi = $EnvSourceIdentities->upsertOrFail( - data: [ - 'env_source_id' => $envSource->id, - 'source_key' => $sourceKey, - 'env_attributes' => json_encode($attributes) - ], - whereClause: [ - 'env_source_id' => $envSource->id, - 'source_key' => $sourceKey - ] - ); - - // We then upsert PetitionEnvIdentities, which is the Petition artifact linking - // to the EnvSourceIdentity. We allow the same source_key to exist in multiple - // Petitions, eg to account for abandoned enrollments. However, only one Petition - // may create the External Identity, so once that happens any other pending - // Petitions will fail to finalize. - -// XXX Each source_key must be unique across External Identities within the CO -// We do allow more than one active, non-finalized Petition to have the same -// source_key, however this should generate a warning as only the first one to -// finalize will be successful. -// - A source_key already associated with an External Identity may not be associated -// with a new Petition within the same CO -// - A source_key already associated with an active, non-finalized Petition may be -// associated with another new Petition within the same CO, however only the first -// Petition to finalize will be associated with the source_key -// The above could be PARs, but these rules are also likely to apply to whatever -// v4 Query mode becomes, and so should maybe be more general (like -// AR-ExternalIdentitySourceRecord-1). - - $pei = $this->PetitionEnvIdentities->upsertOrFail( - data: [ - 'petition_id' => $petitionId, - 'env_source_collector_id' => $id, - 'env_source_identity_id' => $esi->id - ], - whereClause: [ - 'petition_id' => $petitionId, - 'env_source_collector_id' => $id, - ] - ); - - // Record Petition History - - $PetitionHistoryRecords = TableRegistry::getTableLocator()->get('PetitionHistoryRecords'); - - $PetitionHistoryRecords->record( - petitionId: $petitionId, - enrollmentFlowStepId: $envSourceCollector->enrollment_flow_step_id, - action: PetitionActionEnum::AttributesUpdated, - comment: __d('env_source', 'result.env.saved') - ); return true; } @@ -419,7 +261,7 @@ public function upsert(int $id, int $petitionId, array $attributes) { /** * Set validation rules. * - * @since COmanage Registry v5.1.0 + * @since COmanage Registry v5.2.0 * @param Validator $validator Validator * @return Validator Validator */ @@ -439,50 +281,4 @@ public function validationDefault(Validator $validator): Validator { return $validator; } - - /** - * Obtain the set of Email Addresses known to this plugin that are eligible for - * verification or that have already been verified. - * - * @since COmanage Registry v5.1.0 - * @param EntityInterface $config Configuration entity for this plugin - * @param int $petitionId Petition ID - * @return array Array of Email Addresses and verification status - */ - - public function verifiableEmailAddresses( - EntityInterface $config, - int $petitionId - ): array { - // We treat the email address (if any) provided by the external source (IdP) - // as verifiable, or possibly already verified (trusted). EnvSource does not - // support per-record verification flags, either all email addresses from this - // source are verified or none are. This, in turn, is actually configured in the - // Pipeline, which is where record modification happens. (We don't actually create - // any Verification artifacts here -- that will be handled by the Pipeline - // during finalization.) - - $eis = $this->ExternalIdentitySources->get( - $config->external_identity_source_id, - ['contain' => 'Pipelines'] - ); - - $defaultVerified = isset($eis->pipeline->sync_verify_email_addresses) - && ($eis->pipeline->sync_verify_email_addresses === true); - - $pei = $this->PetitionEnvIdentities->find() - ->where(['petition_id' => $petitionId]) - ->contain(['EnvSourceIdentities']) - ->first(); - - if(!empty($pei->env_source_identity->env_attributes)) { - $attrs = json_decode($pei->env_source_identity->env_attributes); - - if(!empty($attrs->env_mail)) { - return [$attrs->env_mail => $defaultVerified]; - } - } - - return []; - } } \ No newline at end of file diff --git a/app/plugins/OrcidSource/src/Model/Table/OrcidSourcesTable.php b/app/plugins/OrcidSource/src/Model/Table/OrcidSourcesTable.php index 96ea8e493..72ab0c3b1 100644 --- a/app/plugins/OrcidSource/src/Model/Table/OrcidSourcesTable.php +++ b/app/plugins/OrcidSource/src/Model/Table/OrcidSourcesTable.php @@ -149,8 +149,8 @@ public function redirectUri(): string { $callback = [ 'plugin' => 'OrcidSource', - 'controller' => 'OrcidSources', - 'action' => 'callback', + 'controller' => 'OrcidSourceCollectors', + 'action' => 'dispatch', ]; return Router::url($callback, true); @@ -185,8 +185,6 @@ public function getChangeList( public function inventory( \App\Model\Entity\ExternalIdentitySource $source ): array { -// XXX do we want to implement inventory of cached records? - return false; } @@ -221,7 +219,19 @@ public function retrieve( \App\Model\Entity\ExternalIdentitySource $source, string $source_key ): array { - return []; + try { + $this->orcidConnect($this->pluginCfg['server_id'], $id); + + $orcidbio = $this->orcidRequest('/v3.0/' . $id . '/person'); + } + catch(InvalidArgumentException $e) { + throw new InvalidArgumentException(_txt('er.id.unk-a', array($id))); + } + + return array( + 'raw' => json_encode($orcidbio), + 'orgidentity' => $this->resultToOrgIdentity($id, $orcidbio) + ); } /** @@ -237,20 +247,131 @@ public function search( \App\Model\Entity\ExternalIdentitySource $source, array $searchAttrs ): array { - return []; + $ret = []; + + if(!isset($searchAttrs['q'])) { + // For now, we only support free form search (though ORCID does support + // search by eg email). + + return []; + } + + // We just let search exceptions pop up the stack + + $this->orcidConnect($this->pluginCfg['server_id']); + + $records = $this->orcidRequest('/v3.0/search/', $searchAttrs); + + // We can control pagination with query params, but the OIS search capability + // doesn't currently understand pagination. + + if(isset($records->{'num-found'}) && $records->{'num-found'} > 0) { + foreach($records->result as $rec) { + if(!empty($rec->{'orcid-identifier'}->{'path'})) { + $orcid = $rec->{'orcid-identifier'}->{'path'}; + + $orcidbio = $this->orcidRequest('/v3.0/' . $orcid . '/person'); + + if(!empty($orcidbio)) { +// $ret[ $orcid ] = $this->resultToOrgIdentity($orcid, $orcidbio); + } + } + } + } + + return $ret; } /** - * Obtain the set of searchable attributes for this backend. + * Get the set of searchable attributes for this backend. * * @since COmanage Registry v5.2.0 * @return array Array of searchable attributes and localized descriptions */ public function searchableAttributes(): array { + // By default, ORCID uses a free form search. It is possible to search on + // specific fields (eg: email), though for the initial implementation we + // won't support that. return [ - 'source_key' => __d('field', 'source_key') + // XXX This really isn't the right language key, we want an fd.* + 'q' => __d('operation', 'search') + ]; + } + + /** + * Make a request to the ORCID API. + * + * @since COmanage Registry v3.2.0 + * @param String $urlPath URL Path to request from API + * @param Array $data Array of query paramaters + * @param String $action HTTP action + * @return Array Decoded json message body + * @throws InvalidArgumentException + * @throws RuntimeException + */ + + public function orcidRequest(string $urlPath, array $data=[], string $action="get"): array + { + $OrcidToken = new OrcidToken(); + + $options = [ + 'header' => [ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer ' . ($OrcidToken->getUnencrypted($this->orcidToken['OrcidToken']['access_token']) + ?? $this->server['Oauth2Server']['access_token']), + 'Content-Type' => 'application/orcid+json' + ] ]; + + $orcidUrlBase = $this->orcidUrl($this->orcidSource['OrcidSource']['api_type'], + $this->orcidSource['OrcidSource']['api_tier']); + $results = $this->Http->$action($orcidUrlBase . $urlPath, + ($action == 'get' ? $data : json_encode($data)), + $options); + + if($results->code == 404) { + // Most likely retrieving an invalid ORCID + throw new InvalidArgumentException(_txt('er.orcidsource.search', [$results->code])); + } + + if($results->code != 200) { + // This is probably an RDF blob, which is slightly annoying to parse. + // Rather than do it properly since we don't parse RDF anywhere else, + // we return a generic error. + throw new RuntimeException(_txt('er.orcidsource.search', [$results->code])); + } + + return json_decode($results->body); + } + + /** + * Get the root URL for the ORCID API. + * + * @param string $api API type: auth, public, or member + * @param string $tier API tier: prod or sandbox + * @return string URL prefix + * @since COmanage Registry v5.2.0 + */ + + public function orcidUrl(string $api=OrcidSourceApiEnum::PUBLIC, string $tier=OrcidSourceTierEnum::PROD): string + { + $orcidUrls = [ + OrcidSourceApiEnum::AUTH => [ + OrcidSourceTierEnum::PROD => 'https://orcid.org', + OrcidSourceTierEnum::SANDBOX => 'https://sandbox.orcid.org' + ], + OrcidSourceApiEnum::MEMBERS => [ + OrcidSourceTierEnum::PROD => 'https://api.orcid.org', + OrcidSourceTierEnum::SANDBOX => 'https://api.sandbox.orcid.org' + ], + OrcidSourceApiEnum::PUBLIC => [ + OrcidSourceTierEnum::PROD => 'https://pub.orcid.org', + OrcidSourceTierEnum::SANDBOX => 'https://pub.sandbox.orcid.org' + ] + ]; + + return $orcidUrls[$api][$tier]; } /** diff --git a/app/plugins/OrcidSource/src/Model/Table/OrcidTokensTable.php b/app/plugins/OrcidSource/src/Model/Table/OrcidTokensTable.php new file mode 100644 index 000000000..3249b2378 --- /dev/null +++ b/app/plugins/OrcidSource/src/Model/Table/OrcidTokensTable.php @@ -0,0 +1,169 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + // Define associations + $this->belongsTo('OrcidSources'); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, //['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Perform actions while marshaling data, before validation. + * + * @param EventInterface $event Event + * @param \ArrayObject $data Object data, in array format + * @param \ArrayObject $options Entity save options + * @since COmanage Registry v5.2.0 + */ + + public function beforeMarshal(EventInterface $event, \ArrayObject $data, \ArrayObject $options) + { + // Encryption logic + $key = Security::getSalt(); + + foreach (['id_token', 'access_token', 'refresh_token'] as $column) { + if (!empty($data[$column])) { + // Security::encrypt expects string, $key must be correct length for the cipher! + $payload = base64_encode(Security::encrypt($data[$column], $key)); + + // If updating, try to fetch existing stored value to compare + $stored_key = ''; + if (!empty($data['id'])) { + $entity = $this->find()->select([$column])->where(['id' => $data['id']])->first(); + if ($entity) { + $stored_key = $entity->{$column}; + } + } + + if ($stored_key !== $payload) { + $data[$column] = $payload; + } + } + } + + } + + /** + * Unencrypt a value previously encrypted using salt + * + * @param string $value + * + * @return false|string + * @since COmanage Registry v5.2.0 + */ + + public function getUnencrypted(string $value): string|false + { + if(empty($value)) { + return ''; + } + return Security::decrypt(base64_decode($value), Security::getSalt()); + } + + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('orcid_source_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('orcid_source_id'); + + foreach(['orcid_identifier', 'access_token', 'id_token', 'refresh_token'] as $column) { + $validator->add($column, [ + 'content' => [ + 'rule' => 'validateNotBlank', + 'provider' => 'table' + ] + ]); + } + $validator->notEmptyString('orcid_identifier'); + $validator->notEmptyString('access_token'); + $validator->allowEmptyString('id_token'); + $validator->allowEmptyString('refresh_token'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/plugins/OrcidSource/src/View/Cell/OrcidSourceCollectorsCell.php b/app/plugins/OrcidSource/src/View/Cell/OrcidSourceCollectorsCell.php new file mode 100644 index 000000000..2a4144509 --- /dev/null +++ b/app/plugins/OrcidSource/src/View/Cell/OrcidSourceCollectorsCell.php @@ -0,0 +1,80 @@ + + */ + protected $_validCellOptions = [ + 'vv_obj', + 'vv_step', + 'viewVars', + ]; + + /** + * Initialization logic run at the end of object construction. + * + * @return void + */ + public function initialize(): void + { + } + + /** + * Default display method. + * + * @param int $petitionId + * @return void + * @since COmanage Registry v5.2.0 + */ + public function display(int $petitionId): void + { + $OrcidTokens = $this->fetchTable('OrcidSource.OrcidTokens'); + + $this->set('vv_orcid_token',[]); + + $this->set('vv_step', $this->vv_step); + $this->set('vv_obj', $this->vv_obj); + } +} diff --git a/app/plugins/OrcidSource/templates/OrcidSourceCollectors/dispatch.inc b/app/plugins/OrcidSource/templates/OrcidSourceCollectors/dispatch.inc index 8d3b2ca6a..21f13cbc3 100644 --- a/app/plugins/OrcidSource/templates/OrcidSourceCollectors/dispatch.inc +++ b/app/plugins/OrcidSource/templates/OrcidSourceCollectors/dispatch.inc @@ -21,39 +21,44 @@ * * @link https://www.internet2.edu/comanage COmanage Project * @package registry - * @since COmanage Registry v5.1.0 + * @since COmanage Registry v5.2.0 * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ declare(strict_types = 1); // This view is intended to work with dispatch -if($vv_action == 'dispatch') { - // Make the Form fields editable - $this->Field->enableFormEditMode(); - ksort($vv_env_source_vars); - $previousKey = ''; - // Render the parsed variables -?> -
= __d('env_source', 'information.review') ?>
-
+ Sign in with your ORCID account to securely verify your ORCID iD.
+ + +This is a test page