[CST-14905] Orcid revoke token feature

This commit is contained in:
Vincenzo Mecca
2024-07-24 19:07:44 +02:00
parent 086655712d
commit affa4b00ff
7 changed files with 201 additions and 17 deletions

View File

@@ -10,6 +10,7 @@ package org.dspace.orcid.client;
import java.util.List;
import java.util.Optional;
import org.dspace.orcid.OrcidToken;
import org.dspace.orcid.exception.OrcidClientException;
import org.dspace.orcid.model.OrcidTokenResponseDTO;
import org.orcid.jaxb.model.v3.release.record.Person;
@@ -161,4 +162,11 @@ public interface OrcidClient {
*/
OrcidResponse deleteByPutCode(String accessToken, String orcid, String putCode, String path);
/**
* Revokes the given {@param accessToken} with a POST method.
* @param orcidToken the access token to revoke
* @throws OrcidClientException if some error occurs during the search
*/
void revokeToken(OrcidToken orcidToken);
}

View File

@@ -42,6 +42,7 @@ import org.apache.http.client.methods.RequestBuilder;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.message.BasicNameValuePair;
import org.dspace.orcid.OrcidToken;
import org.dspace.orcid.exception.OrcidClientException;
import org.dspace.orcid.model.OrcidEntityType;
import org.dspace.orcid.model.OrcidProfileSectionType;
@@ -178,6 +179,16 @@ public class OrcidClientImpl implements OrcidClient {
return execute(buildDeleteUriRequest(accessToken, "/" + orcid + path + "/" + putCode), true);
}
@Override
public void revokeToken(OrcidToken orcidToken) {
List<NameValuePair> params = new ArrayList<>();
params.add(new BasicNameValuePair("client_id", orcidConfiguration.getClientId()));
params.add(new BasicNameValuePair("client_secret", orcidConfiguration.getClientSecret()));
params.add(new BasicNameValuePair("token", orcidToken.getAccessToken()));
executeSuccessful(buildPostForRevokeToken(new UrlEncodedFormEntity(params, Charset.defaultCharset())));
}
@Override
public OrcidTokenResponseDTO getReadPublicAccessToken() {
return getClientCredentialsAccessToken("/read-public");
@@ -220,6 +231,14 @@ public class OrcidClientImpl implements OrcidClient {
.build();
}
private HttpUriRequest buildPostForRevokeToken(HttpEntity entity) {
return post(orcidConfiguration.getRevokeUrl())
.addHeader("Accept", "application/json")
.addHeader("Content-Type", "application/x-www-form-urlencoded")
.setEntity(entity)
.build();
}
private HttpUriRequest buildPutUriRequest(String accessToken, String relativePath, Object object) {
return put(orcidConfiguration.getApiUrl() + relativePath.trim())
.addHeader("Content-Type", "application/vnd.orcid+xml")
@@ -234,6 +253,24 @@ public class OrcidClientImpl implements OrcidClient {
.build();
}
private void executeSuccessful(HttpUriRequest httpUriRequest) {
try {
HttpClient client = HttpClientBuilder.create().build();
HttpResponse response = client.execute(httpUriRequest);
if (isNotSuccessfull(response)) {
throw new OrcidClientException(
getStatusCode(response),
"Operation " + httpUriRequest.getMethod() + " for the resource " + httpUriRequest.getURI() +
" was not successful: " + new String(response.getEntity().getContent().readAllBytes(),
StandardCharsets.UTF_8)
);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private <T> T executeAndParseJson(HttpUriRequest httpUriRequest, Class<T> clazz) {
HttpClient client = HttpClientBuilder.create().build();

View File

@@ -35,6 +35,8 @@ public final class OrcidConfiguration {
private String scopes;
private String revokeUrl;
public String getApiUrl() {
return apiUrl;
}
@@ -111,4 +113,11 @@ public final class OrcidConfiguration {
return !StringUtils.isAnyBlank(clientId, clientSecret);
}
public String getRevokeUrl() {
return revokeUrl;
}
public void setRevokeUrl(String revokeUrl) {
this.revokeUrl = revokeUrl;
}
}

View File

@@ -37,6 +37,7 @@ import org.dspace.discovery.indexobject.IndexableItem;
import org.dspace.eperson.EPerson;
import org.dspace.eperson.service.EPersonService;
import org.dspace.orcid.OrcidToken;
import org.dspace.orcid.client.OrcidClient;
import org.dspace.orcid.model.OrcidEntityType;
import org.dspace.orcid.model.OrcidTokenResponseDTO;
import org.dspace.orcid.service.OrcidSynchronizationService;
@@ -47,6 +48,8 @@ import org.dspace.profile.OrcidProfileSyncPreference;
import org.dspace.profile.OrcidSynchronizationMode;
import org.dspace.profile.service.ResearcherProfileService;
import org.dspace.services.ConfigurationService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
/**
@@ -57,6 +60,7 @@ import org.springframework.beans.factory.annotation.Autowired;
*/
public class OrcidSynchronizationServiceImpl implements OrcidSynchronizationService {
private static final Logger log = LoggerFactory.getLogger(OrcidSynchronizationServiceImpl.class);
@Autowired
private ItemService itemService;
@@ -75,6 +79,9 @@ public class OrcidSynchronizationServiceImpl implements OrcidSynchronizationServ
@Autowired
private ResearcherProfileService researcherProfileService;
@Autowired
private OrcidClient orcidClient;
@Override
public void linkProfile(Context context, Item profile, OrcidTokenResponseDTO token) throws SQLException {
@@ -118,7 +125,14 @@ public class OrcidSynchronizationServiceImpl implements OrcidSynchronizationServ
itemService.clearMetadata(context, profile, "dspace", "orcid", "scope", Item.ANY);
itemService.clearMetadata(context, profile, "dspace", "orcid", "authenticated", Item.ANY);
OrcidToken profileToken = orcidTokenService.findByProfileItem(context, profile);
if (profileToken == null) {
log.warn("Cannot find any token related to the user profile: {}", profile.getID());
return;
}
orcidTokenService.deleteByProfileItem(context, profile);
orcidClient.revokeToken(profileToken);
updateItem(context, profile);

View File

@@ -30,7 +30,10 @@ import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.notNullValue;
import static org.hamcrest.Matchers.nullValue;
import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
@@ -44,6 +47,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.lang.reflect.Field;
import java.sql.SQLException;
import java.util.List;
import java.util.UUID;
@@ -79,11 +83,13 @@ import org.dspace.orcid.client.OrcidClient;
import org.dspace.orcid.exception.OrcidClientException;
import org.dspace.orcid.model.OrcidTokenResponseDTO;
import org.dspace.orcid.service.OrcidQueueService;
import org.dspace.orcid.service.OrcidSynchronizationService;
import org.dspace.orcid.service.OrcidTokenService;
import org.dspace.services.ConfigurationService;
import org.dspace.util.UUIDUtils;
import org.junit.After;
import org.junit.Test;
import org.mockito.Mock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MvcResult;
@@ -114,7 +120,11 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra
@Autowired
private OrcidClient orcidClient;
private OrcidClient orcidClientMock = mock(OrcidClient.class);
@Mock
private OrcidClient orcidClientMock;
@Autowired
private OrcidSynchronizationService orcidSynchronizationService;
private EPerson user;
@@ -158,16 +168,36 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra
context.restoreAuthSystemState();
researcherProfileAddOrcidOperation.setOrcidClient(orcidClientMock);
useInstanceForBean(orcidSynchronizationService, orcidClientMock);
useInstanceForBean(researcherProfileAddOrcidOperation, orcidClientMock);
}
@After
public void after() {
orcidTokenService.deleteAll(context);
researcherProfileAddOrcidOperation.setOrcidClient(orcidClient);
useInstanceForBean(orcidSynchronizationService, orcidClient);
useInstanceForBean(researcherProfileAddOrcidOperation, orcidClient);
}
private <B, I> void useInstanceForBean(B bean, I instance) {
Field[] fields = bean.getClass().getDeclaredFields();
for (Field field : fields) {
if (field.getType().isAssignableFrom(instance.getClass())) {
boolean accessible = field.isAccessible();
try {
field.setAccessible(true);
field.set(bean, instance);
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
} finally {
field.setAccessible(accessible);
}
}
}
}
/**
* Verify that the findById endpoint returns the own profile.
*
@@ -608,30 +638,38 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra
.withOrcidAuthenticated("authenticated")
.build();
String id = user.getID().toString();
String authToken = getAuthToken(user.getEmail(), password);
context.restoreAuthSystemState();
getClient(authToken).perform(get("/api/eperson/profiles/{id}", id))
String id = user.getID().toString();
String authToken = getAuthToken(user.getEmail(), password);
OrcidToken orcidToken = orcidTokenService.findByProfileItem(context, profileItem);
getClient(authToken)
.perform(get("/api/eperson/profiles/{id}", id))
.andExpect(status().isOk());
assertThat(profileItem.getMetadata(), hasItem(with("person.identifier.orcid", "0000-1111-2222-3333")));
assertThat(profileItem.getMetadata(), hasItem(with("dspace.orcid.authenticated", "authenticated")));
assertThat(getOrcidAccessToken(profileItem), notNullValue());
assertThat(orcidToken.getAccessToken(), notNullValue());
getClient(authToken).perform(get("/api/eperson/profiles/{id}/item", id))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasJsonPath("$.metadata", matchMetadataNotEmpty("dspace.object.owner"))));
getClient(authToken).perform(delete("/api/eperson/profiles/{id}", id))
.andExpect(status().isNoContent());
verify(orcidClientMock, times(1)).revokeToken(matchesToken(orcidToken));
verifyNoMoreInteractions(orcidClientMock);
profileItem = context.reloadEntity(profileItem);
orcidToken = orcidTokenService.findByProfileItem(context, profileItem);
assertThat(profileItem.getMetadata(), not(hasItem(with("person.identifier.orcid", "0000-1111-2222-3333"))));
assertThat(profileItem.getMetadata(), not(hasItem(with("dspace.orcid.authenticated", "authenticated"))));
assertThat(getOrcidAccessToken(profileItem), nullValue());
assertThat(orcidToken, nullValue());
}
@@ -1850,7 +1888,8 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra
.withNameInMetadata("Test", "User")
.build();
OrcidTokenBuilder.create(context, ePerson, "3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4").build();
OrcidToken orcidToken =
OrcidTokenBuilder.create(context, ePerson, "3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4").build();
Item profile = createProfile(ePerson);
@@ -1872,6 +1911,9 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra
.andExpect(jsonPath("$.orcid").doesNotExist())
.andExpect(jsonPath("$.orcidSynchronization").doesNotExist());
verify(orcidClientMock, times(1)).revokeToken(matchesToken(orcidToken));
verifyNoMoreInteractions(orcidClientMock);
profile = context.reloadEntity(profile);
assertThat(getMetadataValues(profile, "person.identifier.orcid"), empty());
@@ -1880,6 +1922,54 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra
assertThat(getOrcidAccessToken(profile), nullValue());
}
@Test
public void testPatchToDisconnectProfileFromOrcidDoesntRevokeOrcidToken() 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")
.withOrcidScope("/read")
.withOrcidScope("/write")
.withEmail("test@email.it")
.withPassword(password)
.withNameInMetadata("Test", "User")
.build();
OrcidToken orcidToken =
OrcidTokenBuilder.create(context, ePerson, "3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4").build();
Item profile = createProfile(ePerson);
assertThat(getMetadataValues(profile, "person.identifier.orcid"), not(empty()));
assertThat(getMetadataValues(profile, "dspace.orcid.scope"), not(empty()));
assertThat(getMetadataValues(profile, "dspace.orcid.authenticated"), not(empty()));
assertThat(getOrcidAccessToken(profile), is("3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4"));
context.restoreAuthSystemState();
doThrow(new OrcidClientException(403, "")).when(orcidClientMock).revokeToken(any(OrcidToken.class));
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().isInternalServerError());
verify(orcidClientMock, times(1)).revokeToken(matchesToken(orcidToken));
verifyNoMoreInteractions(orcidClientMock);
profile = context.reloadEntity(profile);
assertThat(getMetadataValues(profile, "person.identifier.orcid"), not(empty()));
assertThat(getMetadataValues(profile, "dspace.orcid.scope"), not(empty()));
assertThat(getMetadataValues(profile, "dspace.orcid.authenticated"), not(empty()));
assertThat(getOrcidAccessToken(profile), is("3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4"));
}
@Test
public void testAdminPatchToDisconnectProfileFromOrcidWithOnlyOwnerConfiguration() throws Exception {
@@ -2023,7 +2113,8 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra
.withNameInMetadata("Test", "User")
.build();
OrcidTokenBuilder.create(context, ePerson, "3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4").build();
OrcidToken orcidToken =
OrcidTokenBuilder.create(context, ePerson, "3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4").build();
Item profile = createProfile(ePerson);
@@ -2034,6 +2125,7 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra
context.restoreAuthSystemState();
getClient(getAuthToken(admin.getEmail(), password))
.perform(patch("/api/eperson/profiles/{id}", ePerson.getID().toString())
.content(getPatchContent(asList(new RemoveOperation("/orcid"))))
@@ -2045,6 +2137,9 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra
.andExpect(jsonPath("$.orcid").doesNotExist())
.andExpect(jsonPath("$.orcidSynchronization").doesNotExist());
verify(orcidClientMock, times(1)).revokeToken(matchesToken(orcidToken));
verifyNoMoreInteractions(orcidClientMock);
profile = context.reloadEntity(profile);
assertThat(getMetadataValues(profile, "person.identifier.orcid"), empty());
@@ -2111,17 +2206,18 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra
.withPassword(password)
.withNameInMetadata("Test", "User")
.build();
OrcidTokenBuilder.create(context, ePerson, "3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4").build();
OrcidToken orcidToken =
OrcidTokenBuilder.create(context, ePerson, "3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4").build();
Item profile = createProfile(ePerson);
context.restoreAuthSystemState();
assertThat(getMetadataValues(profile, "person.identifier.orcid"), not(empty()));
assertThat(getMetadataValues(profile, "dspace.orcid.scope"), not(empty()));
assertThat(getMetadataValues(profile, "dspace.orcid.authenticated"), not(empty()));
assertThat(getOrcidAccessToken(profile), is("3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4"));
context.restoreAuthSystemState();
getClient(getAuthToken(ePerson.getEmail(), password))
.perform(patch("/api/eperson/profiles/{id}", ePerson.getID().toString())
@@ -2134,6 +2230,9 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra
.andExpect(jsonPath("$.orcid").doesNotExist())
.andExpect(jsonPath("$.orcidSynchronization").doesNotExist());
verify(orcidClientMock, times(1)).revokeToken(matchesToken(orcidToken));
verifyNoMoreInteractions(orcidClientMock);
profile = context.reloadEntity(profile);
assertThat(getMetadataValues(profile, "person.identifier.orcid"), empty());
@@ -2159,7 +2258,8 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra
.withNameInMetadata("Test", "User")
.build();
OrcidTokenBuilder.create(context, ePerson, "3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4").build();
OrcidToken orcidToken =
OrcidTokenBuilder.create(context, ePerson, "3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4").build();
Item profile = createProfile(ePerson);
@@ -2170,6 +2270,7 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra
context.restoreAuthSystemState();
getClient(getAuthToken(admin.getEmail(), password))
.perform(patch("/api/eperson/profiles/{id}", ePerson.getID().toString())
.content(getPatchContent(asList(new RemoveOperation("/orcid"))))
@@ -2181,6 +2282,10 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra
.andExpect(jsonPath("$.orcid").doesNotExist())
.andExpect(jsonPath("$.orcidSynchronization").doesNotExist());
verify(orcidClientMock, times(1)).revokeToken(matchesToken(orcidToken));
verifyNoMoreInteractions(orcidClientMock);
profile = context.reloadEntity(profile);
assertThat(getMetadataValues(profile, "person.identifier.orcid"), empty());
@@ -2627,4 +2732,13 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra
return token;
}
private static OrcidToken matchesToken(OrcidToken orcidToken) {
return argThat(
token ->
token != null &&
orcidToken.getAccessToken().equals(token.getAccessToken()) &&
orcidToken.getID().equals(token.getID())
);
}
}

View File

@@ -14,6 +14,7 @@ orcid.disconnection.allowed-users = admin_and_owner
# ORCID API (https://github.com/ORCID/ORCID-Source/tree/master/orcid-api-web#endpoints)
orcid.domain-url= https://sandbox.orcid.org
orcid.authorize-url = ${orcid.domain-url}/oauth/authorize
orcid.revoke-url = ${orcid.domain-url}/oauth/revoke
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

View File

@@ -30,6 +30,7 @@
<property name="tokenEndpointUrl" value="${orcid.token-url}" />
<property name="authorizeEndpointUrl" value="${orcid.authorize-url}" />
<property name="scopes" value="${orcid.scope}" />
<property name="revokeUrl" value="${orcid.revoke-url}" />
</bean>
<bean class="org.dspace.orcid.client.OrcidClientImpl" />