diff --git a/dspace-api/src/main/java/org/dspace/app/orcid/service/OrcidSynchronizationService.java b/dspace-api/src/main/java/org/dspace/app/orcid/service/OrcidSynchronizationService.java index ca22a77027..d32ad688b8 100644 --- a/dspace-api/src/main/java/org/dspace/app/orcid/service/OrcidSynchronizationService.java +++ b/dspace-api/src/main/java/org/dspace/app/orcid/service/OrcidSynchronizationService.java @@ -10,6 +10,7 @@ package org.dspace.app.orcid.service; import java.sql.SQLException; import org.dspace.app.orcid.model.OrcidTokenResponseDTO; +import org.dspace.app.profile.OrcidProfileDisconnectionMode; import org.dspace.content.Item; import org.dspace.core.Context; @@ -33,4 +34,20 @@ public interface OrcidSynchronizationService { */ public void linkProfile(Context context, Item profile, OrcidTokenResponseDTO token) throws SQLException; + /** + * Disconnect the given profile from ORCID. + * + * @param context the relevant DSpace Context. + * @param profile the profile to disconnect + * @throws SQLException if a SQL error occurs during the profile update + */ + public void unlinkProfile(Context context, Item profile) throws SQLException; + + /** + * Returns the configuration ORCID profile's disconnection mode. If that mode is + * not configured or the configuration is wrong, the value DISABLED is returned. + * + * @return the disconnection mode + */ + OrcidProfileDisconnectionMode getDisconnectionMode(); } diff --git a/dspace-api/src/main/java/org/dspace/app/orcid/service/impl/OrcidSynchronizationServiceImpl.java b/dspace-api/src/main/java/org/dspace/app/orcid/service/impl/OrcidSynchronizationServiceImpl.java index 5ed533af3a..528aae3620 100644 --- a/dspace-api/src/main/java/org/dspace/app/orcid/service/impl/OrcidSynchronizationServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/app/orcid/service/impl/OrcidSynchronizationServiceImpl.java @@ -16,6 +16,7 @@ import java.util.stream.Stream; import org.dspace.app.orcid.model.OrcidTokenResponseDTO; import org.dspace.app.orcid.service.OrcidSynchronizationService; +import org.dspace.app.profile.OrcidProfileDisconnectionMode; import org.dspace.authorize.AuthorizeException; import org.dspace.content.Item; import org.dspace.content.MetadataValue; @@ -75,6 +76,27 @@ public class OrcidSynchronizationServiceImpl implements OrcidSynchronizationServ } + @Override + public void unlinkProfile(Context context, Item profile) throws SQLException { + + itemService.clearMetadata(context, profile, "person", "identifier", "orcid", Item.ANY); + itemService.clearMetadata(context, profile, "dspace", "orcid", "access-token", Item.ANY); + itemService.clearMetadata(context, profile, "dspace", "orcid", "refresh-token", Item.ANY); + itemService.clearMetadata(context, profile, "dspace", "orcid", "scope", Item.ANY); + itemService.clearMetadata(context, profile, "dspace", "orcid", "authenticated", Item.ANY); + updateItem(context, profile); + + } + + @Override + public OrcidProfileDisconnectionMode getDisconnectionMode() { + String value = configurationService.getProperty("orcid.disconnection.allowed-users"); + if (!OrcidProfileDisconnectionMode.isValid(value)) { + return OrcidProfileDisconnectionMode.DISABLED; + } + return OrcidProfileDisconnectionMode.fromString(value); + } + private Stream getMetadataValues(Item item, String metadataField) { return item.getMetadata().stream() .filter(metadata -> metadataField.equals(metadata.getMetadataField().toString('.'))); diff --git a/dspace-api/src/main/java/org/dspace/app/profile/OrcidMetadataCopyingAction.java b/dspace-api/src/main/java/org/dspace/app/profile/OrcidMetadataCopyingAction.java new file mode 100644 index 0000000000..36a9172d8e --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/app/profile/OrcidMetadataCopyingAction.java @@ -0,0 +1,90 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.app.profile; + +import static java.time.LocalDateTime.now; +import static java.time.format.DateTimeFormatter.ISO_DATE_TIME; +import static org.apache.commons.collections.CollectionUtils.isNotEmpty; +import static org.dspace.content.Item.ANY; + +import java.sql.SQLException; +import java.util.List; +import java.util.stream.Collectors; + +import org.apache.commons.collections.CollectionUtils; +import org.dspace.app.profile.service.AfterResearcherProfileCreationAction; +import org.dspace.content.Item; +import org.dspace.content.MetadataFieldName; +import org.dspace.content.MetadataValue; +import org.dspace.content.service.ItemService; +import org.dspace.core.Context; +import org.dspace.eperson.EPerson; +import org.dspace.eperson.service.EPersonService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; + +/** + * Implementation of {@link AfterResearcherProfileCreationAction} that copy the + * ORCID metadata, if any, from the owner to the researcher profile item. + * + * @author Luca Giamminonni (luca.giamminonni at 4science.it) + * + */ +@Order(Ordered.HIGHEST_PRECEDENCE) +public class OrcidMetadataCopyingAction implements AfterResearcherProfileCreationAction { + + @Autowired + private ItemService itemService; + + @Autowired + private EPersonService ePersonService; + + @Override + public void perform(Context context, ResearcherProfile researcherProfile, EPerson owner) throws SQLException { + + Item item = researcherProfile.getItem(); + + copyMetadataValues(context, owner, "eperson.orcid", item, "person.identifier.orcid"); + copyMetadataValues(context, owner, "eperson.orcid.access-token", item, "dspace.orcid.access-token"); + copyMetadataValues(context, owner, "eperson.orcid.refresh-token", item, "dspace.orcid.refresh-token"); + copyMetadataValues(context, owner, "eperson.orcid.scope", item, "dspace.orcid.scope"); + + if (isLinkedToOrcid(owner)) { + String currentDate = ISO_DATE_TIME.format(now()); + itemService.setMetadataSingleValue(context, item, "dspace", "orcid", "authenticated", null, currentDate); + } + + } + + private void copyMetadataValues(Context context, EPerson ePerson, String ePersonMetadataField, Item item, + String itemMetadataField) throws SQLException { + + List values = getMetadataValues(ePerson, ePersonMetadataField); + if (CollectionUtils.isEmpty(values)) { + return; + } + + MetadataFieldName metadata = new MetadataFieldName(itemMetadataField); + itemService.clearMetadata(context, item, metadata.schema, metadata.element, metadata.qualifier, ANY); + itemService.addMetadata(context, item, metadata.schema, metadata.element, metadata.qualifier, null, values); + + } + + private boolean isLinkedToOrcid(EPerson ePerson) { + return isNotEmpty(getMetadataValues(ePerson, "eperson.orcid")) + && isNotEmpty(getMetadataValues(ePerson, "eperson.orcid.access-token")); + } + + private List getMetadataValues(EPerson ePerson, String metadataField) { + return ePersonService.getMetadataByMetadataString(ePerson, metadataField).stream() + .map(MetadataValue::getValue) + .collect(Collectors.toList()); + } + +} diff --git a/dspace-api/src/main/java/org/dspace/app/profile/OrcidProfileDisconnectionMode.java b/dspace-api/src/main/java/org/dspace/app/profile/OrcidProfileDisconnectionMode.java new file mode 100644 index 0000000000..e53ba13f5a --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/app/profile/OrcidProfileDisconnectionMode.java @@ -0,0 +1,49 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.app.profile; + +import static org.apache.commons.lang3.EnumUtils.isValidEnum; + +/** + * Enum that models all the available values of the property that which + * determines which users can disconnect a profile from an ORCID account. + * + * @author Luca Giamminonni (luca.giamminonni at 4science.it) + * + */ +public enum OrcidProfileDisconnectionMode { + + /** + * The disconnection is disabled. + */ + DISABLED, + + /** + * Only the profile's owner can disconnect that profile from ORCID. + */ + ONLY_OWNER, + + /** + * Only the admins can disconnect profiles from ORCID. + */ + ONLY_ADMIN, + + /** + * Only the admin or the profile's owner can disconnect that profile from ORCID. + */ + ADMIN_AND_OWNER; + + public static boolean isValid(String mode) { + return mode != null ? isValidEnum(OrcidProfileDisconnectionMode.class, mode.toUpperCase()) : false; + } + + public static OrcidProfileDisconnectionMode fromString(String mode) { + return isValid(mode) ? OrcidProfileDisconnectionMode.valueOf(mode.toUpperCase()) : null; + } + +} diff --git a/dspace-api/src/main/java/org/dspace/app/profile/ResearcherProfileServiceImpl.java b/dspace-api/src/main/java/org/dspace/app/profile/ResearcherProfileServiceImpl.java index 75c1db45b0..19f167260d 100644 --- a/dspace-api/src/main/java/org/dspace/app/profile/ResearcherProfileServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/app/profile/ResearcherProfileServiceImpl.java @@ -15,14 +15,17 @@ import java.io.IOException; import java.net.URI; import java.sql.SQLException; import java.util.Arrays; +import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Objects; import java.util.UUID; +import javax.annotation.PostConstruct; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang.StringUtils; import org.dspace.app.exception.ResourceConflictException; +import org.dspace.app.profile.service.AfterResearcherProfileCreationAction; import org.dspace.app.profile.service.ResearcherProfileService; import org.dspace.authorize.AuthorizeException; import org.dspace.authorize.service.AuthorizeService; @@ -85,6 +88,18 @@ public class ResearcherProfileServiceImpl implements ResearcherProfileService { @Autowired private AuthorizeService authorizeService; + @Autowired(required = false) + private List afterCreationActions; + + @PostConstruct + public void postConstruct() { + + if (afterCreationActions == null) { + afterCreationActions = Collections.emptyList(); + } + + } + @Override public ResearcherProfile findById(Context context, UUID id) throws SQLException, AuthorizeException { Assert.notNull(id, "An id must be provided to find a researcher profile"); @@ -118,6 +133,10 @@ public class ResearcherProfileServiceImpl implements ResearcherProfileService { ResearcherProfile researcherProfile = new ResearcherProfile(item); + for (AfterResearcherProfileCreationAction afterCreationAction : afterCreationActions) { + afterCreationAction.perform(context, researcherProfile, ePerson); + } + return researcherProfile; } diff --git a/dspace-api/src/main/java/org/dspace/app/profile/service/AfterResearcherProfileCreationAction.java b/dspace-api/src/main/java/org/dspace/app/profile/service/AfterResearcherProfileCreationAction.java new file mode 100644 index 0000000000..3e8a4f394c --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/app/profile/service/AfterResearcherProfileCreationAction.java @@ -0,0 +1,35 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.app.profile.service; + +import java.sql.SQLException; + +import org.dspace.app.profile.ResearcherProfile; +import org.dspace.core.Context; +import org.dspace.eperson.EPerson; + +/** + * Interface to mark classes that allow to perform additional logic on created + * researcher profile. + * + * @author Luca Giamminonni (luca.giamminonni at 4science.it) + * + */ +public interface AfterResearcherProfileCreationAction { + + /** + * Perform some actions on the given researcher profile and returns the updated + * profile. + * + * @param context the DSpace context + * @param researcherProfile the created researcher profile + * @param owner the EPerson that is owner of the given profile + * @throws SQLException if a SQL error occurs + */ + void perform(Context context, ResearcherProfile researcherProfile, EPerson owner) throws SQLException; +} diff --git a/dspace-api/src/test/java/org/dspace/builder/EPersonBuilder.java b/dspace-api/src/test/java/org/dspace/builder/EPersonBuilder.java index c6c1efd461..c85d5aeb78 100644 --- a/dspace-api/src/test/java/org/dspace/builder/EPersonBuilder.java +++ b/dspace-api/src/test/java/org/dspace/builder/EPersonBuilder.java @@ -129,6 +129,26 @@ public class EPersonBuilder extends AbstractDSpaceObjectBuilder { return this; } + public EPersonBuilder withOrcid(final String orcid) { + setMetadataSingleValue(ePerson, "eperson", "orcid", null, orcid); + return this; + } + + public EPersonBuilder withOrcidAccessToken(final String accessToken) { + setMetadataSingleValue(ePerson, "eperson", "orcid", "access-token", accessToken); + return this; + } + + public EPersonBuilder withOrcidRefreshToken(final String refreshToken) { + setMetadataSingleValue(ePerson, "eperson", "orcid", "refresh-token", refreshToken); + return this; + } + + public EPersonBuilder withOrcidScope(final String scope) { + addMetadataValue(ePerson, "eperson", "orcid", "scope", scope); + return this; + } + public static void deleteEPerson(UUID uuid) throws SQLException, IOException { try (Context c = new Context()) { c.turnOffAuthorisationSystem(); diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/patch/operation/ResearcherProfileRemoveOrcidOperation.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/patch/operation/ResearcherProfileRemoveOrcidOperation.java new file mode 100644 index 0000000000..63f6a67247 --- /dev/null +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/patch/operation/ResearcherProfileRemoveOrcidOperation.java @@ -0,0 +1,106 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.app.rest.repository.patch.operation; + +import static org.dspace.app.profile.OrcidProfileDisconnectionMode.ADMIN_AND_OWNER; +import static org.dspace.app.profile.OrcidProfileDisconnectionMode.DISABLED; +import static org.dspace.app.profile.OrcidProfileDisconnectionMode.ONLY_ADMIN; +import static org.dspace.app.profile.OrcidProfileDisconnectionMode.ONLY_OWNER; + +import java.sql.SQLException; + +import org.dspace.app.orcid.service.OrcidSynchronizationService; +import org.dspace.app.profile.OrcidProfileDisconnectionMode; +import org.dspace.app.profile.ResearcherProfile; +import org.dspace.app.profile.service.ResearcherProfileService; +import org.dspace.app.rest.exception.RESTAuthorizationException; +import org.dspace.app.rest.model.patch.Operation; +import org.dspace.authorize.AuthorizeException; +import org.dspace.authorize.service.AuthorizeService; +import org.dspace.core.Context; +import org.dspace.eperson.EPerson; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * Implementation for ResearcherProfile ORCID disconnection. + * + * Example:
+ * curl -X PATCH http://${dspace.server.url}/api/cris/profiles/<:id-eperson> -H " + * Content-Type: application/json" -d '[{ "op": "remove", "path": "/orcid" }]' + *
+ */ +@Component +public class ResearcherProfileRemoveOrcidOperation extends PatchOperation { + + private static final String OPERATION_ORCID = "/orcid"; + + @Autowired + private ResearcherProfileService profileService; + + @Autowired + private OrcidSynchronizationService synchronizationService; + + @Autowired + private AuthorizeService authorizeService; + + @Override + public ResearcherProfile perform(Context context, ResearcherProfile profile, Operation operation) + throws SQLException { + + checkProfileDisconnectionPermissions(context, profile); + + synchronizationService.unlinkProfile(context, profile.getItem()); + + try { + return profileService.findById(context, profile.getId()); + } catch (AuthorizeException e) { + throw new RESTAuthorizationException(e); + } + + } + + private void checkProfileDisconnectionPermissions(Context context, ResearcherProfile profile) throws SQLException { + + OrcidProfileDisconnectionMode mode = synchronizationService.getDisconnectionMode(); + + if (mode == ADMIN_AND_OWNER) { + return; + } + + if (mode == DISABLED) { + throw new RESTAuthorizationException("Profile disconnection from ORCID is disabled"); + } + + if (mode == ONLY_OWNER && isNotOwner(context, profile)) { + throw new RESTAuthorizationException("Only the profile's owner can perform the ORCID disconnection"); + } + + if (mode == ONLY_ADMIN && isNotAdmin(context)) { + throw new RESTAuthorizationException("Only admins can perform the profile disconnection from ORCID"); + } + + } + + private boolean isNotAdmin(Context context) throws SQLException { + return !authorizeService.isAdmin(context); + } + + private boolean isNotOwner(Context context, ResearcherProfile profile) { + EPerson currentUser = context.getCurrentUser(); + return currentUser == null || !currentUser.getID().equals(profile.getId()); + } + + @Override + public boolean supports(Object objectToMatch, Operation operation) { + return objectToMatch instanceof ResearcherProfile + && operation.getOp().trim().equalsIgnoreCase(OPERATION_REMOVE) + && operation.getPath().trim().toLowerCase().startsWith(OPERATION_ORCID); + } + +} diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ResearcherProfileRestRepositoryIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ResearcherProfileRestRepositoryIT.java index 3d03a09912..5b16f3a0f4 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ResearcherProfileRestRepositoryIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ResearcherProfileRestRepositoryIT.java @@ -15,7 +15,10 @@ import static org.dspace.app.rest.matcher.HalMatcher.matchLinks; import static org.dspace.app.rest.matcher.MetadataMatcher.matchMetadata; import static org.dspace.app.rest.matcher.MetadataMatcher.matchMetadataDoesNotExist; import static org.dspace.app.rest.matcher.MetadataMatcher.matchMetadataNotEmpty; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; import static org.junit.Assert.assertEquals; import static org.springframework.data.rest.webmvc.RestMediaTypes.TEXT_URI_LIST; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; @@ -26,6 +29,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.io.UnsupportedEncodingException; +import java.sql.SQLException; import java.util.List; import java.util.UUID; import java.util.concurrent.atomic.AtomicReference; @@ -34,6 +38,7 @@ import com.jayway.jsonpath.JsonPath; import org.dspace.app.rest.model.MetadataValueRest; import org.dspace.app.rest.model.patch.AddOperation; import org.dspace.app.rest.model.patch.Operation; +import org.dspace.app.rest.model.patch.RemoveOperation; import org.dspace.app.rest.model.patch.ReplaceOperation; import org.dspace.app.rest.repository.ResearcherProfileRestRepository; import org.dspace.app.rest.test.AbstractControllerIntegrationTest; @@ -43,6 +48,8 @@ import org.dspace.builder.EPersonBuilder; import org.dspace.builder.ItemBuilder; import org.dspace.content.Collection; import org.dspace.content.Item; +import org.dspace.content.MetadataValue; +import org.dspace.content.service.ItemService; import org.dspace.eperson.EPerson; import org.dspace.services.ConfigurationService; import org.junit.Test; @@ -61,6 +68,9 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra @Autowired private ConfigurationService configurationService; + @Autowired + private ItemService itemService; + private EPerson user; private EPerson anotherUser; @@ -1035,15 +1045,525 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra .andExpect(status().isConflict()); } - private String getItemIdByProfileId(String token, String id) throws Exception { - MvcResult result = getClient(token).perform(get("/api/eperson/profiles/{id}/item", id)) + @Test + public void testOwnerPatchToDisconnectProfileFromOrcidWithDisabledConfiguration() throws Exception { + + configurationService.setProperty("orcid.disconnection.allowed-users", "disabled"); + + context.turnOffAuthorisationSystem(); + + EPerson ePerson = EPersonBuilder.createEPerson(context) + .withCanLogin(true) + .withOrcid("0000-1111-2222-3333") + .withOrcidAccessToken("3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4") + .withOrcidRefreshToken("6b29a03d-f494-4690-889f-2c0ddf26b82d") + .withOrcidScope("/read") + .withOrcidScope("/write") + .withEmail("test@email.it") + .withPassword(password) + .withNameInMetadata("Test", "User") + .build(); + + Item profile = createProfile(ePerson); + + context.restoreAuthSystemState(); + + getClient(getAuthToken(ePerson.getEmail(), password)) + .perform(patch("/api/eperson/profiles/{id}", ePerson.getID().toString()) + .content(getPatchContent(asList(new RemoveOperation("/orcid")))) + .contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isForbidden()); + + profile = context.reloadEntity(profile); + + assertThat(getMetadataValues(profile, "person.identifier.orcid"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.access-token"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.refresh-token"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.scope"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.authenticated"), not(empty())); + } + + @Test + public void testAdminPatchToDisconnectProfileFromOrcidWithDisabledConfiguration() throws Exception { + + configurationService.setProperty("orcid.disconnection.allowed-users", null); + + context.turnOffAuthorisationSystem(); + + EPerson ePerson = EPersonBuilder.createEPerson(context) + .withCanLogin(true) + .withOrcid("0000-1111-2222-3333") + .withOrcidAccessToken("3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4") + .withOrcidRefreshToken("6b29a03d-f494-4690-889f-2c0ddf26b82d") + .withOrcidScope("/read") + .withOrcidScope("/write") + .withEmail("test@email.it") + .withPassword(password) + .withNameInMetadata("Test", "User") + .build(); + + Item profile = createProfile(ePerson); + + context.restoreAuthSystemState(); + + getClient(getAuthToken(admin.getEmail(), password)) + .perform(patch("/api/eperson/profiles/{id}", ePerson.getID().toString()) + .content(getPatchContent(asList(new RemoveOperation("/orcid")))) + .contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isForbidden()); + + profile = context.reloadEntity(profile); + + assertThat(getMetadataValues(profile, "person.identifier.orcid"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.access-token"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.refresh-token"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.scope"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.authenticated"), not(empty())); + } + + @Test + public void testAnotherUserPatchToDisconnectProfileFromOrcidWithDisabledConfiguration() throws Exception { + + configurationService.setProperty("orcid.disconnection.allowed-users", ""); + + context.turnOffAuthorisationSystem(); + + EPerson ePerson = EPersonBuilder.createEPerson(context) + .withCanLogin(true) + .withOrcid("0000-1111-2222-3333") + .withOrcidAccessToken("3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4") + .withOrcidRefreshToken("6b29a03d-f494-4690-889f-2c0ddf26b82d") + .withOrcidScope("/read") + .withOrcidScope("/write") + .withEmail("test@email.it") + .withPassword(password) + .withNameInMetadata("Test", "User") + .build(); + + EPerson anotherUser = EPersonBuilder.createEPerson(context) + .withCanLogin(true) + .withEmail("user@email.it") + .withPassword(password) + .withNameInMetadata("Another", "User") + .build(); + + Item profile = createProfile(ePerson); + + context.restoreAuthSystemState(); + + getClient(getAuthToken(anotherUser.getEmail(), password)) + .perform(patch("/api/eperson/profiles/{id}", ePerson.getID().toString()) + .content(getPatchContent(asList(new RemoveOperation("/orcid")))) + .contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isForbidden()); + + profile = context.reloadEntity(profile); + + assertThat(getMetadataValues(profile, "person.identifier.orcid"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.access-token"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.refresh-token"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.scope"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.authenticated"), not(empty())); + } + + @Test + public void testOwnerPatchToDisconnectProfileFromOrcidWithOnlyOwnerConfiguration() throws Exception { + + configurationService.setProperty("orcid.disconnection.allowed-users", "only_owner"); + + context.turnOffAuthorisationSystem(); + + EPerson ePerson = EPersonBuilder.createEPerson(context) + .withCanLogin(true) + .withOrcid("0000-1111-2222-3333") + .withOrcidAccessToken("3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4") + .withOrcidRefreshToken("6b29a03d-f494-4690-889f-2c0ddf26b82d") + .withOrcidScope("/read") + .withOrcidScope("/write") + .withEmail("test@email.it") + .withPassword(password) + .withNameInMetadata("Test", "User") + .build(); + + Item profile = createProfile(ePerson); + + context.restoreAuthSystemState(); + + getClient(getAuthToken(ePerson.getEmail(), password)) + .perform(patch("/api/eperson/profiles/{id}", ePerson.getID().toString()) + .content(getPatchContent(asList(new RemoveOperation("/orcid")))) + .contentType(MediaType.APPLICATION_JSON_VALUE)) .andExpect(status().isOk()) - .andReturn(); + .andExpect(jsonPath("$.id", is(ePerson.getID().toString()))) + .andExpect(jsonPath("$.visible", is(false))) + .andExpect(jsonPath("$.type", is("profile"))) + .andExpect(jsonPath("$.orcid").doesNotExist()) + .andExpect(jsonPath("$.orcidSynchronization").doesNotExist()); + + profile = context.reloadEntity(profile); + + assertThat(getMetadataValues(profile, "person.identifier.orcid"), empty()); + assertThat(getMetadataValues(profile, "dspace.orcid.access-token"), empty()); + assertThat(getMetadataValues(profile, "dspace.orcid.refresh-token"), empty()); + assertThat(getMetadataValues(profile, "dspace.orcid.scope"), empty()); + assertThat(getMetadataValues(profile, "dspace.orcid.authenticated"), empty()); + } + + @Test + public void testAdminPatchToDisconnectProfileFromOrcidWithOnlyOwnerConfiguration() throws Exception { + + configurationService.setProperty("orcid.disconnection.allowed-users", "only_owner"); + + context.turnOffAuthorisationSystem(); + + EPerson ePerson = EPersonBuilder.createEPerson(context) + .withCanLogin(true) + .withOrcid("0000-1111-2222-3333") + .withOrcidAccessToken("3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4") + .withOrcidRefreshToken("6b29a03d-f494-4690-889f-2c0ddf26b82d") + .withOrcidScope("/read") + .withOrcidScope("/write") + .withEmail("test@email.it") + .withPassword(password) + .withNameInMetadata("Test", "User") + .build(); + + Item profile = createProfile(ePerson); + + context.restoreAuthSystemState(); + + getClient(getAuthToken(admin.getEmail(), password)) + .perform(patch("/api/eperson/profiles/{id}", ePerson.getID().toString()) + .content(getPatchContent(asList(new RemoveOperation("/orcid")))) + .contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isForbidden()); + + profile = context.reloadEntity(profile); + + assertThat(getMetadataValues(profile, "person.identifier.orcid"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.access-token"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.refresh-token"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.scope"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.authenticated"), not(empty())); + } + + @Test + public void testAnotherUserPatchToDisconnectProfileFromOrcidWithOnlyOwnerConfiguration() throws Exception { + + configurationService.setProperty("orcid.disconnection.allowed-users", "admin_and_owner"); + + context.turnOffAuthorisationSystem(); + + EPerson ePerson = EPersonBuilder.createEPerson(context) + .withCanLogin(true) + .withOrcid("0000-1111-2222-3333") + .withOrcidAccessToken("3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4") + .withOrcidRefreshToken("6b29a03d-f494-4690-889f-2c0ddf26b82d") + .withOrcidScope("/read") + .withOrcidScope("/write") + .withEmail("test@email.it") + .withPassword(password) + .withNameInMetadata("Test", "User") + .build(); + + Item profile = createProfile(ePerson); + + context.restoreAuthSystemState(); + + getClient(getAuthToken(anotherUser.getEmail(), password)) + .perform(patch("/api/eperson/profiles/{id}", ePerson.getID().toString()) + .content(getPatchContent(asList(new RemoveOperation("/orcid")))) + .contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isForbidden()); + + profile = context.reloadEntity(profile); + + assertThat(getMetadataValues(profile, "person.identifier.orcid"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.access-token"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.refresh-token"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.scope"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.authenticated"), not(empty())); + } + + @Test + public void testOwnerPatchToDisconnectProfileFromOrcidWithOnlyAdminConfiguration() throws Exception { + + configurationService.setProperty("orcid.disconnection.allowed-users", "only_admin"); + + context.turnOffAuthorisationSystem(); + + EPerson ePerson = EPersonBuilder.createEPerson(context) + .withCanLogin(true) + .withOrcid("0000-1111-2222-3333") + .withOrcidAccessToken("3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4") + .withOrcidRefreshToken("6b29a03d-f494-4690-889f-2c0ddf26b82d") + .withOrcidScope("/read") + .withOrcidScope("/write") + .withEmail("test@email.it") + .withPassword(password) + .withNameInMetadata("Test", "User") + .build(); + + Item profile = createProfile(ePerson); + + context.restoreAuthSystemState(); + + getClient(getAuthToken(ePerson.getEmail(), password)) + .perform(patch("/api/eperson/profiles/{id}", ePerson.getID().toString()) + .content(getPatchContent(asList(new RemoveOperation("/orcid")))) + .contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isForbidden()); + + profile = context.reloadEntity(profile); + + assertThat(getMetadataValues(profile, "person.identifier.orcid"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.access-token"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.refresh-token"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.scope"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.authenticated"), not(empty())); + } + + @Test + public void testAdminPatchToDisconnectProfileFromOrcidWithOnlyAdminConfiguration() throws Exception { + + configurationService.setProperty("orcid.disconnection.allowed-users", "only_admin"); + + context.turnOffAuthorisationSystem(); + + EPerson ePerson = EPersonBuilder.createEPerson(context) + .withCanLogin(true) + .withOrcid("0000-1111-2222-3333") + .withOrcidAccessToken("3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4") + .withOrcidRefreshToken("6b29a03d-f494-4690-889f-2c0ddf26b82d") + .withOrcidScope("/read") + .withOrcidScope("/write") + .withEmail("test@email.it") + .withPassword(password) + .withNameInMetadata("Test", "User") + .build(); + + Item profile = createProfile(ePerson); + + context.restoreAuthSystemState(); + + getClient(getAuthToken(admin.getEmail(), password)) + .perform(patch("/api/eperson/profiles/{id}", ePerson.getID().toString()) + .content(getPatchContent(asList(new RemoveOperation("/orcid")))) + .contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", is(ePerson.getID().toString()))) + .andExpect(jsonPath("$.visible", is(false))) + .andExpect(jsonPath("$.type", is("profile"))) + .andExpect(jsonPath("$.orcid").doesNotExist()) + .andExpect(jsonPath("$.orcidSynchronization").doesNotExist()); + + profile = context.reloadEntity(profile); + + assertThat(getMetadataValues(profile, "person.identifier.orcid"), empty()); + assertThat(getMetadataValues(profile, "dspace.orcid.access-token"), empty()); + assertThat(getMetadataValues(profile, "dspace.orcid.refresh-token"), empty()); + assertThat(getMetadataValues(profile, "dspace.orcid.scope"), empty()); + assertThat(getMetadataValues(profile, "dspace.orcid.authenticated"), empty()); + } + + @Test + public void testAnotherUserPatchToDisconnectProfileFromOrcidWithOnlyAdminConfiguration() throws Exception { + + configurationService.setProperty("orcid.disconnection.allowed-users", "only_admin"); + + context.turnOffAuthorisationSystem(); + + EPerson ePerson = EPersonBuilder.createEPerson(context) + .withCanLogin(true) + .withOrcid("0000-1111-2222-3333") + .withOrcidAccessToken("3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4") + .withOrcidRefreshToken("6b29a03d-f494-4690-889f-2c0ddf26b82d") + .withOrcidScope("/read") + .withOrcidScope("/write") + .withEmail("test@email.it") + .withPassword(password) + .withNameInMetadata("Test", "User") + .build(); + + Item profile = createProfile(ePerson); + + context.restoreAuthSystemState(); + + getClient(getAuthToken(anotherUser.getEmail(), password)) + .perform(patch("/api/eperson/profiles/{id}", ePerson.getID().toString()) + .content(getPatchContent(asList(new RemoveOperation("/orcid")))) + .contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isForbidden()); + + profile = context.reloadEntity(profile); + + assertThat(getMetadataValues(profile, "person.identifier.orcid"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.access-token"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.refresh-token"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.scope"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.authenticated"), not(empty())); + } + + @Test + public void testOwnerPatchToDisconnectProfileFromOrcidWithAdminAndOwnerConfiguration() throws Exception { + + configurationService.setProperty("orcid.disconnection.allowed-users", "admin_and_owner"); + + context.turnOffAuthorisationSystem(); + + EPerson ePerson = EPersonBuilder.createEPerson(context) + .withCanLogin(true) + .withOrcid("0000-1111-2222-3333") + .withOrcidAccessToken("3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4") + .withOrcidRefreshToken("6b29a03d-f494-4690-889f-2c0ddf26b82d") + .withOrcidScope("/read") + .withOrcidScope("/write") + .withEmail("test@email.it") + .withPassword(password) + .withNameInMetadata("Test", "User") + .build(); + + Item profile = createProfile(ePerson); + + context.restoreAuthSystemState(); + + getClient(getAuthToken(ePerson.getEmail(), password)) + .perform(patch("/api/eperson/profiles/{id}", ePerson.getID().toString()) + .content(getPatchContent(asList(new RemoveOperation("/orcid")))) + .contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", is(ePerson.getID().toString()))) + .andExpect(jsonPath("$.visible", is(false))) + .andExpect(jsonPath("$.type", is("profile"))) + .andExpect(jsonPath("$.orcid").doesNotExist()) + .andExpect(jsonPath("$.orcidSynchronization").doesNotExist()); + + profile = context.reloadEntity(profile); + + assertThat(getMetadataValues(profile, "person.identifier.orcid"), empty()); + assertThat(getMetadataValues(profile, "dspace.orcid.access-token"), empty()); + assertThat(getMetadataValues(profile, "dspace.orcid.refresh-token"), empty()); + assertThat(getMetadataValues(profile, "dspace.orcid.scope"), empty()); + assertThat(getMetadataValues(profile, "dspace.orcid.authenticated"), empty()); + } + + @Test + public void testAdminPatchToDisconnectProfileFromOrcidWithAdminAndOwnerConfiguration() throws Exception { + + configurationService.setProperty("orcid.disconnection.allowed-users", "admin_and_owner"); + + context.turnOffAuthorisationSystem(); + + EPerson ePerson = EPersonBuilder.createEPerson(context) + .withCanLogin(true) + .withOrcid("0000-1111-2222-3333") + .withOrcidAccessToken("3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4") + .withOrcidRefreshToken("6b29a03d-f494-4690-889f-2c0ddf26b82d") + .withOrcidScope("/read") + .withOrcidScope("/write") + .withEmail("test@email.it") + .withPassword(password) + .withNameInMetadata("Test", "User") + .build(); + + Item profile = createProfile(ePerson); + + context.restoreAuthSystemState(); + + getClient(getAuthToken(admin.getEmail(), password)) + .perform(patch("/api/eperson/profiles/{id}", ePerson.getID().toString()) + .content(getPatchContent(asList(new RemoveOperation("/orcid")))) + .contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", is(ePerson.getID().toString()))) + .andExpect(jsonPath("$.visible", is(false))) + .andExpect(jsonPath("$.type", is("profile"))) + .andExpect(jsonPath("$.orcid").doesNotExist()) + .andExpect(jsonPath("$.orcidSynchronization").doesNotExist()); + + profile = context.reloadEntity(profile); + + assertThat(getMetadataValues(profile, "person.identifier.orcid"), empty()); + assertThat(getMetadataValues(profile, "dspace.orcid.access-token"), empty()); + assertThat(getMetadataValues(profile, "dspace.orcid.refresh-token"), empty()); + assertThat(getMetadataValues(profile, "dspace.orcid.scope"), empty()); + assertThat(getMetadataValues(profile, "dspace.orcid.authenticated"), empty()); + } + + @Test + public void testAnotherUserPatchToDisconnectProfileFromOrcidWithAdminAndOwnerConfiguration() throws Exception { + + configurationService.setProperty("orcid.disconnection.allowed-users", "admin_and_owner"); + + context.turnOffAuthorisationSystem(); + + EPerson ePerson = EPersonBuilder.createEPerson(context) + .withCanLogin(true) + .withOrcid("0000-1111-2222-3333") + .withOrcidAccessToken("3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4") + .withOrcidRefreshToken("6b29a03d-f494-4690-889f-2c0ddf26b82d") + .withOrcidScope("/read") + .withOrcidScope("/write") + .withEmail("test@email.it") + .withPassword(password) + .withNameInMetadata("Test", "User") + .build(); + + Item profile = createProfile(ePerson); + + context.restoreAuthSystemState(); + + getClient(getAuthToken(anotherUser.getEmail(), password)) + .perform(patch("/api/eperson/profiles/{id}", ePerson.getID().toString()) + .content(getPatchContent(asList(new RemoveOperation("/orcid")))) + .contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isForbidden()); + + profile = context.reloadEntity(profile); + + assertThat(getMetadataValues(profile, "person.identifier.orcid"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.access-token"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.refresh-token"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.scope"), not(empty())); + assertThat(getMetadataValues(profile, "dspace.orcid.authenticated"), not(empty())); + } + + private Item createProfile(EPerson ePerson) throws Exception { + + String authToken = getAuthToken(ePerson.getEmail(), password); + + AtomicReference ePersonIdRef = new AtomicReference(); + AtomicReference itemIdRef = new AtomicReference(); + + getClient(authToken).perform(post("/api/eperson/profiles/") + .contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isCreated()) + .andDo(result -> ePersonIdRef.set(fromString(read(result.getResponse().getContentAsString(), + "$.id")))); + + getClient(authToken).perform(get("/api/eperson/profiles/{id}/item", ePersonIdRef.get()) + .contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()) + .andDo(result -> itemIdRef.set(fromString(read(result.getResponse().getContentAsString(), + "$.id")))); + + return itemService.find(context, itemIdRef.get()); + } + + private String getItemIdByProfileId(String token, String id) throws SQLException, Exception { + MvcResult result = getClient(token).perform(get("/api/eperson/profiles/{id}/item", id)) + .andExpect(status().isOk()) + .andReturn(); return readAttributeFromResponse(result, "$.id"); } + private List getMetadataValues(Item item, String metadataField) { + return itemService.getMetadataByMetadataString(item, metadataField); + } + private T readAttributeFromResponse(MvcResult result, String attribute) throws UnsupportedEncodingException { return JsonPath.read(result.getResponse().getContentAsString(), attribute); } + } diff --git a/dspace/config/dspace.cfg b/dspace/config/dspace.cfg index 0a837c56f8..228423fe1c 100644 --- a/dspace/config/dspace.cfg +++ b/dspace/config/dspace.cfg @@ -1586,21 +1586,6 @@ request.item.helpdesk.override = false # ${dspace.dir}/config/ directory. module_dir = modules -#------------------------------------------------------------------# -#--------------------ORCID CONFIGURATIONS--------------------------# -#------------------------------------------------------------------# - -orcid.domain-url= https://sandbox.orcid.org -orcid.authorize-url = ${orcid.domain-url}/oauth/authorize -orcid.token-url = ${orcid.domain-url}/oauth/token -orcid.api-url = https://api.sandbox.orcid.org/v3.0 -orcid.redirect-url = ${dspace.server.url}/api/authn/orcid -orcid.application-client-id = -orcid.application-client-secret = -orcid.scope = /authenticate -orcid.scope = /read-limited -orcid.scope = /activities/update -orcid.scope = /person/update # Load default module configs # ---------------------------- @@ -1644,4 +1629,5 @@ include = ${module_dir}/translator.cfg include = ${module_dir}/usage-statistics.cfg include = ${module_dir}/versioning.cfg include = ${module_dir}/workflow.cfg -include = ${module_dir}/authority.cfg \ No newline at end of file +include = ${module_dir}/authority.cfg +include = ${module_dir}/orcid.cfg \ No newline at end of file diff --git a/dspace/config/modules/orcid.cfg b/dspace/config/modules/orcid.cfg new file mode 100644 index 0000000000..78bbf4311a --- /dev/null +++ b/dspace/config/modules/orcid.cfg @@ -0,0 +1,24 @@ +#------------------------------------------------------------------# +#--------------------ORCID GENERIC CONFIGURATIONS------------------# +#------------------------------------------------------------------# + +#Allowed values are disabled, only_admin, only_owner or admin_and_owner +orcid.disconnection.allowed-users = admin_and_owner + +#------------------------------------------------------------------# +#--------------------ORCID CLIENT CONFIGURATIONS-------------------# +#------------------------------------------------------------------# + +orcid.domain-url= https://sandbox.orcid.org +orcid.authorize-url = ${orcid.domain-url}/oauth/authorize +orcid.token-url = ${orcid.domain-url}/oauth/token +orcid.api-url = https://api.sandbox.orcid.org/v3.0 +orcid.public-url = https://pub.sandbox.orcid.org/v3.0 +orcid.redirect-url = ${dspace.server.url}/api/authn/orcid +orcid.webhook-url = https://api.sandbox.orcid.org/ +orcid.application-client-id = +orcid.application-client-secret = +orcid.scope = /authenticate +orcid.scope = /read-limited +orcid.scope = /activities/update +orcid.scope = /person/update diff --git a/dspace/config/spring/api/core-services.xml b/dspace/config/spring/api/core-services.xml index 5d5157273b..036ad54363 100644 --- a/dspace/config/spring/api/core-services.xml +++ b/dspace/config/spring/api/core-services.xml @@ -65,6 +65,8 @@ + +