diff --git a/classes/core/PKPContainer.php b/classes/core/PKPContainer.php index f3e639adb97..8b870333b31 100644 --- a/classes/core/PKPContainer.php +++ b/classes/core/PKPContainer.php @@ -450,7 +450,7 @@ protected function loadConfiguration(): void // Cache configuration $items['cache'] = [ - 'default' => Config::getVar('cache', 'default', 'opcache'), + 'default' => Config::getVar('cache', 'default', 'file'), 'stores' => [ 'opcache' => [ 'driver' => 'opcache', diff --git a/classes/mail/mailables/OrcidRequestUpdateScope.php b/classes/mail/mailables/OrcidRequestUpdateScope.php new file mode 100644 index 00000000000..c44a892a1ed --- /dev/null +++ b/classes/mail/mailables/OrcidRequestUpdateScope.php @@ -0,0 +1,52 @@ +setupOrcidVariables($oauthUrl, $context); + } + + public static function getDataDescriptions(): array + { + /** + * Adds ORCID URLs to email template + */ + return array_merge( + parent::getDataDescriptions(), + static::getOrcidDataDescriptions() + ); + } +} diff --git a/classes/mail/traits/OrcidVariables.php b/classes/mail/traits/OrcidVariables.php index 3188ef2ec04..274f5e0dc3e 100644 --- a/classes/mail/traits/OrcidVariables.php +++ b/classes/mail/traits/OrcidVariables.php @@ -50,7 +50,13 @@ protected function setupOrcidVariables(string $oauthUrl, Context $context): void $this->addData([ self::$authorOrcidUrl => $oauthUrl, - self::$orcidAboutUrl => $dispatcher->url($request, Application::ROUTE_PAGE, null, 'orcid', 'about', urlLocaleForPage: ''), + self::$orcidAboutUrl => $dispatcher->url( + $request, + Application::ROUTE_PAGE, + newContext: $context->getPath(), + handler: 'orcid', + op: 'about', + urlLocaleForPage: ''), self::$principalContactSignature => $principalContact->getLocalizedSignature(), ]); } diff --git a/classes/migration/upgrade/v3_5_0/I10819_OrcidOauthScopeMail.php b/classes/migration/upgrade/v3_5_0/I10819_OrcidOauthScopeMail.php new file mode 100644 index 00000000000..3528c8eeddf --- /dev/null +++ b/classes/migration/upgrade/v3_5_0/I10819_OrcidOauthScopeMail.php @@ -0,0 +1,45 @@ +dao->installEmailTemplates( + Repo::emailTemplate()->dao->getMainEmailTemplatesFilename(), + [], + 'ORCID_REQUEST_UPDATE_SCOPE', + true, + ); + } + + /** + * @inheritDoc + * @throws DowngradeNotSupportedException + */ + public function down(): void + { + throw new DowngradeNotSupportedException(); + } +} diff --git a/classes/orcid/OrcidManager.php b/classes/orcid/OrcidManager.php index f64c44d5171..7c45605be83 100644 --- a/classes/orcid/OrcidManager.php +++ b/classes/orcid/OrcidManager.php @@ -152,30 +152,29 @@ public static function isSandbox(?Context $context = null): bool * * @throws \Exception */ - public static function buildOAuthUrl(string $handlerMethod, array $redirectParams): string + public static function buildOAuthUrl(string $handlerMethod, array $redirectParams, ?Context $context = null): string { $request = Application::get()->getRequest(); - $context = $request->getContext(); + $context = $context ?? $request->getContext(); if ($context === null) { throw new \Exception('OAuth URLs can only be made in a Context, not site wide.'); } - $scope = self::isMemberApiEnabled() ? self::ORCID_API_SCOPE_MEMBER : self::ORCID_API_SCOPE_PUBLIC; + $scope = self::isMemberApiEnabled($context) ? self::ORCID_API_SCOPE_MEMBER : self::ORCID_API_SCOPE_PUBLIC; // We need to construct a page url, but the request is using the component router. // Use the Dispatcher to construct the url and set the page router. $redirectUrl = $request->getDispatcher()->url( $request, Application::ROUTE_PAGE, - null, - 'orcid', - $handlerMethod, - null, - $redirectParams, + newContext: $context->getPath(), + handler: 'orcid', + op: $handlerMethod, + params: $redirectParams, urlLocaleForPage: '', ); - return self::getOauthPath() . 'authorize?' . http_build_query( + return self::getOauthPath($context) . 'authorize?' . http_build_query( [ 'client_id' => self::getClientId($context), 'response_type' => 'code', @@ -232,7 +231,7 @@ public static function getLogLevel(?Context $context = null): string $context = Application::get()->getRequest()->getContext(); } - return $context->getData(self::LOG_LEVEL) ?? self::LOG_LEVEL_ERROR; + return $context?->getData(self::LOG_LEVEL) ?? self::LOG_LEVEL_ERROR; } @@ -251,9 +250,9 @@ public static function shouldSendMailToAuthors(?Context $context = null): bool /** * Helper method that gets OAuth endpoint for configured ORCID URL (production or sandbox) */ - public static function getOauthPath(): string + public static function getOauthPath(?Context $context = null): string { - return self::getOrcidUrl() . 'oauth/'; + return self::getOrcidUrl($context) . 'oauth/'; } /** @@ -352,9 +351,9 @@ private static function writeLog(string $message, string $level): void /** * Gets the ORCID API endpoint to revoke an access token */ - public static function getTokenRevocationUrl(): string + public static function getTokenRevocationUrl(?Context $context = null): string { - return self::getOauthPath() . 'revoke'; + return self::getOauthPath($context) . 'revoke'; } /** diff --git a/classes/orcid/actions/PKPSendSubmissionToOrcid.php b/classes/orcid/actions/PKPSendSubmissionToOrcid.php index 3af43d121b9..c16a97f250c 100644 --- a/classes/orcid/actions/PKPSendSubmissionToOrcid.php +++ b/classes/orcid/actions/PKPSendSubmissionToOrcid.php @@ -18,6 +18,7 @@ use APP\orcid\actions\SendReviewToOrcid; use APP\publication\Publication; use Carbon\Carbon; +use Illuminate\Contracts\Queue\ShouldBeUnique; use PKP\context\Context; use PKP\jobs\orcid\DepositOrcidSubmission; use PKP\orcid\OrcidManager; diff --git a/classes/orcid/actions/VerifyAuthorWithOrcid.php b/classes/orcid/actions/VerifyAuthorWithOrcid.php deleted file mode 100644 index c824be7202a..00000000000 --- a/classes/orcid/actions/VerifyAuthorWithOrcid.php +++ /dev/null @@ -1,177 +0,0 @@ -request->getContext(); - if (!OrcidManager::isEnabled($context)) { - return $this; - } - - // Fetch the access token - $oauthTokenUrl = OrcidManager::getApiPath($context) . OrcidManager::OAUTH_TOKEN_URL; - - $httpClient = Application::get()->getHttpClient(); - $headers = ['Accept' => 'application/json']; - $postData = [ - 'code' => $this->request->getUserVar('code'), - 'grant_type' => 'authorization_code', - 'client_id' => OrcidManager::getClientId($context), - 'client_secret' => OrcidManager::getClientSecret($context) - ]; - - OrcidManager::logInfo('POST ' . $oauthTokenUrl); - OrcidManager::logInfo('Request headers: ' . var_export($headers, true)); - OrcidManager::logInfo('Request body: ' . http_build_query($postData)); - - try { - $response = $httpClient->request( - 'POST', - $oauthTokenUrl, - [ - 'headers' => $headers, - 'form_params' => $postData, - 'allow_redirects' => ['strict' => true], - ], - ); - - if ($response->getStatusCode() !== 200) { - OrcidManager::logError('VerifyAuthorWithOrcid::execute - unexpected response: ' . $response->getStatusCode()); - $this->addTemplateVar('authFailure', true); - } - $results = json_decode($response->getBody(), true); - - // Check for errors - OrcidManager::logInfo('Response body: ' . print_r($results, true)); - if (($results['error'] ?? null) === 'invalid_grant') { - OrcidManager::logError('Authorization code invalid, maybe already used'); - $this->addTemplateVar('authFailure', true); - } - if (isset($results['error'])) { - OrcidManager::logError('Invalid ORCID response: ' . $results['error']); - $this->addTemplateVar('authFailure', true); - } - - // Check for duplicate ORCID for author - $orcidUri = OrcidManager::getOrcidUrl($context) . $results['orcid']; - if (!empty($this->author->getOrcid()) && $orcidUri !== $this->author->getOrcid()) { - $this->addTemplateVar('duplicateOrcid', true); - } - $this->addTemplateVar('orcid', $orcidUri); - - $this->author->setOrcid($orcidUri); - $this->author->setOrcidVerified(true); - $this->author->setData('orcidVerificationRequested', null); - if (OrcidManager::isSandbox($context)) { - $this->author->setData('orcidEmailToken', null); - } - $this->setOrcidAccessData($orcidUri, $results); - Repo::author()->dao->update($this->author); - - // Send member submissions to ORCID - if (OrcidManager::isMemberApiEnabled($context)) { - $publicationId = $this->request->getUserVar('state'); - $publication = Repo::publication()->get($publicationId); - - if ($publication->getData('status') == PKPSubmission::STATUS_PUBLISHED) { - (new SendSubmissionToOrcid($publication, $context))->execute(); - $this->addTemplateVar('sendSubmissionSuccess', true); - } else { - $this->addTemplateVar('submissionNotPublished', true); - } - } - - $this->addTemplateVar('verifySuccess', true); - $this->addTemplateVar('orcidIcon', OrcidManager::getIcon()); - } catch (GuzzleException $exception) { - OrcidManager::logError('Publication fail: ' . $exception->getMessage()); - $this->addTemplateVar('orcidAPIError', $exception->getMessage()); - } - - // Does not indicate auth was a failure, but if verifySuccess was not set to true above, - // the nature of the failure will be auth related . If verifySuccess is set to true above, an `else` branch - // in the template that covers various failure/error messages will never be reached. - $this->addTemplateVar('authFailure', true); - return $this; - } - - /** - * Takes template variables for frontend display from OAuth process and assigns them to the TemplateManager. - * - * @param TemplateManager $templateMgr The template manager to which the variable should be set - */ - public function updateTemplateMgrVars(TemplateManager &$templateMgr): void - { - foreach ($this->templateVarsToSet as $key => $value) { - $templateMgr->assign($key, $value); - } - } - - /** - * Helper to set ORCID and OAuth values to the author. NB: Does not save updated Author instance to the database. - * - * @param string $orcidUri Complete ORCID URL - */ - private function setOrcidAccessData(string $orcidUri, array $results): void - { - // Save the access token - $orcidAccessExpiresOn = Carbon::now(); - // expires_in field from the response contains the lifetime in seconds of the token - // See https://members.orcid.org/api/get-oauthtoken - $orcidAccessExpiresOn->addSeconds($results['expires_in']); - $this->author->setOrcid($orcidUri); - // remove the access denied marker, because now the access was granted - $this->author->setData('orcidAccessDenied', null); - $this->author->setData('orcidAccessToken', $results['access_token']); - $this->author->setData('orcidAccessScope', $results['scope']); - $this->author->setData('orcidRefreshToken', $results['refresh_token']); - $this->author->setData('orcidAccessExpiresOn', $orcidAccessExpiresOn->toDateTimeString()); - - } - - /** - * Stores key, value pair to be assigned to the TemplateManager for display in frontend UI. - */ - private function addTemplateVar(string $key, mixed $value): void - { - $this->templateVarsToSet[$key] = $value; - } -} diff --git a/classes/orcid/actions/VerifyIdentityWithOrcid.php b/classes/orcid/actions/VerifyIdentityWithOrcid.php new file mode 100644 index 00000000000..6bd8d9c0973 --- /dev/null +++ b/classes/orcid/actions/VerifyIdentityWithOrcid.php @@ -0,0 +1,238 @@ +context = $this->request->getContext(); + } + + /** + * Execute the action. + * + * NB: This method returns itself so template variables can be added to a template via `updateTemplateMgrVars()` below. + */ + public function execute(): self + { + if (!OrcidManager::isEnabled($this->context)) { + return $this; + } + + $response = $this->getHandshakeResponse(); + if ($response !== null) { + $this->handleResponseErrors($response); + $this->setIdentityData($response); + $this->saveIdentityData(); + $this->depositOrcidItem(); + } + + // Does not indicate auth was a failure, but if verifySuccess was not set to true above, + // the nature of the failure will be auth related . If verifySuccess is set to true above, an `else` branch + // in the template that covers various failure/error messages will never be reached. + $this->addTemplateVar('authFailure', true); + + return $this; + } + + /** + * Takes template variables for frontend display from OAuth process and assigns them to the TemplateManager. + * + * @param TemplateManager $templateMgr The template manager to which the variable should be set + */ + public function updateTemplateMgrVars(TemplateManager &$templateMgr): void + { + foreach ($this->templateVarsToSet as $key => $value) { + $templateMgr->assign($key, $value); + } + } + + /** + * Returns the decoded JSON contents of the OAuth handshake response + */ + private function getHandshakeResponse(): ?array + { + // Fetch access token + $oauthTokenUrl = OrcidManager::getApiPath($this->context) . OrcidManager::OAUTH_TOKEN_URL; + + $httpClient = Application::get()->getHttpClient(); + $headers = ['Accept' => 'application/json']; + $postData = [ + 'code' => $this->request->getUserVar('code'), + 'grant_type' => 'authorization_code', + 'client_id' => OrcidManager::getClientId($this->context), + 'client_secret' => OrcidManager::getClientSecret($this->context) + ]; + + OrcidManager::logInfo('POST ' . $oauthTokenUrl); + OrcidManager::logInfo('Request headers: ' . var_export($headers, true)); + OrcidManager::logInfo('Request body: ' . http_build_query($postData)); + + try { + $response = $httpClient->request( + 'POST', + $oauthTokenUrl, + [ + 'headers' => $headers, + 'form_params' => $postData, + 'allow_redirects' => ['strict' => true], + ], + ); + + if ($response->getStatusCode() !== 200) { + OrcidManager::logError('VerifyIdentityWithOrcid::getHandshakeResponse - unexpected response: ' . $response->getStatusCode()); + $this->addTemplateVar('authFailure', true); + } + + $this->addTemplateVar('verifySuccess', true); + $this->addTemplateVar('orcidIcon', OrcidManager::getIcon()); + + return json_decode($response->getBody(), true); + } catch (GuzzleException $exception) { + OrcidManager::logError('Publication fail: ' . $exception->getMessage()); + $this->addTemplateVar('orcidAPIError', $exception->getMessage()); + } + + return null; + } + + /** + * Handles setting relevant ORCID data to user or author. + * + * @param array $response + * @return void + */ + private function setIdentityData(array $response): void + { + $orcidUri = OrcidManager::getOrcidUrl($this->context) . $response['orcid']; + $this->addTemplateVar('orcid', $orcidUri); + + $this->identity->setOrcid($orcidUri); + $this->identity->setOrcidVerified(true); + + // Save the access token + $orcidAccessExpiresOn = Carbon::now(); + // expires_in field from the response contains the lifetime in seconds of the token + // See https://members.orcid.org/api/get-oauthtoken + $orcidAccessExpiresOn->addSeconds($response['expires_in']); + // remove the access denied marker, because now the access was granted + $this->identity->setData('orcidAccessDenied', null); + $this->identity->setData('orcidAccessToken', $response['access_token']); + $this->identity->setData('orcidAccessScope', $response['scope']); + $this->identity->setData('orcidRefreshToken', $response['refresh_token']); + $this->identity->setData('orcidAccessExpiresOn', $orcidAccessExpiresOn->toDateTimeString()); + + + if ($this->identity instanceof Author) { + $this->identity->setData('orcidEmailToken', null); + } + } + + /** + * Saves identity information using the correct DAO (`Author` or `User`). + * + * @throws \Exception + */ + private function saveIdentityData(): void + { + if ($this->identity instanceof Author) { + Repo::author()->dao->update($this->identity); + } else if ($this->identity instanceof User) { + Repo::user()->dao->update($this->identity); + } else { + throw new \Exception('Identity must be an instance of Author or User'); + } + } + + /** + * Stores key, value pair to be assigned to the TemplateManager for display in frontend UI. + */ + protected function addTemplateVar(string $key, mixed $value): void + { + $this->templateVarsToSet[$key] = $value; + } + + /** + * Dispatches job to deposit either ORCID work or review + */ + private function depositOrcidItem(): void + { + if (!OrcidManager::isMemberApiEnabled($this->context)) { + return; + } + + if ($this->depositType === OrcidDepositType::WORK) { + $publicationId = $this->request->getUserVar('state'); + $publication = Repo::publication()->get($publicationId); + + if ($publication->getData('status') == PKPSubmission::STATUS_PUBLISHED) { + (new SendSubmissionToOrcid($publication, $this->context))->execute(); + $this->addTemplateVar('sendSubmissionSuccess', true); + } else { + $this->addTemplateVar('submissionNotPublished', true); + } + } else if ($this->depositType === OrcidDepositType::REVIEW) { + $reviewAssignmentId = $this->request->getUserVar('itemId'); + (new SendReviewToOrcid($reviewAssignmentId))->execute(); + $this->addTemplateVar('sendSubmissionSuccess', true); + } + } + + /** + * Handles logging and template variable updates for OAuth response errors. + */ + private function handleResponseErrors(array $response): void + { + OrcidManager::logInfo('Response body: ' . print_r($response, true)); + if (($response['error'] ?? null) === 'invalid_grant') { + OrcidManager::logError('Authorization code invalid, maybe already used'); + $this->addTemplateVar('authFailure', true); + } + if (isset($response['error'])) { + OrcidManager::logError('Invalid ORCID response: ' . $response['error']); + $this->addTemplateVar('authFailure', true); + } + + $orcidUri = OrcidManager::getOrcidUrl($this->context) . $response['orcid']; + if (!empty($this->identity->getOrcid()) && $orcidUri !== $this->identity->getOrcid()) { + $this->addTemplateVar('duplicateOrcid', true); + } + } +} diff --git a/classes/orcid/enums/OrcidDepositType.php b/classes/orcid/enums/OrcidDepositType.php new file mode 100644 index 00000000000..73614bfd11b --- /dev/null +++ b/classes/orcid/enums/OrcidDepositType.php @@ -0,0 +1,21 @@ +context)) { + return; + } + + // Check author scope, if public API, stop here and request member scope + if ($this->author->getData('orcidAccessScope') !== OrcidManager::ORCID_API_SCOPE_MEMBER) { + // Request member scope and retry deposit + dispatch(new SendUpdateScopeMail($this->author, $this->context->getId(), $this->author->getData('publicationId'), OrcidDepositType::WORK)); + return; + } + $uri = OrcidManager::getApiPath($this->context) . OrcidManager::ORCID_API_VERSION_URL . $this->authorOrcid . '/' . OrcidManager::ORCID_WORK_URL; $method = 'POST'; if ($putCode = $this->author->getData('orcidWorkPutCode')) { - // Submission has already been sent to ORCID. Use PUT to update meta data + // Submission has already been sent to ORCID. Use PUT to update metadata $uri .= '/' . $putCode; $method = 'PUT'; $this->orcidWork['put-code'] = $putCode; @@ -134,4 +147,9 @@ public function handle(): void OrcidManager::logError("Unexpected status {$httpStatus} response, body: " . $response->getBody()); } } + + public function uniqueId(): string + { + return $this->author->getId(); + } } diff --git a/jobs/orcid/RevokeOrcidToken.php b/jobs/orcid/RevokeOrcidToken.php index e5b1c64a659..d461e9e9fb2 100644 --- a/jobs/orcid/RevokeOrcidToken.php +++ b/jobs/orcid/RevokeOrcidToken.php @@ -55,7 +55,7 @@ public function handle(): void try { $httpClient->request( 'POST', - OrcidManager::getTokenRevocationUrl(), + OrcidManager::getTokenRevocationUrl($this->context), [ 'headers' => $headers, 'form_params' => $postData, diff --git a/jobs/orcid/SendAuthorMail.php b/jobs/orcid/SendAuthorMail.php index 9952824b237..90f5397346b 100644 --- a/jobs/orcid/SendAuthorMail.php +++ b/jobs/orcid/SendAuthorMail.php @@ -18,6 +18,7 @@ use APP\author\Author; use APP\facades\Repo; +use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Support\Facades\Mail; use PKP\context\Context; use PKP\jobs\BaseJob; @@ -25,7 +26,7 @@ use PKP\mail\mailables\OrcidRequestAuthorAuthorization; use PKP\orcid\OrcidManager; -class SendAuthorMail extends BaseJob +class SendAuthorMail extends BaseJob implements ShouldBeUnique { public function __construct( private Author $author, @@ -55,7 +56,11 @@ public function handle() $emailToken = md5(microtime() . $this->author->getEmail()); $this->author->setData('orcidEmailToken', $emailToken); - $oauthUrl = OrcidManager::buildOAuthUrl('verify', ['token' => $emailToken, 'state' => $publicationId]); + $oauthUrl = OrcidManager::buildOAuthUrl( + 'verify', + ['token' => $emailToken, 'state' => $publicationId], + $this->context + ); if (OrcidManager::isMemberApiEnabled($this->context)) { $mailable = new OrcidRequestAuthorAuthorization($this->context, $submission, $oauthUrl); @@ -78,4 +83,9 @@ public function handle() Repo::author()->dao->update($this->author); } } + + public function uniqueId(): string + { + return $this->author->getId(); + } } diff --git a/jobs/orcid/SendUpdateScopeMail.php b/jobs/orcid/SendUpdateScopeMail.php new file mode 100644 index 00000000000..8538d6c219f --- /dev/null +++ b/jobs/orcid/SendUpdateScopeMail.php @@ -0,0 +1,125 @@ +getById($this->contextId); + if ($context === null) { + $this->fail("ORCID emails can only be sent from a context. A context could not be found for contextId: $this->contextId."); + } + + $submission = $this->getSubmission(); + if (!$submission) { + $this->fail('A submission could not be found for the associated item'); + } + + $emailToken = md5(microtime() . $this->identity->getEmail()); + $this->identity->setData('orcidEmailToken', $emailToken); + $oauthUrl = OrcidManager::buildOAuthUrl( + 'updateScope', + [ + 'token' => $emailToken, + 'itemId' => $this->itemId, + 'itemType' => $this->depositType->value, + 'userId' => $this->identity->getId(), + 'userIdType' => $this->identity instanceof User ? 'user' : 'author', + ], + $context + ); + + $mailable = new OrcidRequestUpdateScope($context, $submission, $oauthUrl); + + // Set From to primary journal contact + $mailable->from($context->getData('contactEmail'), $context->getData('contactName')); + + // Send mail + $mailable->recipients([$this->identity]); + $emailTemplateKey = $mailable::getEmailTemplateKey(); + $emailTemplate = Repo::emailTemplate()->getByKey($context->getId(), $emailTemplateKey); + $mailable->body($emailTemplate->getLocalizedData('body')) + ->subject($emailTemplate->getLocalizedData('subject')); + Mail::send($mailable); + + $this->saveIdentity(); + } + + /** + * Saves identity information using the correct DAO (`Author` or `User`). + */ + private function saveIdentity(): void + { + if ($this->identity instanceof Author) { + Repo::author()->dao->update($this->identity); + } else if ($this->identity instanceof User) { + Repo::user()->dao->update($this->identity); + } + } + + /** + * Gets the associated submission in the correct way depending on the type of item deposit (work or review). + */ + private function getSubmission(): ?Submission + { + switch ($this->depositType) { + case OrcidDepositType::WORK: + $publication = Repo::publication()->get($this->itemId); + return Repo::submission()->get($publication->getData('submissionId')); + case OrcidDepositType::REVIEW: + $reviewAssigment = Repo::reviewAssignment()->get($this->itemId); + return Repo::submission()->get($reviewAssigment->getSubmissionId()); + } + + return null; + } + + /** + * Provides a unique ID for the job to ensure only one job is dispatched per User/Author at a time. + */ + public function uniqueId(): string + { + return $this->identity->getId(); + } +} diff --git a/locale/en/emails.po b/locale/en/emails.po index 9b704a8bf5a..9a148012a04 100644 --- a/locale/en/emails.po +++ b/locale/en/emails.po @@ -540,6 +540,43 @@ msgstr "" msgid "emails.orcidRequestAuthorAuthorization.description" msgstr "This email is sent to request ORCID record access from authors." +msgid "emails.orcidRequestUpdateScope.description" +msgstr "This email is sent to request member API OAuth scope for ORCID." + +msgid "emails.orcidRequestUpdateScope.subject" +msgstr "Requesting updated ORCID record access" + +msgid "emails.orcidRequestUpdateScope.body" +msgstr "" +"Dear {$recipientName},
\n" +"
\n" +"You are listed as a contributor (author or reviewer) on the manuscript submission \"" +"{$submissionTitle}\" to {$contextName}.\n" +"
\n" +"
\n" +"You have previously authorized {$contextName} to list your ORCID id on the site, " +"and we require updateded permissions to add your contribution to your ORCID profile.
\n" +"Visit the link to the official ORCID website, login with your profile and " +"authorize the access by following the instructions.
\n" +"
\n" +"" +"\"ORCIDRegister or Connect your ORCID " +"iD
\n" +"
\n" +"
\n" +"Click here to update your account with ORCID: {$authorOrcidUrl}.\n" +"
\n" +"
\n" +"More about ORCID at {$contextName}
\n" +"
\n" +"
\n" +"If you have any questions, please contact me.
\n" +"
\n" +"{$principalContactSignature}
\n" + msgid "emailTemplate.variable.authorOrcidUrl" msgstr "ORCID OAuth authorization link" @@ -550,6 +587,9 @@ msgstr "URL to the page about ORCID" msgid "orcid.orcidRequestAuthorAuthorization.name" msgstr "orcidRequestAuthorAuthorization" +msgid "orcid.orcidRequestUpdateScope.name" +msgstr "orcidRequestUpdateScope" + #, fuzzy msgid "orcid.orcidCollectAuthorId.name" msgstr "orcidCollectAuthorId" diff --git a/pages/orcid/OrcidHandler.php b/pages/orcid/OrcidHandler.php index ab16bf31679..ca00a841815 100644 --- a/pages/orcid/OrcidHandler.php +++ b/pages/orcid/OrcidHandler.php @@ -25,11 +25,15 @@ use PKP\config\Config; use PKP\core\Core; use PKP\core\PKPSessionGuard; +use PKP\identity\Identity; use PKP\orcid\actions\AuthorizeUserData; use PKP\orcid\actions\VerifyAuthorWithOrcid; +use PKP\orcid\actions\VerifyIdentityWithOrcid; +use PKP\orcid\enums\OrcidDepositType; use PKP\orcid\OrcidManager; use PKP\security\authorization\PKPSiteAccessPolicy; use PKP\security\authorization\UserRequiredPolicy; +use PKP\user\User; class OrcidHandler extends Handler { @@ -42,7 +46,7 @@ public function authorize($request, &$args, $roleAssignments) // Authorize all requests $this->addPolicy(new PKPSiteAccessPolicy( $request, - ['verify', 'authorizeOrcid', 'about'], + ['verify', 'authorizeOrcid', 'about', 'updateScope'], PKPSiteAccessPolicy::SITE_ACCESS_ALL_ROLES )); @@ -96,7 +100,9 @@ public function verify(array $args, Request $request): void $this->handleUserDeniedAccess($author, $templateMgr, $request->getUserVar('error_description')); } - (new VerifyAuthorWithOrcid($author, $request))->execute()->updateTemplateMgrVars($templateMgr); + (new VerifyIdentityWithOrcid($author, $request, OrcidDepositType::WORK)) + ->execute() + ->updateTemplateMgrVars($templateMgr); $templateMgr->display(self::VERIFY_TEMPLATE_PATH); } @@ -129,6 +135,51 @@ public function about(array $args, Request $request): void } + /** + * Displays page for completed OAuth process when the goal is to update the author's/user's OAuth scope and + * resubmit the ORCID item for deposit requiring the updated scope. + */ + public function updateScope(array $args, Request $request): void + { + // If the application is set to sandbox mode, it will not reach out to external services + if (Config::getVar('general', 'sandbox', false)) { + error_log('Application is set to sandbox mode and will not interact with the ORCID service'); + return; + } + + $templateMgr = TemplateManager::getManager($request); + + // Initialise template parameters + $templateMgr->assign([ + 'currentUrl' => $request->url(null, 'index'), + 'verifySuccess' => false, + 'authFailure' => false, + 'notPublished' => false, + 'sendSubmission' => false, + 'sendSubmissionSuccess' => false, + 'denied' => false, + 'contextName' => $request->getContext()->getName($request->getContext()->getPrimaryLocale()), + ]); + + $identity = $this->getIdentityToVerify($request); + + if ($identity === null) { + $this->handleNoAuthorWithToken($templateMgr); + } elseif ($request->getUserVar('error') === 'access_denied') { + // Handle access denied + $this->handleUserDeniedAccess($identity, $templateMgr, $request->getUserVar('error_description')); + } + + $depositType = OrcidDepositType::tryFrom($request->getUserVar('itemType')); + if ($depositType !== null) { + (new VerifyIdentityWithOrcid($identity, $request, $depositType)) + ->execute() + ->updateTemplateMgrVars($templateMgr); + } + + $templateMgr->display(self::VERIFY_TEMPLATE_PATH); + } + /** * Helper to retrieve author for which the ORCID verification was requested. */ @@ -165,19 +216,49 @@ private function handleNoAuthorWithToken(TemplateManager $templateMgr): void /** * Remove previously assigned ORCID OAuth related fields and assign denied variable to TemplateManagerA */ - private function handleUserDeniedAccess(Author $author, TemplateManager $templateMgr, string $errorDescription): void + private function handleUserDeniedAccess(Identity $identity, TemplateManager $templateMgr, string $errorDescription): void { // User denied access // Store the date time the author denied ORCID access to remember this - $author->setData('orcidAccessDenied', Core::getCurrentDate()); + $identity->setData('orcidAccessDenied', Core::getCurrentDate()); // remove all previously stored ORCID access token - $author->setData('orcidAccessToken', null); - $author->setData('orcidAccessScope', null); - $author->setData('orcidRefreshToken', null); - $author->setData('orcidAccessExpiresOn', null); - $author->setData('orcidEmailToken', null); - Repo::author()->dao->update($author); + $identity->setData('orcidAccessToken', null); + $identity->setData('orcidAccessScope', null); + $identity->setData('orcidRefreshToken', null); + $identity->setData('orcidAccessExpiresOn', null); + $identity->setData('orcidEmailToken', null); + + if ($identity instanceof Author) { + Repo::author()->dao->update($identity); + } else if ($identity instanceof User) { + Repo::user()->dao->update($identity); + } OrcidManager::logError('OrcidHandler::verify - ORCID access denied. Error description: ' . $errorDescription); $templateMgr->assign('denied', true); } + + private function getIdentityToVerify(Request $request): ?Identity + { + return match (OrcidDepositType::tryFrom($request->getUserVar('itemType'))) { + OrcidDepositType::WORK => $this->getAuthorToVerify($request), + OrcidDepositType::REVIEW => $this->getReviewerToVerify($request), + default => null, + }; + } + + private function getReviewerToVerify(Request $request): ?User + { + $user = null; + $userId = $request->getUserVar('userId'); + $user = Repo::user()->get($userId); + if ( + $user === null || + empty($request->getUserVar('token')) || + $user->getData('orcidEmailToken') != $request->getUserVar('token') + ) { + return null; + } + + return $user; + } } diff --git a/pages/orcid/index.php b/pages/orcid/index.php index 991b8293977..3a3efc00c72 100644 --- a/pages/orcid/index.php +++ b/pages/orcid/index.php @@ -21,5 +21,6 @@ case 'verify': case 'authorizeOrcid': case 'about': + case 'updateScope': return new \PKP\pages\orcid\OrcidHandler(); } diff --git a/schemas/user.json b/schemas/user.json index 7dc5d944f0a..89231b2592c 100644 --- a/schemas/user.json +++ b/schemas/user.json @@ -280,6 +280,13 @@ "nullable" ] }, + "orcidEmailToken": { + "type": "string", + "apiSummary": true, + "validation": [ + "nullable" + ] + }, "orcidIsVerified": { "type": "boolean", "apiSummary": true,