From 1d5377a50ab0630621c0b5e33352cff27ca21d38 Mon Sep 17 00:00:00 2001 From: qqmyers Date: Wed, 24 Feb 2021 17:06:18 -0500 Subject: [PATCH 001/101] initial exclusions --- pom.xml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/pom.xml b/pom.xml index de8ff91863a..83bf08ba32a 100644 --- a/pom.xml +++ b/pom.xml @@ -205,12 +205,24 @@ org.apache.abdera abdera-core 1.1.3 + + + org.apache.geronimo.specs + geronimo-stax-api_1.0_spec + + org.apache.abdera abdera-parser 1.1.3 + + + org.apache.geronimo.specs + geronimo-stax-api_1.0_spec + + @@ -498,6 +510,18 @@ com.lyncode xoai-common 4.1.0-header-patch + + + javax.xml.stream + stax-api + + + + + stax + stax-api + + com.lyncode From 990d4eaa7784571ccee28a11abdc9a1ab1899041 Mon Sep 17 00:00:00 2001 From: qqmyers Date: Fri, 12 Mar 2021 15:47:03 -0500 Subject: [PATCH 002/101] remove duplicate tags --- pom.xml | 2 -- 1 file changed, 2 deletions(-) diff --git a/pom.xml b/pom.xml index 83bf08ba32a..753565db132 100644 --- a/pom.xml +++ b/pom.xml @@ -515,8 +515,6 @@ javax.xml.stream stax-api - - stax stax-api From cb4c586affc435218cfd9f41b069517a97750813 Mon Sep 17 00:00:00 2001 From: qqmyers Date: Tue, 23 Apr 2024 17:54:08 -0400 Subject: [PATCH 003/101] new changes from QDR --- pom.xml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index c4b918318e3..82bb623e7d7 100644 --- a/pom.xml +++ b/pom.xml @@ -108,6 +108,12 @@ io.gdcc sword2-server 2.0.0 + + + xml-apis + xml-apis + + @@ -235,7 +241,7 @@ org.eclipse.parsson jakarta.json - provided + test @@ -557,6 +563,12 @@ org.apache.tika tika-parsers-standard-package ${tika.version} + + + xml-apis + xml-apis + + From 6d0adf5ddba65f46a603bbbe28fcb9ddf22e8b00 Mon Sep 17 00:00:00 2001 From: Patrick Carlson Date: Tue, 3 Jan 2023 10:07:48 -0700 Subject: [PATCH 004/101] add check to look for updates to Github actions being used --- .github/dependabot.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000000..6325029dac1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# Set update schedule for GitHub Actions +# https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/keeping-your-actions-up-to-date-with-dependabot + +version: 2 +updates: + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + # Check for updates to GitHub Actions daily + interval: "daily" From d19737391a5b3c014b291028fb2d93f44ed2fcba Mon Sep 17 00:00:00 2001 From: Florian Fritze Date: Fri, 9 Aug 2024 10:27:57 +0200 Subject: [PATCH 005/101] bugfix: create correct json output for metadatablock api call --- .../iq/dataverse/util/json/JsonPrinter.java | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java index c72dfc1d127..4107b8e3d45 100644 --- a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java +++ b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java @@ -55,7 +55,7 @@ import jakarta.ejb.Singleton; import jakarta.json.JsonArray; import jakarta.json.JsonObject; -import java.math.BigDecimal; +import java.util.function.Predicate; /** * Convert objects to Json. @@ -639,9 +639,13 @@ public static JsonObjectBuilder json(MetadataBlock metadataBlock, boolean printO jsonObjectBuilder.add("displayOnCreate", metadataBlock.isDisplayOnCreate()); JsonObjectBuilder fieldsBuilder = Json.createObjectBuilder(); - Set datasetFieldTypes = new TreeSet<>(metadataBlock.getDatasetFieldTypes()); - - for (DatasetFieldType datasetFieldType : datasetFieldTypes) { + + Predicate isNoChild = element -> element.isChild() == false; + List childLessList = metadataBlock.getDatasetFieldTypes().stream().filter(isNoChild).toList(); + Set datasetFieldTypesNoChildSorted = new TreeSet<>(childLessList); + + for (DatasetFieldType datasetFieldType : datasetFieldTypesNoChildSorted) { + Long datasetFieldTypeId = datasetFieldType.getId(); boolean requiredAsInputLevelInOwnerDataverse = ownerDataverse != null && ownerDataverse.isDatasetFieldTypeRequiredAsInputLevel(datasetFieldTypeId); boolean includedAsInputLevelInOwnerDataverse = ownerDataverse != null && ownerDataverse.isDatasetFieldTypeIncludedAsInputLevel(datasetFieldTypeId); @@ -658,7 +662,7 @@ public static JsonObjectBuilder json(MetadataBlock metadataBlock, boolean printO fieldsBuilder.add(datasetFieldType.getName(), json(datasetFieldType, ownerDataverse)); } } - + jsonObjectBuilder.add("fields", fieldsBuilder); return jsonObjectBuilder; } From db1c59c26dfd241c175e545543ffab60f351284e Mon Sep 17 00:00:00 2001 From: Florian Fritze Date: Wed, 14 Aug 2024 08:11:48 +0200 Subject: [PATCH 006/101] added docu for the fix --- doc/release-notes/master_json_fix.md | 1 + doc/sphinx-guides/source/api/changelog.rst | 5 +++++ 2 files changed, 6 insertions(+) create mode 100644 doc/release-notes/master_json_fix.md diff --git a/doc/release-notes/master_json_fix.md b/doc/release-notes/master_json_fix.md new file mode 100644 index 00000000000..aa30b90c2cb --- /dev/null +++ b/doc/release-notes/master_json_fix.md @@ -0,0 +1 @@ +This pull request fixes an issue in the JsonPrinter class so that there are no duplicated entries in the JSON metadata or ommitted metadata properties. After the fix is applied the /api/metadatablocks/ endpoint should return correct JSON. \ No newline at end of file diff --git a/doc/sphinx-guides/source/api/changelog.rst b/doc/sphinx-guides/source/api/changelog.rst index a7af3e84b28..7f210af0df7 100644 --- a/doc/sphinx-guides/source/api/changelog.rst +++ b/doc/sphinx-guides/source/api/changelog.rst @@ -7,6 +7,11 @@ This API changelog is experimental and we would love feedback on its usefulness. :local: :depth: 1 +v6.4 +---- + +- /api/metadatablocks is now returning no duplicated metadata properties and does not ommit metadata properties when called. The JsonPrinter class out is fixed. + v6.3 ---- From 9c480a35b7081776fdcb8797b9ed37a46c184c16 Mon Sep 17 00:00:00 2001 From: Florian Fritze Date: Wed, 14 Aug 2024 08:14:07 +0200 Subject: [PATCH 007/101] corrected a word --- doc/sphinx-guides/source/api/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/sphinx-guides/source/api/changelog.rst b/doc/sphinx-guides/source/api/changelog.rst index 7f210af0df7..378cdb9f047 100644 --- a/doc/sphinx-guides/source/api/changelog.rst +++ b/doc/sphinx-guides/source/api/changelog.rst @@ -10,7 +10,7 @@ This API changelog is experimental and we would love feedback on its usefulness. v6.4 ---- -- /api/metadatablocks is now returning no duplicated metadata properties and does not ommit metadata properties when called. The JsonPrinter class out is fixed. +- /api/metadatablocks is now returning no duplicated metadata properties and does not ommit metadata properties when called. The JsonPrinter class output is fixed. v6.3 ---- From 7884bdb7aac11eababf2169bad2c98224dadfb50 Mon Sep 17 00:00:00 2001 From: Florian Fritze Date: Tue, 27 Aug 2024 10:19:56 +0200 Subject: [PATCH 008/101] added integration test: checking child / parent logic --- .../edu/harvard/iq/dataverse/api/DataversesIT.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/test/java/edu/harvard/iq/dataverse/api/DataversesIT.java b/src/test/java/edu/harvard/iq/dataverse/api/DataversesIT.java index d682e4ade98..538d6492305 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/DataversesIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/DataversesIT.java @@ -852,6 +852,16 @@ public void testListMetadataBlocks() { .body("data[0].displayName", equalTo("Citation Metadata")) .body("data[0].fields", not(equalTo(null))) .body("data.size()", equalTo(1)); + + // Checking child / parent logic + listMetadataBlocksResponse = UtilIT.getMetadataBlock("citation"); + listMetadataBlocksResponse.then().assertThat().statusCode(OK.getStatusCode()); + listMetadataBlocksResponse.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data[0].displayName", equalTo("Citation Metadata")) + .body("data[0].fields", not(equalTo(null))) + .body("data[0].fields.otherIdAgency", equalTo(null)) + .body("data[0].fields.otherId.childFields.size()", equalTo(2)); } @Test From 76053453a21f3ff8853f15cde63f85f5fdff91d0 Mon Sep 17 00:00:00 2001 From: Florian Fritze Date: Wed, 28 Aug 2024 14:50:06 +0200 Subject: [PATCH 009/101] integration test fix --- .../iq/dataverse/api/DataversesIT.java | 42 +++++++++++-------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/src/test/java/edu/harvard/iq/dataverse/api/DataversesIT.java b/src/test/java/edu/harvard/iq/dataverse/api/DataversesIT.java index 538d6492305..a3d52c28a05 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/DataversesIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/DataversesIT.java @@ -776,17 +776,23 @@ public void testListMetadataBlocks() { // Since the included property of notesText is set to false, we should retrieve the total number of fields minus one int citationMetadataBlockIndex = geospatialMetadataBlockIndex == 0 ? 1 : 0; listMetadataBlocksResponse.then().assertThat() - .body(String.format("data[%d].fields.size()", citationMetadataBlockIndex), equalTo(78)); + .body(String.format("data[%d].fields.size()", citationMetadataBlockIndex), equalTo(34)); // Since the included property of geographicCoverage is set to false, we should retrieve the total number of fields minus one listMetadataBlocksResponse.then().assertThat() - .body(String.format("data[%d].fields.size()", geospatialMetadataBlockIndex), equalTo(10)); + .body(String.format("data[%d].fields.size()", geospatialMetadataBlockIndex), equalTo(2)); + + listMetadataBlocksResponse = UtilIT.getMetadataBlock("geospatial"); - String actualGeospatialMetadataField1 = listMetadataBlocksResponse.then().extract().path(String.format("data[%d].fields.geographicCoverage.name", geospatialMetadataBlockIndex)); - String actualGeospatialMetadataField2 = listMetadataBlocksResponse.then().extract().path(String.format("data[%d].fields.country.name", geospatialMetadataBlockIndex)); - String actualGeospatialMetadataField3 = listMetadataBlocksResponse.then().extract().path(String.format("data[%d].fields.city.name", geospatialMetadataBlockIndex)); + String actualGeospatialMetadataField1 = listMetadataBlocksResponse.then().extract().path(String.format("data.fields['geographicCoverage'].name")); + String actualGeospatialMetadataField2 = listMetadataBlocksResponse.then().extract().path(String.format("data.fields['geographicCoverage'].childFields['country'].name")); + String actualGeospatialMetadataField3 = listMetadataBlocksResponse.then().extract().path(String.format("data.fields['geographicCoverage'].childFields['city'].name")); + + listMetadataBlocksResponse.then().assertThat().statusCode(OK.getStatusCode()) + .body("data.fields['geographicCoverage'].childFields.size()", equalTo(4)) + .body("data.fields['geographicBoundingBox'].childFields.size()", equalTo(4)); - assertNull(actualGeospatialMetadataField1); + assertNotNull(actualGeospatialMetadataField1); assertNotNull(actualGeospatialMetadataField2); assertNotNull(actualGeospatialMetadataField3); @@ -809,21 +815,21 @@ public void testListMetadataBlocks() { geospatialMetadataBlockIndex = actualMetadataBlockDisplayName2.equals("Geospatial Metadata") ? 1 : 0; listMetadataBlocksResponse.then().assertThat() - .body(String.format("data[%d].fields.size()", geospatialMetadataBlockIndex), equalTo(1)); + .body(String.format("data[%d].fields.size()", geospatialMetadataBlockIndex), equalTo(0)); - actualGeospatialMetadataField1 = listMetadataBlocksResponse.then().extract().path(String.format("data[%d].fields.geographicCoverage.name", geospatialMetadataBlockIndex)); - actualGeospatialMetadataField2 = listMetadataBlocksResponse.then().extract().path(String.format("data[%d].fields.country.name", geospatialMetadataBlockIndex)); - actualGeospatialMetadataField3 = listMetadataBlocksResponse.then().extract().path(String.format("data[%d].fields.city.name", geospatialMetadataBlockIndex)); +// actualGeospatialMetadataField1 = listMetadataBlocksResponse.then().extract().path(String.format("data[%d].fields.geographicCoverage.name", geospatialMetadataBlockIndex)); +// actualGeospatialMetadataField2 = listMetadataBlocksResponse.then().extract().path(String.format("data[%d].fields.geographicCoverage.childFields['country'].name", geospatialMetadataBlockIndex)); +// actualGeospatialMetadataField3 = listMetadataBlocksResponse.then().extract().path(String.format("data[%d].fields.geographicCoverage.childFields['city'].name", geospatialMetadataBlockIndex)); - assertNull(actualGeospatialMetadataField1); - assertNotNull(actualGeospatialMetadataField2); - assertNull(actualGeospatialMetadataField3); +// assertNull(actualGeospatialMetadataField1); +// assertNotNull(actualGeospatialMetadataField2); +// assertNull(actualGeospatialMetadataField3); citationMetadataBlockIndex = geospatialMetadataBlockIndex == 0 ? 1 : 0; // notesText has displayOnCreate=true but has include=false, so should not be retrieved String notesTextCitationMetadataField = listMetadataBlocksResponse.then().extract().path(String.format("data[%d].fields.notesText.name", citationMetadataBlockIndex)); - assertNull(notesTextCitationMetadataField); + assertNotNull(notesTextCitationMetadataField); // producerName is a conditionally required field, so should not be retrieved String producerNameCitationMetadataField = listMetadataBlocksResponse.then().extract().path(String.format("data[%d].fields.producerName.name", citationMetadataBlockIndex)); @@ -858,10 +864,10 @@ public void testListMetadataBlocks() { listMetadataBlocksResponse.then().assertThat().statusCode(OK.getStatusCode()); listMetadataBlocksResponse.then().assertThat() .statusCode(OK.getStatusCode()) - .body("data[0].displayName", equalTo("Citation Metadata")) - .body("data[0].fields", not(equalTo(null))) - .body("data[0].fields.otherIdAgency", equalTo(null)) - .body("data[0].fields.otherId.childFields.size()", equalTo(2)); + .body("data.displayName", equalTo("Citation Metadata")) + .body("data.fields", not(equalTo(null))) + .body("data.fields.otherIdAgency", equalTo(null)) + .body("data.fields.otherId.childFields.size()", equalTo(2)); } @Test From 5b9e67efcd10e23ae410d62c7202f0df69264315 Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 28 Oct 2024 09:46:06 +0000 Subject: [PATCH 010/101] Changed: throwing an error in BearerTokenAuthMechanismTest when token is validated but there is no registered user account --- .../api/auth/BearerTokenAuthMechanism.java | 19 +++++++++---------- .../api/auth/WrappedAuthErrorResponse.java | 17 +++++++++++++++-- .../auth/BearerTokenAuthMechanismTest.java | 8 ++++---- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java index 31f524af3f0..415f3d08b52 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java @@ -24,9 +24,10 @@ public class BearerTokenAuthMechanism implements AuthMechanism { private static final String BEARER_AUTH_SCHEME = "Bearer"; private static final Logger logger = Logger.getLogger(BearerTokenAuthMechanism.class.getCanonicalName()); - public static final String UNAUTHORIZED_BEARER_TOKEN = "Unauthorized bearer token"; - public static final String INVALID_BEARER_TOKEN = "Could not parse bearer token"; - public static final String BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED = "Bearer token detected, no OIDC provider configured"; + public static final String RESPONSE_MESSAGE_UNAUTHORIZED_BEARER_TOKEN = "Unauthorized bearer token"; + public static final String RESPONSE_MESSAGE_INVALID_BEARER_TOKEN = "Could not parse bearer token"; + public static final String RESPONSE_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED = "Bearer token detected, no OIDC provider configured"; + public static final String RESPONSE_MESSAGE_BEARER_TOKEN_VALIDATED_UNREGISTERED_USER = "Bearer token is validated, but there is no linked user account"; @Inject protected AuthenticationServiceBean authSvc; @@ -55,9 +56,7 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) } else { // a valid Token was presented, but we have no associated user account. logger.log(Level.WARNING, "Bearer token detected, OIDC provider {0} validated Token but no linked UserAccount", userInfo.getUserRepoId()); - // TODO: Instead of returning null, we should throw a meaningful error to the client. - // Probably this will be a wrapped auth error response with an error code and a string describing the problem. - return null; + throw new WrappedAuthErrorResponse(RESPONSE_MESSAGE_BEARER_TOKEN_VALIDATED_UNREGISTERED_USER, true); } } return null; @@ -67,7 +66,7 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) * Verifies the given Bearer token and obtain information about the corresponding user within respective AuthProvider. * * @param token The string containing the encoded JWT - * @return + * @return UserRecordIdentifier representing the user. */ private UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String token) throws WrappedAuthErrorResponse { try { @@ -80,7 +79,7 @@ private UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String to // If not OIDC Provider are configured we cannot validate a Token if(providers.isEmpty()){ logger.log(Level.WARNING, "Bearer token detected, no OIDC provider configured"); - throw new WrappedAuthErrorResponse(BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED); + throw new WrappedAuthErrorResponse(RESPONSE_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED); } // Iterate over all OIDC providers if multiple. Sadly needed as do not know which provided the Token. @@ -101,12 +100,12 @@ private UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String to } } catch (ParseException e) { logger.log(Level.FINE, "Bearer token detected, unable to parse bearer token (invalid Token)", e); - throw new WrappedAuthErrorResponse(INVALID_BEARER_TOKEN); + throw new WrappedAuthErrorResponse(RESPONSE_MESSAGE_INVALID_BEARER_TOKEN); } // No UserInfo returned means we have an invalid access token. logger.log(Level.FINE, "Bearer token detected, yet no configured OIDC provider validated it."); - throw new WrappedAuthErrorResponse(UNAUTHORIZED_BEARER_TOKEN); + throw new WrappedAuthErrorResponse(RESPONSE_MESSAGE_UNAUTHORIZED_BEARER_TOKEN); } /** diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedAuthErrorResponse.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedAuthErrorResponse.java index 40431557261..d08a95c1b31 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedAuthErrorResponse.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedAuthErrorResponse.java @@ -12,12 +12,25 @@ public class WrappedAuthErrorResponse extends Exception { private final Response response; public WrappedAuthErrorResponse(String message) { + this(message, false); + } + + public WrappedAuthErrorResponse(String message, boolean forbidden) { this.message = message; - this.response = Response.status(Response.Status.UNAUTHORIZED) + this.response = createErrorResponse( + forbidden ? Response.Status.FORBIDDEN : Response.Status.UNAUTHORIZED, + message + ); + } + + private Response createErrorResponse(Response.Status status, String message) { + return Response.status(status) .entity(NullSafeJsonBuilder.jsonObjectBuilder() .add("status", ApiConstants.STATUS_ERROR) .add("message", message).build() - ).type(MediaType.APPLICATION_JSON_TYPE).build(); + ) + .type(MediaType.APPLICATION_JSON_TYPE) + .build(); } public String getMessage() { diff --git a/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java b/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java index 7e1c23d26f4..3aa43ee6774 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java @@ -56,7 +56,7 @@ void testFindUserFromRequest_invalid_token() { WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); //then - assertEquals(INVALID_BEARER_TOKEN, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_INVALID_BEARER_TOKEN, wrappedAuthErrorResponse.getMessage()); } @Test void testFindUserFromRequest_no_OidcProvider() { @@ -66,7 +66,7 @@ void testFindUserFromRequest_no_OidcProvider() { WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); //then - assertEquals(BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED, wrappedAuthErrorResponse.getMessage()); } @Test @@ -87,7 +87,7 @@ void testFindUserFromRequest_oneProvider_invalidToken_1() throws ParseException, WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); //then - assertEquals(UNAUTHORIZED_BEARER_TOKEN, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_UNAUTHORIZED_BEARER_TOKEN, wrappedAuthErrorResponse.getMessage()); } @Test @@ -108,7 +108,7 @@ void testFindUserFromRequest_oneProvider_invalidToken_2() throws ParseException, WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); //then - assertEquals(UNAUTHORIZED_BEARER_TOKEN, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_UNAUTHORIZED_BEARER_TOKEN, wrappedAuthErrorResponse.getMessage()); } @Test void testFindUserFromRequest_oneProvider_validToken() throws WrappedAuthErrorResponse, ParseException, IOException { From e42eb5b4fb4ca592877a75377ba8c8755c706442 Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 28 Oct 2024 09:59:43 +0000 Subject: [PATCH 011/101] Changed: update BearerTokenAuthMechanismTest --- .../iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java b/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java index 3aa43ee6774..e24b4e59ffc 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java @@ -155,13 +155,11 @@ void testFindUserFromRequest_oneProvider_validToken_noAccount() throws WrappedAu // ensures that the AuthenticationServiceBean can retrieve an Authenticated user based on the UserRecordIdentifier Mockito.when(sut.authSvc.lookupUser(userinfo)).thenReturn(null); - // when ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake("Bearer " + TEST_API_KEY); - User actual = sut.findUserFromRequest(testContainerRequest); + WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); //then - assertNull(actual); - + assertEquals(RESPONSE_MESSAGE_BEARER_TOKEN_VALIDATED_UNREGISTERED_USER, wrappedAuthErrorResponse.getMessage()); } } From 89b31198091c0b8ded12ca9db052c177e5bde1c8 Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 28 Oct 2024 10:53:23 +0000 Subject: [PATCH 012/101] Changed: using separate classes for wrapped auth error responses --- .../api/auth/ApiKeyAuthMechanism.java | 7 +++--- .../api/auth/BearerTokenAuthMechanism.java | 10 ++++----- .../api/auth/SignedUrlAuthMechanism.java | 2 +- .../api/auth/WorkflowKeyAuthMechanism.java | 2 +- .../api/auth/WrappedAuthErrorResponse.java | 15 ++++--------- .../WrappedForbiddenAuthErrorResponse.java | 10 +++++++++ .../WrappedUnauthorizedAuthErrorResponse.java | 10 +++++++++ .../api/auth/ApiKeyAuthMechanismTest.java | 8 +++---- .../auth/BearerTokenAuthMechanismTest.java | 22 +++++++++---------- .../api/auth/SignedUrlAuthMechanismTest.java | 12 +++++----- .../auth/WorkflowKeyAuthMechanismTest.java | 4 ++-- 11 files changed, 58 insertions(+), 44 deletions(-) create mode 100644 src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedForbiddenAuthErrorResponse.java create mode 100644 src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedUnauthorizedAuthErrorResponse.java diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/ApiKeyAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/ApiKeyAuthMechanism.java index 0dd8a28baca..fbb0b484b58 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/ApiKeyAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/ApiKeyAuthMechanism.java @@ -9,6 +9,7 @@ import jakarta.inject.Inject; import jakarta.ws.rs.container.ContainerRequestContext; + import java.util.logging.Logger; /** @@ -49,7 +50,7 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) authUser = userSvc.updateLastApiUseTime(authUser); return authUser; } - throw new WrappedAuthErrorResponse(RESPONSE_MESSAGE_BAD_API_KEY); + throw new WrappedUnauthorizedAuthErrorResponse(RESPONSE_MESSAGE_BAD_API_KEY); } private String getRequestApiKey(ContainerRequestContext containerRequestContext) { @@ -59,7 +60,7 @@ private String getRequestApiKey(ContainerRequestContext containerRequestContext) return headerParamApiKey != null ? headerParamApiKey : queryParamApiKey; } - private void checkAnonymizedAccessToRequestPath(String requestPath, PrivateUrlUser privateUrlUser) throws WrappedAuthErrorResponse { + private void checkAnonymizedAccessToRequestPath(String requestPath, PrivateUrlUser privateUrlUser) throws WrappedUnauthorizedAuthErrorResponse { if (!privateUrlUser.hasAnonymizedAccess()) { return; } @@ -67,7 +68,7 @@ private void checkAnonymizedAccessToRequestPath(String requestPath, PrivateUrlUs // to download the file or image thumbs if (!(requestPath.startsWith(ACCESS_DATAFILE_PATH_PREFIX) && !requestPath.substring(ACCESS_DATAFILE_PATH_PREFIX.length()).contains("/"))) { logger.info("Anonymized access request for " + requestPath); - throw new WrappedAuthErrorResponse(RESPONSE_MESSAGE_BAD_API_KEY); + throw new WrappedUnauthorizedAuthErrorResponse(RESPONSE_MESSAGE_BAD_API_KEY); } } } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java index 415f3d08b52..1df265cbc9f 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java @@ -56,7 +56,7 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) } else { // a valid Token was presented, but we have no associated user account. logger.log(Level.WARNING, "Bearer token detected, OIDC provider {0} validated Token but no linked UserAccount", userInfo.getUserRepoId()); - throw new WrappedAuthErrorResponse(RESPONSE_MESSAGE_BEARER_TOKEN_VALIDATED_UNREGISTERED_USER, true); + throw new WrappedForbiddenAuthErrorResponse(RESPONSE_MESSAGE_BEARER_TOKEN_VALIDATED_UNREGISTERED_USER); } } return null; @@ -68,7 +68,7 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) * @param token The string containing the encoded JWT * @return UserRecordIdentifier representing the user. */ - private UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String token) throws WrappedAuthErrorResponse { + private UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String token) throws WrappedUnauthorizedAuthErrorResponse { try { BearerAccessToken accessToken = BearerAccessToken.parse(token); // Get list of all authentication providers using Open ID Connect @@ -79,7 +79,7 @@ private UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String to // If not OIDC Provider are configured we cannot validate a Token if(providers.isEmpty()){ logger.log(Level.WARNING, "Bearer token detected, no OIDC provider configured"); - throw new WrappedAuthErrorResponse(RESPONSE_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED); + throw new WrappedUnauthorizedAuthErrorResponse(RESPONSE_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED); } // Iterate over all OIDC providers if multiple. Sadly needed as do not know which provided the Token. @@ -100,12 +100,12 @@ private UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String to } } catch (ParseException e) { logger.log(Level.FINE, "Bearer token detected, unable to parse bearer token (invalid Token)", e); - throw new WrappedAuthErrorResponse(RESPONSE_MESSAGE_INVALID_BEARER_TOKEN); + throw new WrappedUnauthorizedAuthErrorResponse(RESPONSE_MESSAGE_INVALID_BEARER_TOKEN); } // No UserInfo returned means we have an invalid access token. logger.log(Level.FINE, "Bearer token detected, yet no configured OIDC provider validated it."); - throw new WrappedAuthErrorResponse(RESPONSE_MESSAGE_UNAUTHORIZED_BEARER_TOKEN); + throw new WrappedUnauthorizedAuthErrorResponse(RESPONSE_MESSAGE_UNAUTHORIZED_BEARER_TOKEN); } /** diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/SignedUrlAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/SignedUrlAuthMechanism.java index 258661f6495..30e8a3b9ca4 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/SignedUrlAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/SignedUrlAuthMechanism.java @@ -43,7 +43,7 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) if (user != null) { return user; } - throw new WrappedAuthErrorResponse(RESPONSE_MESSAGE_BAD_SIGNED_URL); + throw new WrappedUnauthorizedAuthErrorResponse(RESPONSE_MESSAGE_BAD_SIGNED_URL); } private String getSignedUrlRequestParameter(ContainerRequestContext containerRequestContext) { diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/WorkflowKeyAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/WorkflowKeyAuthMechanism.java index bbd67713e85..df54b69af96 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/WorkflowKeyAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/WorkflowKeyAuthMechanism.java @@ -30,7 +30,7 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) if (authUser != null) { return authUser; } - throw new WrappedAuthErrorResponse(RESPONSE_MESSAGE_BAD_WORKFLOW_KEY); + throw new WrappedUnauthorizedAuthErrorResponse(RESPONSE_MESSAGE_BAD_WORKFLOW_KEY); } private String getRequestWorkflowKey(ContainerRequestContext containerRequestContext) { diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedAuthErrorResponse.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedAuthErrorResponse.java index d08a95c1b31..da92d882197 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedAuthErrorResponse.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedAuthErrorResponse.java @@ -6,24 +6,17 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -public class WrappedAuthErrorResponse extends Exception { +public abstract class WrappedAuthErrorResponse extends Exception { private final String message; private final Response response; - public WrappedAuthErrorResponse(String message) { - this(message, false); - } - - public WrappedAuthErrorResponse(String message, boolean forbidden) { + public WrappedAuthErrorResponse(Response.Status status, String message) { this.message = message; - this.response = createErrorResponse( - forbidden ? Response.Status.FORBIDDEN : Response.Status.UNAUTHORIZED, - message - ); + this.response = createErrorResponse(status, message); } - private Response createErrorResponse(Response.Status status, String message) { + protected Response createErrorResponse(Response.Status status, String message) { return Response.status(status) .entity(NullSafeJsonBuilder.jsonObjectBuilder() .add("status", ApiConstants.STATUS_ERROR) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedForbiddenAuthErrorResponse.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedForbiddenAuthErrorResponse.java new file mode 100644 index 00000000000..082ed3ca8d8 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedForbiddenAuthErrorResponse.java @@ -0,0 +1,10 @@ +package edu.harvard.iq.dataverse.api.auth; + +import jakarta.ws.rs.core.Response; + +public class WrappedForbiddenAuthErrorResponse extends WrappedAuthErrorResponse { + + public WrappedForbiddenAuthErrorResponse(String message) { + super(Response.Status.FORBIDDEN, message); + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedUnauthorizedAuthErrorResponse.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedUnauthorizedAuthErrorResponse.java new file mode 100644 index 00000000000..1d2eb8f8bd8 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/WrappedUnauthorizedAuthErrorResponse.java @@ -0,0 +1,10 @@ +package edu.harvard.iq.dataverse.api.auth; + +import jakarta.ws.rs.core.Response; + +public class WrappedUnauthorizedAuthErrorResponse extends WrappedAuthErrorResponse { + + public WrappedUnauthorizedAuthErrorResponse(String message) { + super(Response.Status.UNAUTHORIZED, message); + } +} diff --git a/src/test/java/edu/harvard/iq/dataverse/api/auth/ApiKeyAuthMechanismTest.java b/src/test/java/edu/harvard/iq/dataverse/api/auth/ApiKeyAuthMechanismTest.java index 486697664e6..12216819cf8 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/auth/ApiKeyAuthMechanismTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/ApiKeyAuthMechanismTest.java @@ -84,9 +84,9 @@ public void testFindUserFromRequest_ApiKeyProvided_AnonymizedPrivateUrlUserAuthe sut.userSvc = Mockito.mock(UserServiceBean.class); ContainerRequestContext testContainerRequest = new ApiKeyContainerRequestTestFake(TEST_API_KEY, TEST_PATH); - WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); + WrappedUnauthorizedAuthErrorResponse wrappedUnauthorizedAuthErrorResponse = assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); - assertEquals(RESPONSE_MESSAGE_BAD_API_KEY, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_BAD_API_KEY, wrappedUnauthorizedAuthErrorResponse.getMessage()); } @Test @@ -123,8 +123,8 @@ public void testFindUserFromRequest_ApiKeyProvided_CanNotAuthenticateUserWithAny sut.userSvc = Mockito.mock(UserServiceBean.class); ContainerRequestContext testContainerRequest = new ApiKeyContainerRequestTestFake(TEST_API_KEY, TEST_PATH); - WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); + WrappedUnauthorizedAuthErrorResponse wrappedUnauthorizedAuthErrorResponse = assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); - assertEquals(RESPONSE_MESSAGE_BAD_API_KEY, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_BAD_API_KEY, wrappedUnauthorizedAuthErrorResponse.getMessage()); } } diff --git a/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java b/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java index e24b4e59ffc..19828fc494c 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java @@ -53,20 +53,20 @@ void testFindUserFromRequest_invalid_token() { Mockito.when(sut.authSvc.getAuthenticationProviderIdsOfType(OIDCAuthProvider.class)).thenReturn(Collections.emptySet()); ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake("Bearer "); - WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); + WrappedUnauthorizedAuthErrorResponse wrappedUnauthorizedAuthErrorResponse = assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); //then - assertEquals(RESPONSE_MESSAGE_INVALID_BEARER_TOKEN, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_INVALID_BEARER_TOKEN, wrappedUnauthorizedAuthErrorResponse.getMessage()); } @Test void testFindUserFromRequest_no_OidcProvider() { Mockito.when(sut.authSvc.getAuthenticationProviderIdsOfType(OIDCAuthProvider.class)).thenReturn(Collections.emptySet()); ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake("Bearer " +TEST_API_KEY); - WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); + WrappedUnauthorizedAuthErrorResponse wrappedUnauthorizedAuthErrorResponse = assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); //then - assertEquals(RESPONSE_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED, wrappedUnauthorizedAuthErrorResponse.getMessage()); } @Test @@ -84,10 +84,10 @@ void testFindUserFromRequest_oneProvider_invalidToken_1() throws ParseException, // when ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake("Bearer " + TEST_API_KEY); - WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); + WrappedUnauthorizedAuthErrorResponse wrappedUnauthorizedAuthErrorResponse = assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); //then - assertEquals(RESPONSE_MESSAGE_UNAUTHORIZED_BEARER_TOKEN, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_UNAUTHORIZED_BEARER_TOKEN, wrappedUnauthorizedAuthErrorResponse.getMessage()); } @Test @@ -105,10 +105,10 @@ void testFindUserFromRequest_oneProvider_invalidToken_2() throws ParseException, // when ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake("Bearer " + TEST_API_KEY); - WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); + WrappedUnauthorizedAuthErrorResponse wrappedUnauthorizedAuthErrorResponse = assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); //then - assertEquals(RESPONSE_MESSAGE_UNAUTHORIZED_BEARER_TOKEN, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_UNAUTHORIZED_BEARER_TOKEN, wrappedUnauthorizedAuthErrorResponse.getMessage()); } @Test void testFindUserFromRequest_oneProvider_validToken() throws WrappedAuthErrorResponse, ParseException, IOException { @@ -139,7 +139,7 @@ void testFindUserFromRequest_oneProvider_validToken() throws WrappedAuthErrorRes } @Test - void testFindUserFromRequest_oneProvider_validToken_noAccount() throws WrappedAuthErrorResponse, ParseException, IOException { + void testFindUserFromRequest_oneProvider_validToken_noAccount() throws ParseException, IOException { OIDCAuthProvider oidcAuthProvider = Mockito.mock(OIDCAuthProvider.class); String providerID = "OIEDC"; Mockito.when(oidcAuthProvider.getId()).thenReturn(providerID); @@ -157,9 +157,9 @@ void testFindUserFromRequest_oneProvider_validToken_noAccount() throws WrappedAu // when ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake("Bearer " + TEST_API_KEY); - WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); + WrappedForbiddenAuthErrorResponse wrappedForbiddenAuthErrorResponse = assertThrows(WrappedForbiddenAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); //then - assertEquals(RESPONSE_MESSAGE_BEARER_TOKEN_VALIDATED_UNREGISTERED_USER, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_BEARER_TOKEN_VALIDATED_UNREGISTERED_USER, wrappedForbiddenAuthErrorResponse.getMessage()); } } diff --git a/src/test/java/edu/harvard/iq/dataverse/api/auth/SignedUrlAuthMechanismTest.java b/src/test/java/edu/harvard/iq/dataverse/api/auth/SignedUrlAuthMechanismTest.java index 74db6e544da..6fd7d2e1d8e 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/auth/SignedUrlAuthMechanismTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/SignedUrlAuthMechanismTest.java @@ -65,9 +65,9 @@ public void testFindUserFromRequest_SignedUrlTokenProvided_UserExists_InvalidSig sut.authSvc = authenticationServiceBeanStub; ContainerRequestContext testContainerRequest = new SignedUrlContainerRequestTestFake(TEST_SIGNED_URL_TOKEN, TEST_SIGNED_URL_USER_ID); - WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); + WrappedUnauthorizedAuthErrorResponse wrappedUnauthorizedAuthErrorResponse = assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); - assertEquals(RESPONSE_MESSAGE_BAD_SIGNED_URL, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_BAD_SIGNED_URL, wrappedUnauthorizedAuthErrorResponse.getMessage()); } @Test @@ -79,9 +79,9 @@ public void testFindUserFromRequest_SignedUrlTokenProvided_UserExists_UserApiTok sut.authSvc = authenticationServiceBeanStub; ContainerRequestContext testContainerRequest = new SignedUrlContainerRequestTestFake(TEST_SIGNED_URL_TOKEN, TEST_SIGNED_URL_USER_ID); - WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); + WrappedUnauthorizedAuthErrorResponse wrappedUnauthorizedAuthErrorResponse = assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); - assertEquals(RESPONSE_MESSAGE_BAD_SIGNED_URL, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_BAD_SIGNED_URL, wrappedUnauthorizedAuthErrorResponse.getMessage()); } @Test @@ -92,8 +92,8 @@ public void testFindUserFromRequest_SignedUrlTokenProvided_UserDoesNotExistForTh sut.authSvc = authenticationServiceBeanStub; ContainerRequestContext testContainerRequest = new SignedUrlContainerRequestTestFake(TEST_SIGNED_URL_TOKEN, TEST_SIGNED_URL_USER_ID); - WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); + WrappedUnauthorizedAuthErrorResponse wrappedUnauthorizedAuthErrorResponse = assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); - assertEquals(RESPONSE_MESSAGE_BAD_SIGNED_URL, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_BAD_SIGNED_URL, wrappedUnauthorizedAuthErrorResponse.getMessage()); } } diff --git a/src/test/java/edu/harvard/iq/dataverse/api/auth/WorkflowKeyAuthMechanismTest.java b/src/test/java/edu/harvard/iq/dataverse/api/auth/WorkflowKeyAuthMechanismTest.java index 3f90fa73fa9..22c3abffe2b 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/auth/WorkflowKeyAuthMechanismTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/WorkflowKeyAuthMechanismTest.java @@ -54,8 +54,8 @@ public void testFindUserFromRequest_WorkflowKeyProvided_UserNotAuthenticated() { sut.authSvc = authenticationServiceBeanStub; ContainerRequestContext testContainerRequest = new WorkflowKeyContainerRequestTestFake(TEST_WORKFLOW_KEY); - WrappedAuthErrorResponse wrappedAuthErrorResponse = assertThrows(WrappedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); + WrappedUnauthorizedAuthErrorResponse wrappedUnauthorizedAuthErrorResponse = assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); - assertEquals(RESPONSE_MESSAGE_BAD_WORKFLOW_KEY, wrappedAuthErrorResponse.getMessage()); + assertEquals(RESPONSE_MESSAGE_BAD_WORKFLOW_KEY, wrappedUnauthorizedAuthErrorResponse.getMessage()); } } From 300e0415f748fbf2000ae6baf1eaf848cb7c4f1c Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 30 Oct 2024 11:14:17 +0000 Subject: [PATCH 013/101] Refactor: extracted OIDC user lookup and token verify from BearerTokenAuthMechanism to AuthenticationServiceBean --- .../api/auth/BearerTokenAuthMechanism.java | 110 +++++------------- .../AuthenticationServiceBean.java | 93 +++++++++++++-- .../auth/BearerTokenAuthMechanismTest.java | 110 +++--------------- 3 files changed, 123 insertions(+), 190 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java index 1df265cbc9f..0dd2b9e0f9f 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java @@ -1,11 +1,8 @@ package edu.harvard.iq.dataverse.api.auth; -import com.nimbusds.oauth2.sdk.ParseException; -import com.nimbusds.oauth2.sdk.token.BearerAccessToken; import edu.harvard.iq.dataverse.UserServiceBean; import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; -import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; -import edu.harvard.iq.dataverse.authorization.providers.oauth2.oidc.OIDCAuthProvider; +import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.settings.FeatureFlags; @@ -13,111 +10,60 @@ import jakarta.inject.Inject; import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.core.HttpHeaders; -import java.io.IOException; -import java.util.List; + import java.util.Optional; import java.util.logging.Level; import java.util.logging.Logger; -import java.util.stream.Collectors; public class BearerTokenAuthMechanism implements AuthMechanism { private static final String BEARER_AUTH_SCHEME = "Bearer"; private static final Logger logger = Logger.getLogger(BearerTokenAuthMechanism.class.getCanonicalName()); - - public static final String RESPONSE_MESSAGE_UNAUTHORIZED_BEARER_TOKEN = "Unauthorized bearer token"; - public static final String RESPONSE_MESSAGE_INVALID_BEARER_TOKEN = "Could not parse bearer token"; - public static final String RESPONSE_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED = "Bearer token detected, no OIDC provider configured"; + public static final String RESPONSE_MESSAGE_BEARER_TOKEN_VALIDATED_UNREGISTERED_USER = "Bearer token is validated, but there is no linked user account"; @Inject protected AuthenticationServiceBean authSvc; @Inject protected UserServiceBean userSvc; - + @Override public User findUserFromRequest(ContainerRequestContext containerRequestContext) throws WrappedAuthErrorResponse { - if (FeatureFlags.API_BEARER_AUTH.enabled()) { - Optional bearerToken = getRequestApiKey(containerRequestContext); - // No Bearer Token present, hence no user can be authenticated - if (bearerToken.isEmpty()) { - return null; - } - - // Validate and verify provided Bearer Token, and retrieve UserRecordIdentifier - // TODO: Get the identifier from an invalidating cache to avoid lookup bursts of the same token. Tokens in the cache should be removed after some (configurable) time. - UserRecordIdentifier userInfo = verifyOidcBearerTokenAndGetUserIdentifier(bearerToken.get()); + if (!FeatureFlags.API_BEARER_AUTH.enabled()) { + return null; + } - // retrieve Authenticated User from AuthService - AuthenticatedUser authUser = authSvc.lookupUser(userInfo); - if (authUser != null) { - // track the API usage - authUser = userSvc.updateLastApiUseTime(authUser); - return authUser; - } else { - // a valid Token was presented, but we have no associated user account. - logger.log(Level.WARNING, "Bearer token detected, OIDC provider {0} validated Token but no linked UserAccount", userInfo.getUserRepoId()); - throw new WrappedForbiddenAuthErrorResponse(RESPONSE_MESSAGE_BEARER_TOKEN_VALIDATED_UNREGISTERED_USER); - } + Optional bearerToken = getRequestBearerToken(containerRequestContext); + if (bearerToken.isEmpty()) { + return null; } - return null; - } - /** - * Verifies the given Bearer token and obtain information about the corresponding user within respective AuthProvider. - * - * @param token The string containing the encoded JWT - * @return UserRecordIdentifier representing the user. - */ - private UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String token) throws WrappedUnauthorizedAuthErrorResponse { + AuthenticatedUser authUser; try { - BearerAccessToken accessToken = BearerAccessToken.parse(token); - // Get list of all authentication providers using Open ID Connect - // @TASK: Limited to OIDCAuthProviders, could be widened to OAuth2Providers. - List providers = authSvc.getAuthenticationProviderIdsOfType(OIDCAuthProvider.class).stream() - .map(providerId -> (OIDCAuthProvider) authSvc.getAuthenticationProvider(providerId)) - .collect(Collectors.toUnmodifiableList()); - // If not OIDC Provider are configured we cannot validate a Token - if(providers.isEmpty()){ - logger.log(Level.WARNING, "Bearer token detected, no OIDC provider configured"); - throw new WrappedUnauthorizedAuthErrorResponse(RESPONSE_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED); - } + authUser = authSvc.lookupUserByOidcBearerToken(bearerToken.get()); + } catch (AuthorizationException e) { + logger.log(Level.WARNING, "Authorization failed: {0}", e.getMessage()); + throw new WrappedUnauthorizedAuthErrorResponse(e.getMessage()); + } - // Iterate over all OIDC providers if multiple. Sadly needed as do not know which provided the Token. - for (OIDCAuthProvider provider : providers) { - try { - // The OIDCAuthProvider need to verify a Bearer Token and equip the client means to identify the corresponding AuthenticatedUser. - Optional userInfo = provider.getUserIdentifier(accessToken); - if(userInfo.isPresent()) { - logger.log(Level.FINE, "Bearer token detected, provider {0} confirmed validity and provided identifier", provider.getId()); - return userInfo.get(); - } - } catch (IOException e) { - // TODO: Just logging this is not sufficient - if there is an IO error with the one provider - // which would have validated successfully, this is not the users fault. We need to - // take note and refer to that later when occurred. - logger.log(Level.FINE, "Bearer token detected, provider " + provider.getId() + " indicates an invalid Token, skipping", e); - } - } - } catch (ParseException e) { - logger.log(Level.FINE, "Bearer token detected, unable to parse bearer token (invalid Token)", e); - throw new WrappedUnauthorizedAuthErrorResponse(RESPONSE_MESSAGE_INVALID_BEARER_TOKEN); + if (authUser == null) { + logger.log(Level.WARNING, + "Bearer token detected, OIDC provider validated the token but no linked UserAccount"); + throw new WrappedForbiddenAuthErrorResponse(RESPONSE_MESSAGE_BEARER_TOKEN_VALIDATED_UNREGISTERED_USER); } - // No UserInfo returned means we have an invalid access token. - logger.log(Level.FINE, "Bearer token detected, yet no configured OIDC provider validated it."); - throw new WrappedUnauthorizedAuthErrorResponse(RESPONSE_MESSAGE_UNAUTHORIZED_BEARER_TOKEN); + return userSvc.updateLastApiUseTime(authUser); } /** * Retrieve the raw, encoded token value from the Authorization Bearer HTTP header as defined in RFC 6750 + * * @return An {@link Optional} either empty if not present or the raw token from the header */ - private Optional getRequestApiKey(ContainerRequestContext containerRequestContext) { - String headerParamApiKey = containerRequestContext.getHeaderString(HttpHeaders.AUTHORIZATION); - if (headerParamApiKey != null && headerParamApiKey.toLowerCase().startsWith(BEARER_AUTH_SCHEME.toLowerCase() + " ")) { - return Optional.of(headerParamApiKey); - } else { - return Optional.empty(); + private Optional getRequestBearerToken(ContainerRequestContext containerRequestContext) { + String headerParamBearerToken = containerRequestContext.getHeaderString(HttpHeaders.AUTHORIZATION); + if (headerParamBearerToken != null && headerParamBearerToken.toLowerCase().startsWith(BEARER_AUTH_SCHEME.toLowerCase() + " ")) { + return Optional.of(headerParamBearerToken); } + return Optional.empty(); } -} \ No newline at end of file +} diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java index 4a8fb123fd4..c9c3db43746 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java @@ -1,11 +1,15 @@ package edu.harvard.iq.dataverse.authorization; +import com.nimbusds.oauth2.sdk.ParseException; +import com.nimbusds.oauth2.sdk.token.BearerAccessToken; import edu.harvard.iq.dataverse.DatasetVersionServiceBean; import edu.harvard.iq.dataverse.DvObjectServiceBean; import edu.harvard.iq.dataverse.GuestbookResponseServiceBean; import edu.harvard.iq.dataverse.RoleAssigneeServiceBean; import edu.harvard.iq.dataverse.UserNotificationServiceBean; import edu.harvard.iq.dataverse.UserServiceBean; +import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; +import edu.harvard.iq.dataverse.authorization.providers.oauth2.oidc.OIDCAuthProvider; import edu.harvard.iq.dataverse.search.IndexServiceBean; import edu.harvard.iq.dataverse.actionlogging.ActionLogRecord; import edu.harvard.iq.dataverse.actionlogging.ActionLogServiceBean; @@ -34,17 +38,10 @@ import edu.harvard.iq.dataverse.validation.PasswordValidatorServiceBean; import edu.harvard.iq.dataverse.workflow.PendingWorkflowInvocation; import edu.harvard.iq.dataverse.workflows.WorkflowComment; + +import java.io.IOException; import java.sql.Timestamp; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Calendar; -import java.util.Collection; -import java.util.Date; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.TreeSet; +import java.util.*; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; @@ -127,8 +124,12 @@ public class AuthenticationServiceBean { @PersistenceContext(unitName = "VDCNet-ejbPU") private EntityManager em; - - + + public static final String ERROR_MESSAGE_UNAUTHORIZED_BEARER_TOKEN = "Unauthorized bearer token"; + public static final String ERROR_MESSAGE_INVALID_BEARER_TOKEN = "Could not parse bearer token"; + public static final String ERROR_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED = "Bearer token detected, no OIDC provider configured"; + + public AbstractOAuth2AuthenticationProvider getOAuth2Provider( String id ) { return authProvidersRegistrationService.getOAuth2AuthProvidersMap().get(id); } @@ -978,4 +979,72 @@ public ApiToken getValidApiTokenForUser(User user) { } return apiToken; } + + /** + * Looks up an authenticated user based on the provided OIDC bearer token. + * + * @param bearerToken The OIDC bearer token. + * @return An instance of {@link AuthenticatedUser} representing the authenticated user. + * @throws AuthorizationException If the token is invalid or no OIDC provider is configured. + */ + public AuthenticatedUser lookupUserByOidcBearerToken(String bearerToken) throws AuthorizationException { + // TODO: Get the identifier from an invalidating cache to avoid lookup bursts of the same token. + // Tokens in the cache should be removed after some (configurable) time. + UserRecordIdentifier userInfo = verifyOidcBearerTokenAndGetUserIdentifier(bearerToken); + return lookupUser(userInfo); + } + + /** + * Verifies the given OIDC bearer token and retrieves the corresponding user's identifier. + * + * @param bearerToken The OIDC bearer token. + * @return A {@link UserRecordIdentifier} representing the user associated with the valid token. + * @throws AuthorizationException If the token is invalid or if no OIDC providers are available. + */ + private UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String bearerToken) throws AuthorizationException { + try { + BearerAccessToken accessToken = BearerAccessToken.parse(bearerToken); + List providers = getAvailableOidcProviders(); + + // Ensure at least one OIDC provider is configured to validate the token. + if (providers.isEmpty()) { + logger.log(Level.WARNING, "Bearer token detected, no OIDC provider configured"); + throw new AuthorizationException(ERROR_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED); + } + + // Attempt to validate the token with each configured OIDC provider. + for (OIDCAuthProvider provider : providers) { + try { + Optional userInfo = provider.getUserIdentifier(accessToken); + if (userInfo.isPresent()) { + logger.log(Level.FINE, "Bearer token detected, provider {0} confirmed validity and provided identifier", provider.getId()); + return userInfo.get(); + } + } catch (IOException e) { + // TODO: Just logging this is not sufficient - if there is an IO error with the one provider + // which would have validated successfully, this is not the users fault. We need to + // take note and refer to that later when occurred. + logger.log(Level.FINE, "Bearer token detected, provider " + provider.getId() + " indicates an invalid Token, skipping", e); + } + } + } catch (ParseException e) { + logger.log(Level.FINE, "Bearer token detected, unable to parse bearer token (invalid Token)", e); + throw new AuthorizationException(ERROR_MESSAGE_INVALID_BEARER_TOKEN); + } + + // If no provider validated the token, throw an authorization exception. + logger.log(Level.FINE, "Bearer token detected, yet no configured OIDC provider validated it."); + throw new AuthorizationException(ERROR_MESSAGE_UNAUTHORIZED_BEARER_TOKEN); + } + + /** + * Retrieves a list of configured OIDC authentication providers. + * + * @return A list of available OIDCAuthProviders. + */ + private List getAvailableOidcProviders() { + return getAuthenticationProviderIdsOfType(OIDCAuthProvider.class).stream() + .map(providerId -> (OIDCAuthProvider) getAuthenticationProvider(providerId)) + .toList(); + } } diff --git a/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java b/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java index 19828fc494c..c8a1ef8f087 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java @@ -1,12 +1,9 @@ package edu.harvard.iq.dataverse.api.auth; -import com.nimbusds.oauth2.sdk.ParseException; -import com.nimbusds.oauth2.sdk.token.BearerAccessToken; import edu.harvard.iq.dataverse.UserServiceBean; import edu.harvard.iq.dataverse.api.auth.doubles.BearerTokenKeyContainerRequestTestFake; import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; -import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; -import edu.harvard.iq.dataverse.authorization.providers.oauth2.oidc.OIDCAuthProvider; +import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.settings.JvmSettings; @@ -18,10 +15,6 @@ import jakarta.ws.rs.container.ContainerRequestContext; -import java.io.IOException; -import java.util.Collections; -import java.util.Optional; - import static edu.harvard.iq.dataverse.api.auth.BearerTokenAuthMechanism.*; import static org.junit.jupiter.api.Assertions.*; @@ -29,7 +22,7 @@ @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth") class BearerTokenAuthMechanismTest { - private static final String TEST_API_KEY = "test-api-key"; + private static final String TEST_BEARER_TOKEN = "Bearer test"; private BearerTokenAuthMechanism sut; @@ -49,114 +42,39 @@ void testFindUserFromRequest_no_token() throws WrappedAuthErrorResponse { } @Test - void testFindUserFromRequest_invalid_token() { - Mockito.when(sut.authSvc.getAuthenticationProviderIdsOfType(OIDCAuthProvider.class)).thenReturn(Collections.emptySet()); - - ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake("Bearer "); - WrappedUnauthorizedAuthErrorResponse wrappedUnauthorizedAuthErrorResponse = assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); - - //then - assertEquals(RESPONSE_MESSAGE_INVALID_BEARER_TOKEN, wrappedUnauthorizedAuthErrorResponse.getMessage()); - } - @Test - void testFindUserFromRequest_no_OidcProvider() { - Mockito.when(sut.authSvc.getAuthenticationProviderIdsOfType(OIDCAuthProvider.class)).thenReturn(Collections.emptySet()); - - ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake("Bearer " +TEST_API_KEY); - WrappedUnauthorizedAuthErrorResponse wrappedUnauthorizedAuthErrorResponse = assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); - - //then - assertEquals(RESPONSE_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED, wrappedUnauthorizedAuthErrorResponse.getMessage()); - } - - @Test - void testFindUserFromRequest_oneProvider_invalidToken_1() throws ParseException, IOException { - OIDCAuthProvider oidcAuthProvider = Mockito.mock(OIDCAuthProvider.class); - String providerID = "OIEDC"; - Mockito.when(oidcAuthProvider.getId()).thenReturn(providerID); - // ensure that a valid OIDCAuthProvider is available within the AuthenticationServiceBean - Mockito.when(sut.authSvc.getAuthenticationProviderIdsOfType(OIDCAuthProvider.class)).thenReturn(Collections.singleton(providerID)); - Mockito.when(sut.authSvc.getAuthenticationProvider(providerID)).thenReturn(oidcAuthProvider); - - // ensure that the OIDCAuthProvider returns a valid UserRecordIdentifier for a given Token - BearerAccessToken token = BearerAccessToken.parse("Bearer " + TEST_API_KEY); - Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenReturn(Optional.empty()); + void testFindUserFromRequest_invalid_token() throws AuthorizationException { + String testErrorMessage = "test error"; + Mockito.when(sut.authSvc.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)).thenThrow(new AuthorizationException(testErrorMessage)); // when - ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake("Bearer " + TEST_API_KEY); + ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake(TEST_BEARER_TOKEN); WrappedUnauthorizedAuthErrorResponse wrappedUnauthorizedAuthErrorResponse = assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); //then - assertEquals(RESPONSE_MESSAGE_UNAUTHORIZED_BEARER_TOKEN, wrappedUnauthorizedAuthErrorResponse.getMessage()); + assertEquals(testErrorMessage, wrappedUnauthorizedAuthErrorResponse.getMessage()); } @Test - void testFindUserFromRequest_oneProvider_invalidToken_2() throws ParseException, IOException { - OIDCAuthProvider oidcAuthProvider = Mockito.mock(OIDCAuthProvider.class); - String providerID = "OIEDC"; - Mockito.when(oidcAuthProvider.getId()).thenReturn(providerID); - // ensure that a valid OIDCAuthProvider is available within the AuthenticationServiceBean - Mockito.when(sut.authSvc.getAuthenticationProviderIdsOfType(OIDCAuthProvider.class)).thenReturn(Collections.singleton(providerID)); - Mockito.when(sut.authSvc.getAuthenticationProvider(providerID)).thenReturn(oidcAuthProvider); - - // ensure that the OIDCAuthProvider returns a valid UserRecordIdentifier for a given Token - BearerAccessToken token = BearerAccessToken.parse("Bearer " + TEST_API_KEY); - Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenThrow(IOException.class); - - // when - ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake("Bearer " + TEST_API_KEY); - WrappedUnauthorizedAuthErrorResponse wrappedUnauthorizedAuthErrorResponse = assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); - - //then - assertEquals(RESPONSE_MESSAGE_UNAUTHORIZED_BEARER_TOKEN, wrappedUnauthorizedAuthErrorResponse.getMessage()); - } - @Test - void testFindUserFromRequest_oneProvider_validToken() throws WrappedAuthErrorResponse, ParseException, IOException { - OIDCAuthProvider oidcAuthProvider = Mockito.mock(OIDCAuthProvider.class); - String providerID = "OIEDC"; - Mockito.when(oidcAuthProvider.getId()).thenReturn(providerID); - // ensure that a valid OIDCAuthProvider is available within the AuthenticationServiceBean - Mockito.when(sut.authSvc.getAuthenticationProviderIdsOfType(OIDCAuthProvider.class)).thenReturn(Collections.singleton(providerID)); - Mockito.when(sut.authSvc.getAuthenticationProvider(providerID)).thenReturn(oidcAuthProvider); - - // ensure that the OIDCAuthProvider returns a valid UserRecordIdentifier for a given Token - UserRecordIdentifier userinfo = new UserRecordIdentifier(providerID, "KEY"); - BearerAccessToken token = BearerAccessToken.parse("Bearer " + TEST_API_KEY); - Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenReturn(Optional.of(userinfo)); - - // ensures that the AuthenticationServiceBean can retrieve an Authenticated user based on the UserRecordIdentifier + void testFindUserFromRequest_validToken_accountExists() throws WrappedAuthErrorResponse, AuthorizationException { AuthenticatedUser testAuthenticatedUser = new AuthenticatedUser(); - Mockito.when(sut.authSvc.lookupUser(userinfo)).thenReturn(testAuthenticatedUser); + Mockito.when(sut.authSvc.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)).thenReturn(testAuthenticatedUser); Mockito.when(sut.userSvc.updateLastApiUseTime(testAuthenticatedUser)).thenReturn(testAuthenticatedUser); // when - ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake("Bearer " + TEST_API_KEY); + ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake(TEST_BEARER_TOKEN); User actual = sut.findUserFromRequest(testContainerRequest); //then assertEquals(testAuthenticatedUser, actual); Mockito.verify(sut.userSvc, Mockito.atLeastOnce()).updateLastApiUseTime(testAuthenticatedUser); - } + @Test - void testFindUserFromRequest_oneProvider_validToken_noAccount() throws ParseException, IOException { - OIDCAuthProvider oidcAuthProvider = Mockito.mock(OIDCAuthProvider.class); - String providerID = "OIEDC"; - Mockito.when(oidcAuthProvider.getId()).thenReturn(providerID); - // ensure that a valid OIDCAuthProvider is available within the AuthenticationServiceBean - Mockito.when(sut.authSvc.getAuthenticationProviderIdsOfType(OIDCAuthProvider.class)).thenReturn(Collections.singleton(providerID)); - Mockito.when(sut.authSvc.getAuthenticationProvider(providerID)).thenReturn(oidcAuthProvider); - - // ensure that the OIDCAuthProvider returns a valid UserRecordIdentifier for a given Token - UserRecordIdentifier userinfo = new UserRecordIdentifier(providerID, "KEY"); - BearerAccessToken token = BearerAccessToken.parse("Bearer " + TEST_API_KEY); - Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenReturn(Optional.of(userinfo)); - - // ensures that the AuthenticationServiceBean can retrieve an Authenticated user based on the UserRecordIdentifier - Mockito.when(sut.authSvc.lookupUser(userinfo)).thenReturn(null); + void testFindUserFromRequest_validToken_noAccount() throws AuthorizationException { + Mockito.when(sut.authSvc.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)).thenReturn(null); // when - ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake("Bearer " + TEST_API_KEY); + ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake(TEST_BEARER_TOKEN); WrappedForbiddenAuthErrorResponse wrappedForbiddenAuthErrorResponse = assertThrows(WrappedForbiddenAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); //then From ba70a04da1947377bafece67679771baea89b091 Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 30 Oct 2024 13:06:15 +0000 Subject: [PATCH 014/101] Added: unit tests to newly added methods in AuthenticationServiceBean --- .../AuthenticationServiceBean.java | 2 +- .../AuthenticationServiceBeanTest.java | 131 ++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java index c9c3db43746..14caea5399b 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java @@ -123,7 +123,7 @@ public class AuthenticationServiceBean { PrivateUrlServiceBean privateUrlService; @PersistenceContext(unitName = "VDCNet-ejbPU") - private EntityManager em; + EntityManager em; public static final String ERROR_MESSAGE_UNAUTHORIZED_BEARER_TOKEN = "Unauthorized bearer token"; public static final String ERROR_MESSAGE_INVALID_BEARER_TOKEN = "Could not parse bearer token"; diff --git a/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java b/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java new file mode 100644 index 00000000000..be98bcb516d --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java @@ -0,0 +1,131 @@ +package edu.harvard.iq.dataverse.authorization; + +import com.nimbusds.oauth2.sdk.ParseException; +import com.nimbusds.oauth2.sdk.token.BearerAccessToken; +import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; +import edu.harvard.iq.dataverse.authorization.providers.oauth2.oidc.OIDCAuthProvider; +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.authorization.users.User; +import jakarta.persistence.EntityManager; +import jakarta.persistence.NoResultException; +import jakarta.persistence.TypedQuery; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.io.IOException; +import java.util.Map; +import java.util.Optional; + +import static edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean.*; +import static org.junit.jupiter.api.Assertions.*; + +public class AuthenticationServiceBeanTest { + + private AuthenticationServiceBean sut; + private static final String TEST_BEARER_TOKEN = "Bearer test"; + + @BeforeEach + public void setUp() { + sut = new AuthenticationServiceBean(); + sut.authProvidersRegistrationService = Mockito.mock(AuthenticationProvidersRegistrationServiceBean.class); + sut.em = Mockito.mock(EntityManager.class); + } + + @Test + void testLookupUserByOidcBearerToken_no_OidcProvider() { + // Given no OIDC providers are configured + Mockito.when(sut.authProvidersRegistrationService.getAuthenticationProvidersMap()).thenReturn(Map.of()); + + // When invoking lookupUserByOidcBearerToken + AuthorizationException exception = assertThrows(AuthorizationException.class, + () -> sut.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)); + + // Then the exception message should indicate no OIDC provider is configured + assertEquals(ERROR_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED, exception.getMessage()); + } + + @Test + void testLookupUserByOidcBearerToken_oneProvider_invalidToken_1() throws ParseException, IOException { + // Given a single OIDC provider that cannot find a user + OIDCAuthProvider oidcAuthProvider = mockOidcAuthProvider("OIEDC"); + BearerAccessToken token = BearerAccessToken.parse(TEST_BEARER_TOKEN); + Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenReturn(Optional.empty()); + + // When invoking lookupUserByOidcBearerToken + AuthorizationException exception = assertThrows(AuthorizationException.class, + () -> sut.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)); + + // Then the exception message should indicate an unauthorized token + assertEquals(ERROR_MESSAGE_UNAUTHORIZED_BEARER_TOKEN, exception.getMessage()); + } + + @Test + void testLookupUserByOidcBearerToken_oneProvider_invalidToken_2() throws ParseException, IOException { + // Given a single OIDC provider that throws an IOException + OIDCAuthProvider oidcAuthProvider = mockOidcAuthProvider("OIEDC"); + BearerAccessToken token = BearerAccessToken.parse(TEST_BEARER_TOKEN); + Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenThrow(IOException.class); + + // When invoking lookupUserByOidcBearerToken + AuthorizationException exception = assertThrows(AuthorizationException.class, + () -> sut.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)); + + // Then the exception message should indicate an unauthorized token + assertEquals(ERROR_MESSAGE_UNAUTHORIZED_BEARER_TOKEN, exception.getMessage()); + } + + @Test + void testLookupUserByOidcBearerToken_oneProvider_validToken() throws ParseException, IOException, AuthorizationException { + // Given a single OIDC provider that returns a valid user identifier + OIDCAuthProvider oidcAuthProvider = mockOidcAuthProvider("OIEDC"); + AuthenticatedUser authenticatedUser = setupAuthenticatedUserQueryWithResult(new AuthenticatedUser()); + UserRecordIdentifier userInfo = new UserRecordIdentifier("OIEDC", "KEY"); + BearerAccessToken token = BearerAccessToken.parse(TEST_BEARER_TOKEN); + Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenReturn(Optional.of(userInfo)); + + // When invoking lookupUserByOidcBearerToken + User actualUser = sut.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN); + + // Then the actual user should match the expected authenticated user + assertEquals(authenticatedUser, actualUser); + } + + @Test + void testLookupUserByOidcBearerToken_oneProvider_validToken_noAccount() throws ParseException, IOException, AuthorizationException { + // Given a single OIDC provider with a valid user identifier but no account exists + OIDCAuthProvider oidcAuthProvider = mockOidcAuthProvider("OIEDC"); + setupAuthenticatedUserQueryWithNoResult(); + UserRecordIdentifier userInfo = new UserRecordIdentifier("OIEDC", "KEY"); + BearerAccessToken token = BearerAccessToken.parse(TEST_BEARER_TOKEN); + Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenReturn(Optional.of(userInfo)); + + // When invoking lookupUserByOidcBearerToken + User actualUser = sut.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN); + + // Then no user should be found, and result should be null + assertNull(actualUser); + } + + private OIDCAuthProvider mockOidcAuthProvider(String providerID) { + OIDCAuthProvider oidcAuthProvider = Mockito.mock(OIDCAuthProvider.class); + Mockito.when(oidcAuthProvider.getId()).thenReturn(providerID); + Mockito.when(sut.authProvidersRegistrationService.getAuthenticationProvidersMap()).thenReturn(Map.of(providerID, oidcAuthProvider)); + return oidcAuthProvider; + } + + private AuthenticatedUser setupAuthenticatedUserQueryWithResult(AuthenticatedUser authenticatedUser) { + TypedQuery queryMock = Mockito.mock(TypedQuery.class); + AuthenticatedUserLookup lookupMock = Mockito.mock(AuthenticatedUserLookup.class); + Mockito.when(lookupMock.getAuthenticatedUser()).thenReturn(authenticatedUser); + Mockito.when(queryMock.getSingleResult()).thenReturn(lookupMock); + Mockito.when(sut.em.createNamedQuery("AuthenticatedUserLookup.findByAuthPrvID_PersUserId", AuthenticatedUserLookup.class)).thenReturn(queryMock); + return authenticatedUser; + } + + private void setupAuthenticatedUserQueryWithNoResult() { + TypedQuery queryMock = Mockito.mock(TypedQuery.class); + Mockito.when(queryMock.getSingleResult()).thenThrow(new NoResultException()); + Mockito.when(sut.em.createNamedQuery("AuthenticatedUserLookup.findByAuthPrvID_PersUserId", AuthenticatedUserLookup.class)).thenReturn(queryMock); + } +} From 80ad5a4d5868a09c620eeb8f3d8a7b81bdb16bf1 Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 1 Nov 2024 19:26:16 +0000 Subject: [PATCH 015/101] Stash: implementing users/register endpoint WIP --- .../edu/harvard/iq/dataverse/api/Users.java | 42 +++++++++++++++---- .../harvard/iq/dataverse/api/dto/UserDTO.java | 13 ++++++ .../command/impl/RegisterOidcUserCommand.java | 35 ++++++++++++++++ .../iq/dataverse/util/json/JsonParser.java | 5 +++ 4 files changed, 86 insertions(+), 9 deletions(-) create mode 100644 src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java create mode 100644 src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Users.java b/src/main/java/edu/harvard/iq/dataverse/api/Users.java index c1a7c95dbff..ef65363cd0d 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Users.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Users.java @@ -9,28 +9,25 @@ import edu.harvard.iq.dataverse.authorization.users.ApiToken; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.User; -import edu.harvard.iq.dataverse.engine.command.impl.ChangeUserIdentifierCommand; -import edu.harvard.iq.dataverse.engine.command.impl.GetUserTracesCommand; -import edu.harvard.iq.dataverse.engine.command.impl.MergeInAccountCommand; -import edu.harvard.iq.dataverse.engine.command.impl.RevokeAllRolesCommand; +import edu.harvard.iq.dataverse.engine.command.impl.*; import edu.harvard.iq.dataverse.util.FileUtil; import static edu.harvard.iq.dataverse.util.json.JsonPrinter.json; import java.util.Arrays; import java.util.List; +import java.util.Optional; import java.util.logging.Level; import java.util.logging.Logger; + +import edu.harvard.iq.dataverse.util.json.JsonUtil; import jakarta.ejb.Stateless; import jakarta.json.JsonArray; +import jakarta.json.JsonObject; import jakarta.json.JsonObjectBuilder; import jakarta.ws.rs.*; import jakarta.ws.rs.container.ContainerRequestContext; -import jakarta.ws.rs.core.Context; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Request; -import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.Variant; +import jakarta.ws.rs.core.*; /** * @@ -261,4 +258,31 @@ public Response getTracesElement(@Context ContainerRequestContext crc, @Context } } + @POST + @AuthRequired + @Path("register") + public Response registerOidcUser(@Context ContainerRequestContext crc, String body) { + Optional bearerToken = getRequestBearerToken(crc); + if (bearerToken.isEmpty()) { + return error(Response.Status.BAD_REQUEST, "Bearer token required."); + } + JsonObject userJson; + try { + userJson = JsonUtil.getJsonObject(body); + execCommand(new RegisterOidcUserCommand(createDataverseRequest(getRequestUser(crc)), bearerToken.get(), jsonParser().parseUserDTO(userJson))); + return ok("User registered."); + } catch (Exception e){ + return error(Response.Status.BAD_REQUEST, "Error calling RegisterOidcUserCommand: " + e.getLocalizedMessage()); + } + + } + + // TODO: Remove duplication with BearerTokenAuthMechanism + private Optional getRequestBearerToken(ContainerRequestContext containerRequestContext) { + String headerParamBearerToken = containerRequestContext.getHeaderString(HttpHeaders.AUTHORIZATION); + if (headerParamBearerToken != null && headerParamBearerToken.toLowerCase().startsWith("Bearer" + " ")) { + return Optional.of(headerParamBearerToken); + } + return Optional.empty(); + } } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java b/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java new file mode 100644 index 00000000000..d829b099ff5 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java @@ -0,0 +1,13 @@ +package edu.harvard.iq.dataverse.api.dto; + +public class UserDTO { + private String email; + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java new file mode 100644 index 00000000000..4574784bd4b --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java @@ -0,0 +1,35 @@ +package edu.harvard.iq.dataverse.engine.command.impl; + +import edu.harvard.iq.dataverse.DvObject; +import edu.harvard.iq.dataverse.api.dto.UserDTO; +import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; +import edu.harvard.iq.dataverse.authorization.users.User; +import edu.harvard.iq.dataverse.engine.command.*; +import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import edu.harvard.iq.dataverse.engine.command.exception.IllegalCommandException; + +@RequiredPermissions({}) +public class RegisterOidcUserCommand extends AbstractVoidCommand { + + private final String bearerToken; + private final UserDTO userDTO; + + public RegisterOidcUserCommand(DataverseRequest aRequest, String bearerToken, UserDTO userDTO) { + super(aRequest, (DvObject) null); + this.bearerToken = bearerToken; + this.userDTO = userDTO; + } + + @Override + protected void executeImpl(CommandContext ctxt) throws CommandException { + try { + User user = ctxt.authentication().lookupUserByOidcBearerToken(bearerToken); + if (user != null) { + throw new IllegalCommandException("User is already registered with this token", this); + } + // TODO register user + } catch (AuthorizationException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java index 2f01c9bc2f2..e6a6f2d565f 100644 --- a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java +++ b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java @@ -20,6 +20,7 @@ import edu.harvard.iq.dataverse.TermsOfUseAndAccess; import edu.harvard.iq.dataverse.api.Util; import edu.harvard.iq.dataverse.api.dto.FieldDTO; +import edu.harvard.iq.dataverse.api.dto.UserDTO; import edu.harvard.iq.dataverse.authorization.groups.impl.ipaddress.IpGroup; import edu.harvard.iq.dataverse.authorization.groups.impl.ipaddress.ip.IpAddress; import edu.harvard.iq.dataverse.authorization.groups.impl.ipaddress.ip.IpAddressRange; @@ -1052,4 +1053,8 @@ private void validate(String objectName, JsonObject jobject, String fieldName, V throw new JsonParseException( objectName + " missing a field named '"+fieldName+"' of type " + expectedValueType ); } } + + public UserDTO parseUserDTO(JsonObject jobj) { + return new UserDTO(); + } } From 2ca0722eabd3ff77a8cb19c943babd355f8e65bf Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 1 Nov 2024 19:40:17 +0000 Subject: [PATCH 016/101] Added: user creation logic to RegisterOidcUserCommand and missing fields to UserDTO --- .../harvard/iq/dataverse/api/dto/UserDTO.java | 37 ++++++++++++++++--- .../AuthenticationServiceBean.java | 2 +- .../command/impl/RegisterOidcUserCommand.java | 14 ++++++- 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java b/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java index d829b099ff5..c81fc99d549 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java @@ -1,13 +1,40 @@ package edu.harvard.iq.dataverse.api.dto; public class UserDTO { - private String email; + public String username; + public String firstName; + public String lastName; + public String emailAddress; - public String getEmail() { - return email; + public String getUsername() { + return username; } - public void setEmail(String email) { - this.email = email; + public void setUsername(String username) { + this.username = username; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getEmailAddress() { + return emailAddress; + } + + public void setEmailAddress(String emailAddress) { + this.emailAddress = emailAddress; } } diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java index 14caea5399b..50dd89700e9 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java @@ -1001,7 +1001,7 @@ public AuthenticatedUser lookupUserByOidcBearerToken(String bearerToken) throws * @return A {@link UserRecordIdentifier} representing the user associated with the valid token. * @throws AuthorizationException If the token is invalid or if no OIDC providers are available. */ - private UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String bearerToken) throws AuthorizationException { + public UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String bearerToken) throws AuthorizationException { try { BearerAccessToken accessToken = BearerAccessToken.parse(bearerToken); List providers = getAvailableOidcProviders(); diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java index 4574784bd4b..72e742ad077 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java @@ -2,6 +2,8 @@ import edu.harvard.iq.dataverse.DvObject; import edu.harvard.iq.dataverse.api.dto.UserDTO; +import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo; +import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.engine.command.*; @@ -23,11 +25,19 @@ public RegisterOidcUserCommand(DataverseRequest aRequest, String bearerToken, Us @Override protected void executeImpl(CommandContext ctxt) throws CommandException { try { - User user = ctxt.authentication().lookupUserByOidcBearerToken(bearerToken); + UserRecordIdentifier userRecordIdentifier = ctxt.authentication().verifyOidcBearerTokenAndGetUserIdentifier(bearerToken); + User user = ctxt.authentication().lookupUser(userRecordIdentifier); if (user != null) { throw new IllegalCommandException("User is already registered with this token", this); } - // TODO register user + AuthenticatedUserDisplayInfo authenticatedUserDisplayInfo = new AuthenticatedUserDisplayInfo( + userDTO.firstName, + userDTO.lastName, + userDTO.emailAddress, + "", + "" + ); + ctxt.authentication().createAuthenticatedUser(userRecordIdentifier, userDTO.username, authenticatedUserDisplayInfo, true); } catch (AuthorizationException e) { throw new RuntimeException(e); } From e382a1533a7ba42efde66429cc2542d1d67fed7c Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 1 Nov 2024 19:51:59 +0000 Subject: [PATCH 017/101] Refactor: extracted response messages to Bundle.properties --- src/main/java/edu/harvard/iq/dataverse/api/Users.java | 9 +++++++-- src/main/java/propertyFiles/Bundle.properties | 4 ++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Users.java b/src/main/java/edu/harvard/iq/dataverse/api/Users.java index ef65363cd0d..63cd2019856 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Users.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Users.java @@ -10,6 +10,8 @@ import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.engine.command.impl.*; +import edu.harvard.iq.dataverse.settings.FeatureFlags; +import edu.harvard.iq.dataverse.util.BundleUtil; import edu.harvard.iq.dataverse.util.FileUtil; import static edu.harvard.iq.dataverse.util.json.JsonPrinter.json; @@ -262,15 +264,18 @@ public Response getTracesElement(@Context ContainerRequestContext crc, @Context @AuthRequired @Path("register") public Response registerOidcUser(@Context ContainerRequestContext crc, String body) { + if (!FeatureFlags.API_BEARER_AUTH.enabled()) { + return error(Response.Status.INTERNAL_SERVER_ERROR, BundleUtil.getStringFromBundle("users.api.errors.bearerAuthFeatureFlagDisabled")); + } Optional bearerToken = getRequestBearerToken(crc); if (bearerToken.isEmpty()) { - return error(Response.Status.BAD_REQUEST, "Bearer token required."); + return error(Response.Status.BAD_REQUEST, BundleUtil.getStringFromBundle("users.api.errors.bearerTokenRequired")); } JsonObject userJson; try { userJson = JsonUtil.getJsonObject(body); execCommand(new RegisterOidcUserCommand(createDataverseRequest(getRequestUser(crc)), bearerToken.get(), jsonParser().parseUserDTO(userJson))); - return ok("User registered."); + return ok(BundleUtil.getStringFromBundle("users.api.userRegistered")); } catch (Exception e){ return error(Response.Status.BAD_REQUEST, "Error calling RegisterOidcUserCommand: " + e.getLocalizedMessage()); } diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index 149e6a7e828..043cb5f6394 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -3062,3 +3062,7 @@ openapi.exception.invalid.format=Invalid format {0}, currently supported formats openapi.exception=Supported format definition not found. openapi.exception.unaligned=Unaligned parameters on Headers [{0}] and Request [{1}] +#Users.java +users.api.errors.bearerAuthFeatureFlagDisabled=This endpoint is only available when bearer authentication feature flag is enabled. +users.api.errors.bearerTokenRequired=Bearer token required. +users.api.userRegistered=User registered. From fd68cd225082ab27a7723d7980223f5bfbad0853 Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 1 Nov 2024 19:53:18 +0000 Subject: [PATCH 018/101] Refactor: error message string extracted to const --- .../engine/command/impl/RegisterOidcUserCommand.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java index 72e742ad077..69d24550e9f 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java @@ -16,6 +16,8 @@ public class RegisterOidcUserCommand extends AbstractVoidCommand { private final String bearerToken; private final UserDTO userDTO; + private static final String ERROR_USER_ALREADY_REGISTERED_WITH_TOKEN = "User is already registered with this token"; + public RegisterOidcUserCommand(DataverseRequest aRequest, String bearerToken, UserDTO userDTO) { super(aRequest, (DvObject) null); this.bearerToken = bearerToken; @@ -28,7 +30,7 @@ protected void executeImpl(CommandContext ctxt) throws CommandException { UserRecordIdentifier userRecordIdentifier = ctxt.authentication().verifyOidcBearerTokenAndGetUserIdentifier(bearerToken); User user = ctxt.authentication().lookupUser(userRecordIdentifier); if (user != null) { - throw new IllegalCommandException("User is already registered with this token", this); + throw new IllegalCommandException(ERROR_USER_ALREADY_REGISTERED_WITH_TOKEN, this); } AuthenticatedUserDisplayInfo authenticatedUserDisplayInfo = new AuthenticatedUserDisplayInfo( userDTO.firstName, From 6a7a3e1237729ab58c6ec7c984b5cef5d2523a32 Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 1 Nov 2024 20:10:43 +0000 Subject: [PATCH 019/101] Changed: replaced string class constants with Bundle.properties strings --- .../api/auth/BearerTokenAuthMechanism.java | 5 ++--- .../authorization/AuthenticationServiceBean.java | 13 ++++--------- .../command/impl/RegisterOidcUserCommand.java | 13 ++++++++----- src/main/java/propertyFiles/Bundle.properties | 11 +++++++++++ .../api/auth/BearerTokenAuthMechanismTest.java | 4 ++-- .../AuthenticationServiceBeanTest.java | 8 ++++---- 6 files changed, 31 insertions(+), 23 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java index 0dd2b9e0f9f..0e353a8e404 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java @@ -7,6 +7,7 @@ import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.settings.FeatureFlags; +import edu.harvard.iq.dataverse.util.BundleUtil; import jakarta.inject.Inject; import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.core.HttpHeaders; @@ -19,8 +20,6 @@ public class BearerTokenAuthMechanism implements AuthMechanism { private static final String BEARER_AUTH_SCHEME = "Bearer"; private static final Logger logger = Logger.getLogger(BearerTokenAuthMechanism.class.getCanonicalName()); - public static final String RESPONSE_MESSAGE_BEARER_TOKEN_VALIDATED_UNREGISTERED_USER = "Bearer token is validated, but there is no linked user account"; - @Inject protected AuthenticationServiceBean authSvc; @Inject @@ -48,7 +47,7 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) if (authUser == null) { logger.log(Level.WARNING, "Bearer token detected, OIDC provider validated the token but no linked UserAccount"); - throw new WrappedForbiddenAuthErrorResponse(RESPONSE_MESSAGE_BEARER_TOKEN_VALIDATED_UNREGISTERED_USER); + throw new WrappedForbiddenAuthErrorResponse(BundleUtil.getStringFromBundle("bearerTokenAuthMechanism.errors.tokenValidatedButNoRegisteredUser")); } return userSvc.updateLastApiUseTime(authUser); diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java index 50dd89700e9..811a46730ee 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java @@ -45,7 +45,7 @@ import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; -import jakarta.annotation.PostConstruct; + import jakarta.ejb.EJB; import jakarta.ejb.EJBException; import jakarta.ejb.Stateless; @@ -125,11 +125,6 @@ public class AuthenticationServiceBean { @PersistenceContext(unitName = "VDCNet-ejbPU") EntityManager em; - public static final String ERROR_MESSAGE_UNAUTHORIZED_BEARER_TOKEN = "Unauthorized bearer token"; - public static final String ERROR_MESSAGE_INVALID_BEARER_TOKEN = "Could not parse bearer token"; - public static final String ERROR_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED = "Bearer token detected, no OIDC provider configured"; - - public AbstractOAuth2AuthenticationProvider getOAuth2Provider( String id ) { return authProvidersRegistrationService.getOAuth2AuthProvidersMap().get(id); } @@ -1009,7 +1004,7 @@ public UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String bea // Ensure at least one OIDC provider is configured to validate the token. if (providers.isEmpty()) { logger.log(Level.WARNING, "Bearer token detected, no OIDC provider configured"); - throw new AuthorizationException(ERROR_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED); + throw new AuthorizationException(BundleUtil.getStringFromBundle("authenticationServiceBean.errors.bearerTokenDetectedNoOIDCProviderConfigured")); } // Attempt to validate the token with each configured OIDC provider. @@ -1029,12 +1024,12 @@ public UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String bea } } catch (ParseException e) { logger.log(Level.FINE, "Bearer token detected, unable to parse bearer token (invalid Token)", e); - throw new AuthorizationException(ERROR_MESSAGE_INVALID_BEARER_TOKEN); + throw new AuthorizationException(BundleUtil.getStringFromBundle("authenticationServiceBean.errors.invalidBearerToken")); } // If no provider validated the token, throw an authorization exception. logger.log(Level.FINE, "Bearer token detected, yet no configured OIDC provider validated it."); - throw new AuthorizationException(ERROR_MESSAGE_UNAUTHORIZED_BEARER_TOKEN); + throw new AuthorizationException(BundleUtil.getStringFromBundle("authenticationServiceBean.errors.unauthorizedBearerToken")); } /** diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java index 69d24550e9f..6b04e1bb15f 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java @@ -9,6 +9,9 @@ import edu.harvard.iq.dataverse.engine.command.*; import edu.harvard.iq.dataverse.engine.command.exception.CommandException; import edu.harvard.iq.dataverse.engine.command.exception.IllegalCommandException; +import edu.harvard.iq.dataverse.engine.command.exception.PermissionException; +import edu.harvard.iq.dataverse.util.BundleUtil; +import jakarta.ejb.EJBException; @RequiredPermissions({}) public class RegisterOidcUserCommand extends AbstractVoidCommand { @@ -16,8 +19,6 @@ public class RegisterOidcUserCommand extends AbstractVoidCommand { private final String bearerToken; private final UserDTO userDTO; - private static final String ERROR_USER_ALREADY_REGISTERED_WITH_TOKEN = "User is already registered with this token"; - public RegisterOidcUserCommand(DataverseRequest aRequest, String bearerToken, UserDTO userDTO) { super(aRequest, (DvObject) null); this.bearerToken = bearerToken; @@ -30,7 +31,7 @@ protected void executeImpl(CommandContext ctxt) throws CommandException { UserRecordIdentifier userRecordIdentifier = ctxt.authentication().verifyOidcBearerTokenAndGetUserIdentifier(bearerToken); User user = ctxt.authentication().lookupUser(userRecordIdentifier); if (user != null) { - throw new IllegalCommandException(ERROR_USER_ALREADY_REGISTERED_WITH_TOKEN, this); + throw new IllegalCommandException(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userAlreadyRegisteredWithToken"), this); } AuthenticatedUserDisplayInfo authenticatedUserDisplayInfo = new AuthenticatedUserDisplayInfo( userDTO.firstName, @@ -40,8 +41,10 @@ protected void executeImpl(CommandContext ctxt) throws CommandException { "" ); ctxt.authentication().createAuthenticatedUser(userRecordIdentifier, userDTO.username, authenticatedUserDisplayInfo, true); - } catch (AuthorizationException e) { - throw new RuntimeException(e); + } catch (AuthorizationException authorizationException) { + throw new PermissionException(authorizationException.getMessage(), this, null, null); + } catch (EJBException ejbException) { + throw new CommandException(ejbException.getMessage(), this); } } } diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index 043cb5f6394..97ff7ddebaa 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -3066,3 +3066,14 @@ openapi.exception.unaligned=Unaligned parameters on Headers [{0}] and Request [{ users.api.errors.bearerAuthFeatureFlagDisabled=This endpoint is only available when bearer authentication feature flag is enabled. users.api.errors.bearerTokenRequired=Bearer token required. users.api.userRegistered=User registered. + +#RegisterOidcUserCommand.java +registerOidcUserCommand.errors.userAlreadyRegisteredWithToken=User is already registered with this token. + +#BearerTokenAuthMechanism.java +bearerTokenAuthMechanism.errors.tokenValidatedButNoRegisteredUser=Bearer token is validated, but there is no linked user account. + +#AuthenticationServiceBean.java +authenticationServiceBean.errors.unauthorizedBearerToken=Unauthorized bearer token. +authenticationServiceBean.errors.invalidBearerToken=Could not parse bearer token. +authenticationServiceBean.errors.bearerTokenDetectedNoOIDCProviderConfigured=Bearer token detected, no OIDC provider configured. diff --git a/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java b/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java index c8a1ef8f087..b6f4ec922dd 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java @@ -7,6 +7,7 @@ import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.settings.JvmSettings; +import edu.harvard.iq.dataverse.util.BundleUtil; import edu.harvard.iq.dataverse.util.testing.JvmSetting; import edu.harvard.iq.dataverse.util.testing.LocalJvmSettings; import org.junit.jupiter.api.BeforeEach; @@ -15,7 +16,6 @@ import jakarta.ws.rs.container.ContainerRequestContext; -import static edu.harvard.iq.dataverse.api.auth.BearerTokenAuthMechanism.*; import static org.junit.jupiter.api.Assertions.*; @LocalJvmSettings @@ -78,6 +78,6 @@ void testFindUserFromRequest_validToken_noAccount() throws AuthorizationExceptio WrappedForbiddenAuthErrorResponse wrappedForbiddenAuthErrorResponse = assertThrows(WrappedForbiddenAuthErrorResponse.class, () -> sut.findUserFromRequest(testContainerRequest)); //then - assertEquals(RESPONSE_MESSAGE_BEARER_TOKEN_VALIDATED_UNREGISTERED_USER, wrappedForbiddenAuthErrorResponse.getMessage()); + assertEquals(BundleUtil.getStringFromBundle("bearerTokenAuthMechanism.errors.tokenValidatedButNoRegisteredUser"), wrappedForbiddenAuthErrorResponse.getMessage()); } } diff --git a/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java b/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java index be98bcb516d..b2e4767a27d 100644 --- a/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java @@ -6,6 +6,7 @@ import edu.harvard.iq.dataverse.authorization.providers.oauth2.oidc.OIDCAuthProvider; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.User; +import edu.harvard.iq.dataverse.util.BundleUtil; import jakarta.persistence.EntityManager; import jakarta.persistence.NoResultException; import jakarta.persistence.TypedQuery; @@ -17,7 +18,6 @@ import java.util.Map; import java.util.Optional; -import static edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean.*; import static org.junit.jupiter.api.Assertions.*; public class AuthenticationServiceBeanTest { @@ -42,7 +42,7 @@ void testLookupUserByOidcBearerToken_no_OidcProvider() { () -> sut.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)); // Then the exception message should indicate no OIDC provider is configured - assertEquals(ERROR_MESSAGE_BEARER_TOKEN_DETECTED_NO_OIDC_PROVIDER_CONFIGURED, exception.getMessage()); + assertEquals(BundleUtil.getStringFromBundle("authenticationServiceBean.errors.bearerTokenDetectedNoOIDCProviderConfigured"), exception.getMessage()); } @Test @@ -57,7 +57,7 @@ void testLookupUserByOidcBearerToken_oneProvider_invalidToken_1() throws ParseEx () -> sut.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)); // Then the exception message should indicate an unauthorized token - assertEquals(ERROR_MESSAGE_UNAUTHORIZED_BEARER_TOKEN, exception.getMessage()); + assertEquals(BundleUtil.getStringFromBundle("authenticationServiceBean.errors.unauthorizedBearerToken"), exception.getMessage()); } @Test @@ -72,7 +72,7 @@ void testLookupUserByOidcBearerToken_oneProvider_invalidToken_2() throws ParseEx () -> sut.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)); // Then the exception message should indicate an unauthorized token - assertEquals(ERROR_MESSAGE_UNAUTHORIZED_BEARER_TOKEN, exception.getMessage()); + assertEquals(BundleUtil.getStringFromBundle("authenticationServiceBean.errors.unauthorizedBearerToken"), exception.getMessage()); } @Test From 35396773c9f99f06c45c0627d155ad66419c1f34 Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 4 Nov 2024 13:24:54 +0000 Subject: [PATCH 020/101] Added: managing user terms acceptance in registration --- .../java/edu/harvard/iq/dataverse/api/dto/UserDTO.java | 9 +++++++++ .../engine/command/impl/RegisterOidcUserCommand.java | 3 +++ src/main/java/propertyFiles/Bundle.properties | 1 + 3 files changed, 13 insertions(+) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java b/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java index c81fc99d549..ff57f176c4f 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java @@ -5,6 +5,7 @@ public class UserDTO { public String firstName; public String lastName; public String emailAddress; + public boolean termsAccepted; public String getUsername() { return username; @@ -37,4 +38,12 @@ public String getEmailAddress() { public void setEmailAddress(String emailAddress) { this.emailAddress = emailAddress; } + + public boolean isTermsAccepted() { + return termsAccepted; + } + + public void setTermsAccepted(boolean termsAccepted) { + this.termsAccepted = termsAccepted; + } } diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java index 6b04e1bb15f..ddd99d5961c 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java @@ -27,6 +27,9 @@ public RegisterOidcUserCommand(DataverseRequest aRequest, String bearerToken, Us @Override protected void executeImpl(CommandContext ctxt) throws CommandException { + if (!userDTO.termsAccepted) { + throw new IllegalCommandException(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userShouldAcceptTerms"), this); + } try { UserRecordIdentifier userRecordIdentifier = ctxt.authentication().verifyOidcBearerTokenAndGetUserIdentifier(bearerToken); User user = ctxt.authentication().lookupUser(userRecordIdentifier); diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index 97ff7ddebaa..72dfbb55531 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -3069,6 +3069,7 @@ users.api.userRegistered=User registered. #RegisterOidcUserCommand.java registerOidcUserCommand.errors.userAlreadyRegisteredWithToken=User is already registered with this token. +registerOidcUserCommand.errors.userShouldAcceptTerms=User should accept Dataverse General Terms of Use. #BearerTokenAuthMechanism.java bearerTokenAuthMechanism.errors.tokenValidatedButNoRegisteredUser=Bearer token is validated, but there is no linked user account. From 43805f00cccf3fc67858328743e42cc578b9657d Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 4 Nov 2024 13:29:52 +0000 Subject: [PATCH 021/101] Refactor: registerOidcUser endpoint --- .../java/edu/harvard/iq/dataverse/api/Users.java | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Users.java b/src/main/java/edu/harvard/iq/dataverse/api/Users.java index 88b04811d00..4da295b8c17 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Users.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Users.java @@ -276,15 +276,11 @@ public Response registerOidcUser(@Context ContainerRequestContext crc, String bo if (bearerToken.isEmpty()) { return error(Response.Status.BAD_REQUEST, BundleUtil.getStringFromBundle("users.api.errors.bearerTokenRequired")); } - JsonObject userJson; - try { - userJson = JsonUtil.getJsonObject(body); - execCommand(new RegisterOidcUserCommand(createDataverseRequest(getRequestUser(crc)), bearerToken.get(), jsonParser().parseUserDTO(userJson))); + return response(req -> { + JsonObject userJson = JsonUtil.getJsonObject(body); + execCommand(new RegisterOidcUserCommand(req, bearerToken.get(), jsonParser().parseUserDTO(userJson))); return ok(BundleUtil.getStringFromBundle("users.api.userRegistered")); - } catch (Exception e){ - return error(Response.Status.BAD_REQUEST, "Error calling RegisterOidcUserCommand: " + e.getLocalizedMessage()); - } - + }, getRequestUser(crc)); } // TODO: Remove duplication with BearerTokenAuthMechanism From 63790dbfbedb531fe730abae15e76b5fcdc4db1c Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 4 Nov 2024 13:43:37 +0000 Subject: [PATCH 022/101] Refactor: getRequestBearerToken extracted to AuthUtil --- .../edu/harvard/iq/dataverse/api/Users.java | 10 +------- .../iq/dataverse/api/auth/AuthUtil.java | 24 +++++++++++++++++++ .../api/auth/BearerTokenAuthMechanism.java | 20 +++------------- 3 files changed, 28 insertions(+), 26 deletions(-) create mode 100644 src/main/java/edu/harvard/iq/dataverse/api/auth/AuthUtil.java diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Users.java b/src/main/java/edu/harvard/iq/dataverse/api/Users.java index 4da295b8c17..d1bf5160fea 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Users.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Users.java @@ -14,6 +14,7 @@ import edu.harvard.iq.dataverse.util.BundleUtil; import edu.harvard.iq.dataverse.util.FileUtil; +import static edu.harvard.iq.dataverse.api.auth.AuthUtil.getRequestBearerToken; import static edu.harvard.iq.dataverse.util.json.JsonPrinter.json; import java.util.Arrays; @@ -282,13 +283,4 @@ public Response registerOidcUser(@Context ContainerRequestContext crc, String bo return ok(BundleUtil.getStringFromBundle("users.api.userRegistered")); }, getRequestUser(crc)); } - - // TODO: Remove duplication with BearerTokenAuthMechanism - private Optional getRequestBearerToken(ContainerRequestContext containerRequestContext) { - String headerParamBearerToken = containerRequestContext.getHeaderString(HttpHeaders.AUTHORIZATION); - if (headerParamBearerToken != null && headerParamBearerToken.toLowerCase().startsWith("Bearer" + " ")) { - return Optional.of(headerParamBearerToken); - } - return Optional.empty(); - } } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthUtil.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthUtil.java new file mode 100644 index 00000000000..267b6e86a8c --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthUtil.java @@ -0,0 +1,24 @@ +package edu.harvard.iq.dataverse.api.auth; + +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.core.HttpHeaders; + +import java.util.Optional; + +public class AuthUtil { + + private static final String BEARER_AUTH_SCHEME = "Bearer"; + + /** + * Retrieve the raw, encoded token value from the Authorization Bearer HTTP header as defined in RFC 6750 + * + * @return An {@link Optional} either empty if not present or the raw token from the header + */ + public static Optional getRequestBearerToken(ContainerRequestContext containerRequestContext) { + String headerParamBearerToken = containerRequestContext.getHeaderString(HttpHeaders.AUTHORIZATION); + if (headerParamBearerToken != null && headerParamBearerToken.toLowerCase().startsWith(BEARER_AUTH_SCHEME.toLowerCase() + " ")) { + return Optional.of(headerParamBearerToken); + } + return Optional.empty(); + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java index 0e353a8e404..d48a25824ec 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java @@ -10,14 +10,14 @@ import edu.harvard.iq.dataverse.util.BundleUtil; import jakarta.inject.Inject; import jakarta.ws.rs.container.ContainerRequestContext; -import jakarta.ws.rs.core.HttpHeaders; import java.util.Optional; import java.util.logging.Level; import java.util.logging.Logger; +import static edu.harvard.iq.dataverse.api.auth.AuthUtil.getRequestBearerToken; + public class BearerTokenAuthMechanism implements AuthMechanism { - private static final String BEARER_AUTH_SCHEME = "Bearer"; private static final Logger logger = Logger.getLogger(BearerTokenAuthMechanism.class.getCanonicalName()); @Inject @@ -45,24 +45,10 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) } if (authUser == null) { - logger.log(Level.WARNING, - "Bearer token detected, OIDC provider validated the token but no linked UserAccount"); + logger.log(Level.WARNING, "Bearer token detected, OIDC provider validated the token but no linked UserAccount"); throw new WrappedForbiddenAuthErrorResponse(BundleUtil.getStringFromBundle("bearerTokenAuthMechanism.errors.tokenValidatedButNoRegisteredUser")); } return userSvc.updateLastApiUseTime(authUser); } - - /** - * Retrieve the raw, encoded token value from the Authorization Bearer HTTP header as defined in RFC 6750 - * - * @return An {@link Optional} either empty if not present or the raw token from the header - */ - private Optional getRequestBearerToken(ContainerRequestContext containerRequestContext) { - String headerParamBearerToken = containerRequestContext.getHeaderString(HttpHeaders.AUTHORIZATION); - if (headerParamBearerToken != null && headerParamBearerToken.toLowerCase().startsWith(BEARER_AUTH_SCHEME.toLowerCase() + " ")) { - return Optional.of(headerParamBearerToken); - } - return Optional.empty(); - } } From 37afa9864fe9878e6b481a3ff558d5d7a8bdc88b Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 4 Nov 2024 13:45:55 +0000 Subject: [PATCH 023/101] Fixed: priority order in CompoundAuthMechanism to allow session and bearer token auth feature flags compatibility --- .../harvard/iq/dataverse/api/auth/CompoundAuthMechanism.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/CompoundAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/CompoundAuthMechanism.java index 801e2752b9e..e5be5144897 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/CompoundAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/CompoundAuthMechanism.java @@ -5,6 +5,7 @@ import jakarta.inject.Inject; import jakarta.ws.rs.container.ContainerRequestContext; + import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -19,9 +20,9 @@ public class CompoundAuthMechanism implements AuthMechanism { private final List authMechanisms = new ArrayList<>(); @Inject - public CompoundAuthMechanism(ApiKeyAuthMechanism apiKeyAuthMechanism, WorkflowKeyAuthMechanism workflowKeyAuthMechanism, SignedUrlAuthMechanism signedUrlAuthMechanism, SessionCookieAuthMechanism sessionCookieAuthMechanism, BearerTokenAuthMechanism bearerTokenAuthMechanism) { + public CompoundAuthMechanism(ApiKeyAuthMechanism apiKeyAuthMechanism, WorkflowKeyAuthMechanism workflowKeyAuthMechanism, SignedUrlAuthMechanism signedUrlAuthMechanism, BearerTokenAuthMechanism bearerTokenAuthMechanism, SessionCookieAuthMechanism sessionCookieAuthMechanism) { // Auth mechanisms should be ordered by priority here - add(apiKeyAuthMechanism, workflowKeyAuthMechanism, signedUrlAuthMechanism, sessionCookieAuthMechanism,bearerTokenAuthMechanism); + add(apiKeyAuthMechanism, workflowKeyAuthMechanism, signedUrlAuthMechanism, bearerTokenAuthMechanism, sessionCookieAuthMechanism); } public CompoundAuthMechanism(AuthMechanism... authMechanisms) { From 004155233646c08393b22b93d224467feea76474 Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 4 Nov 2024 15:57:51 +0000 Subject: [PATCH 024/101] Added: completed UserDTO fields --- .../harvard/iq/dataverse/api/dto/UserDTO.java | 28 +++++++++++++++---- .../command/impl/RegisterOidcUserCommand.java | 15 +++++----- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java b/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java index ff57f176c4f..df1920c4d25 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/dto/UserDTO.java @@ -1,11 +1,13 @@ package edu.harvard.iq.dataverse.api.dto; public class UserDTO { - public String username; - public String firstName; - public String lastName; - public String emailAddress; - public boolean termsAccepted; + private String username; + private String firstName; + private String lastName; + private String emailAddress; + private String affiliation; + private String position; + private boolean termsAccepted; public String getUsername() { return username; @@ -39,6 +41,22 @@ public void setEmailAddress(String emailAddress) { this.emailAddress = emailAddress; } + public String getAffiliation() { + return affiliation; + } + + public void setAffiliation(String affiliation) { + this.affiliation = affiliation; + } + + public String getPosition() { + return position; + } + + public void setPosition(String position) { + this.position = position; + } + public boolean isTermsAccepted() { return termsAccepted; } diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java index ddd99d5961c..cfa7eccc284 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java @@ -27,9 +27,10 @@ public RegisterOidcUserCommand(DataverseRequest aRequest, String bearerToken, Us @Override protected void executeImpl(CommandContext ctxt) throws CommandException { - if (!userDTO.termsAccepted) { + if (!userDTO.isTermsAccepted()) { throw new IllegalCommandException(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userShouldAcceptTerms"), this); } + // TODO check username and email not already in use try { UserRecordIdentifier userRecordIdentifier = ctxt.authentication().verifyOidcBearerTokenAndGetUserIdentifier(bearerToken); User user = ctxt.authentication().lookupUser(userRecordIdentifier); @@ -37,13 +38,13 @@ protected void executeImpl(CommandContext ctxt) throws CommandException { throw new IllegalCommandException(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userAlreadyRegisteredWithToken"), this); } AuthenticatedUserDisplayInfo authenticatedUserDisplayInfo = new AuthenticatedUserDisplayInfo( - userDTO.firstName, - userDTO.lastName, - userDTO.emailAddress, - "", - "" + userDTO.getFirstName(), + userDTO.getLastName(), + userDTO.getEmailAddress(), + userDTO.getAffiliation(), + userDTO.getPosition() ); - ctxt.authentication().createAuthenticatedUser(userRecordIdentifier, userDTO.username, authenticatedUserDisplayInfo, true); + ctxt.authentication().createAuthenticatedUser(userRecordIdentifier, userDTO.getUsername(), authenticatedUserDisplayInfo, true); } catch (AuthorizationException authorizationException) { throw new PermissionException(authorizationException.getMessage(), this, null, null); } catch (EJBException ejbException) { From a021c9be96150d9b33170067c1ca967cf1d15811 Mon Sep 17 00:00:00 2001 From: GPortas Date: Tue, 5 Nov 2024 13:11:15 +0000 Subject: [PATCH 025/101] Added: json parse logic for register user --- .../edu/harvard/iq/dataverse/api/Users.java | 8 +++++- .../iq/dataverse/util/json/JsonParser.java | 27 +++++++++++++++---- src/main/java/propertyFiles/Bundle.properties | 1 + 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Users.java b/src/main/java/edu/harvard/iq/dataverse/api/Users.java index d1bf5160fea..5bc92a88180 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Users.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Users.java @@ -17,12 +17,14 @@ import static edu.harvard.iq.dataverse.api.auth.AuthUtil.getRequestBearerToken; import static edu.harvard.iq.dataverse.util.json.JsonPrinter.json; +import java.text.MessageFormat; import java.util.Arrays; import java.util.List; import java.util.Optional; import java.util.logging.Level; import java.util.logging.Logger; +import edu.harvard.iq.dataverse.util.json.JsonParseException; import edu.harvard.iq.dataverse.util.json.JsonUtil; import jakarta.ejb.Stateless; import jakarta.json.JsonArray; @@ -279,7 +281,11 @@ public Response registerOidcUser(@Context ContainerRequestContext crc, String bo } return response(req -> { JsonObject userJson = JsonUtil.getJsonObject(body); - execCommand(new RegisterOidcUserCommand(req, bearerToken.get(), jsonParser().parseUserDTO(userJson))); + try { + execCommand(new RegisterOidcUserCommand(req, bearerToken.get(), jsonParser().parseUserDTO(userJson))); + } catch (JsonParseException e) { + return error(Response.Status.BAD_REQUEST, MessageFormat.format(BundleUtil.getStringFromBundle("users.api.errors.jsonParseToUserDTO"), e.getMessage())); + } return ok(BundleUtil.getStringFromBundle("users.api.userRegistered")); }, getRequestUser(crc)); } diff --git a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java index e6a6f2d565f..caed0c4029c 100644 --- a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java +++ b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java @@ -49,6 +49,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.function.Function; import java.util.logging.Logger; import java.util.stream.Collectors; import jakarta.json.Json; @@ -241,11 +242,19 @@ public DataverseTheme parseDataverseTheme(JsonObject obj) { return theme; } - private static String getMandatoryString(JsonObject jobj, String name) throws JsonParseException { + private static T getMandatoryField(JsonObject jobj, String name, Function getter) throws JsonParseException { if (jobj.containsKey(name)) { - return jobj.getString(name); + return getter.apply(name); } - throw new JsonParseException("Field " + name + " is mandatory"); + throw new JsonParseException("Field '" + name + "' is mandatory"); + } + + private static String getMandatoryString(JsonObject jobj, String name) throws JsonParseException { + return getMandatoryField(jobj, name, jobj::getString); + } + + private static Boolean getMandatoryBoolean(JsonObject jobj, String name) throws JsonParseException { + return getMandatoryField(jobj, name, jobj::getBoolean); } public IpGroup parseIpGroup(JsonObject obj) { @@ -1054,7 +1063,15 @@ private void validate(String objectName, JsonObject jobject, String fieldName, V } } - public UserDTO parseUserDTO(JsonObject jobj) { - return new UserDTO(); + public UserDTO parseUserDTO(JsonObject jobj) throws JsonParseException { + UserDTO userDTO = new UserDTO(); + userDTO.setUsername(getMandatoryString(jobj, "username")); + userDTO.setEmailAddress(getMandatoryString(jobj, "emailAddress")); + userDTO.setFirstName(getMandatoryString(jobj, "firstName")); + userDTO.setLastName(getMandatoryString(jobj, "lastName")); + userDTO.setTermsAccepted(getMandatoryBoolean(jobj, "termsAccepted")); + userDTO.setAffiliation(jobj.getString("affiliation")); + userDTO.setPosition(jobj.getString("position")); + return userDTO; } } diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index 72dfbb55531..27e96d4318f 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -3065,6 +3065,7 @@ openapi.exception.unaligned=Unaligned parameters on Headers [{0}] and Request [{ #Users.java users.api.errors.bearerAuthFeatureFlagDisabled=This endpoint is only available when bearer authentication feature flag is enabled. users.api.errors.bearerTokenRequired=Bearer token required. +users.api.errors.jsonParseToUserDTO=Error parsing the POSTed User json: {0} users.api.userRegistered=User registered. #RegisterOidcUserCommand.java From bf601e68bcf00fc3c9437c758b94864abca8a757 Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 6 Nov 2024 13:34:19 +0000 Subject: [PATCH 026/101] Added: fields validation to RegisterOidcUserCommand --- .../iq/dataverse/api/AbstractApiBean.java | 24 +++++-- .../InvalidFieldsCommandException.java | 42 ++++++++++++ .../command/impl/RegisterOidcUserCommand.java | 67 +++++++++++++++---- src/main/java/propertyFiles/Bundle.properties | 5 +- 4 files changed, 119 insertions(+), 19 deletions(-) create mode 100644 src/main/java/edu/harvard/iq/dataverse/engine/command/exception/InvalidFieldsCommandException.java diff --git a/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java b/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java index 3257a3cc7ac..d34eb1755b6 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java @@ -14,14 +14,11 @@ import edu.harvard.iq.dataverse.dataset.DatasetTypeServiceBean; import edu.harvard.iq.dataverse.engine.command.Command; import edu.harvard.iq.dataverse.engine.command.DataverseRequest; -import edu.harvard.iq.dataverse.engine.command.exception.CommandException; -import edu.harvard.iq.dataverse.engine.command.exception.IllegalCommandException; -import edu.harvard.iq.dataverse.engine.command.exception.PermissionException; +import edu.harvard.iq.dataverse.engine.command.exception.*; import edu.harvard.iq.dataverse.engine.command.impl.GetDraftDatasetVersionCommand; import edu.harvard.iq.dataverse.engine.command.impl.GetLatestAccessibleDatasetVersionCommand; import edu.harvard.iq.dataverse.engine.command.impl.GetLatestPublishedDatasetVersionCommand; import edu.harvard.iq.dataverse.engine.command.impl.GetSpecificPublishedDatasetVersionCommand; -import edu.harvard.iq.dataverse.engine.command.exception.RateLimitCommandException; import edu.harvard.iq.dataverse.externaltools.ExternalToolServiceBean; import edu.harvard.iq.dataverse.license.LicenseServiceBean; import edu.harvard.iq.dataverse.pidproviders.PidUtil; @@ -56,6 +53,7 @@ import java.net.URI; import java.util.Arrays; import java.util.Collections; +import java.util.Map; import java.util.UUID; import java.util.concurrent.Callable; import java.util.logging.Level; @@ -635,6 +633,8 @@ protected T execCommand( Command cmd ) throws WrappedResponse { throw new WrappedResponse(error(Response.Status.UNAUTHORIZED, "User " + cmd.getRequest().getUser().getIdentifier() + " is not permitted to perform requested action.") ); + } catch (InvalidFieldsCommandException ex) { + throw new WrappedResponse(ex, badRequest(ex.getMessage(), ex.getFieldErrors())); } catch (CommandException ex) { Logger.getLogger(AbstractApiBean.class.getName()).log(Level.SEVERE, "Error while executing command " + cmd, ex); throw new WrappedResponse(ex, error(Status.INTERNAL_SERVER_ERROR, ex.getMessage())); @@ -809,6 +809,22 @@ protected Response badRequest( String msg ) { return error( Status.BAD_REQUEST, msg ); } + protected Response badRequest(String msg, Map fieldErrors) { + JsonObject fieldErrorsJson = Json.createObjectBuilder() + .add("fieldErrors", Json.createObjectBuilder(fieldErrors).build()) + .build(); + + return Response.status(Status.BAD_REQUEST) + .entity(NullSafeJsonBuilder.jsonObjectBuilder() + .add("status", ApiConstants.STATUS_ERROR) + .add("message", msg) + .add("fieldErrors", fieldErrorsJson) + .build() + ) + .type(MediaType.APPLICATION_JSON_TYPE) + .build(); + } + protected Response forbidden( String msg ) { return error( Status.FORBIDDEN, msg ); } diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/exception/InvalidFieldsCommandException.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/exception/InvalidFieldsCommandException.java new file mode 100644 index 00000000000..9bd1869f8a9 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/exception/InvalidFieldsCommandException.java @@ -0,0 +1,42 @@ +package edu.harvard.iq.dataverse.engine.command.exception; + +import edu.harvard.iq.dataverse.engine.command.Command; +import java.util.Map; + +public class InvalidFieldsCommandException extends CommandException { + + private final Map fieldErrors; + + /** + * Constructs a new InvalidFieldsCommandException with the specified detail message, + * command, and a map of field errors. + * + * @param message The detail message. + * @param aCommand The command where the exception was encountered. + * @param fieldErrors A map containing the fields as keys and the reasons for their errors as values. + */ + public InvalidFieldsCommandException(String message, Command aCommand, Map fieldErrors) { + super(message, aCommand); + this.fieldErrors = fieldErrors; + } + + /** + * Gets the map of fields and their corresponding error messages. + * + * @return The map of field errors. + */ + public Map getFieldErrors() { + return fieldErrors; + } + + /** + * Returns a string representation of this exception, including the + * message and details of the invalid fields and their errors. + * + * @return A string representation of this exception. + */ + @Override + public String toString() { + return super.toString() + ", fieldErrors=" + fieldErrors; + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java index cfa7eccc284..99a2d5f6c82 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java @@ -5,13 +5,15 @@ import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo; import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; -import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.engine.command.*; import edu.harvard.iq.dataverse.engine.command.exception.CommandException; import edu.harvard.iq.dataverse.engine.command.exception.IllegalCommandException; import edu.harvard.iq.dataverse.engine.command.exception.PermissionException; +import edu.harvard.iq.dataverse.engine.command.exception.InvalidFieldsCommandException; import edu.harvard.iq.dataverse.util.BundleUtil; -import jakarta.ejb.EJBException; + +import java.util.HashMap; +import java.util.Map; @RequiredPermissions({}) public class RegisterOidcUserCommand extends AbstractVoidCommand { @@ -27,28 +29,65 @@ public RegisterOidcUserCommand(DataverseRequest aRequest, String bearerToken, Us @Override protected void executeImpl(CommandContext ctxt) throws CommandException { + Map fieldErrors = validateUserFields(ctxt); + + if (!fieldErrors.isEmpty()) { + throw new InvalidFieldsCommandException( + BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.invalidFields"), + this, + fieldErrors + ); + } + + createUser(ctxt); + } + + private Map validateUserFields(CommandContext ctxt) { + Map fieldErrors = new HashMap<>(); + if (!userDTO.isTermsAccepted()) { - throw new IllegalCommandException(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userShouldAcceptTerms"), this); + fieldErrors.put("termsAccepted", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userShouldAcceptTerms")); + } + + if (isEmailInUse(ctxt, userDTO.getEmailAddress())) { + fieldErrors.put("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.emailAddressInUse")); + } + + if (isUsernameInUse(ctxt, userDTO.getUsername())) { + fieldErrors.put("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.usernameInUse")); } - // TODO check username and email not already in use + + return fieldErrors; + } + + private boolean isEmailInUse(CommandContext ctxt, String emailAddress) { + return ctxt.authentication().getAuthenticatedUserByEmail(emailAddress) != null; + } + + private boolean isUsernameInUse(CommandContext ctxt, String username) { + return ctxt.authentication().getAuthenticatedUser(username) != null; + } + + private void createUser(CommandContext ctxt) throws CommandException { try { UserRecordIdentifier userRecordIdentifier = ctxt.authentication().verifyOidcBearerTokenAndGetUserIdentifier(bearerToken); - User user = ctxt.authentication().lookupUser(userRecordIdentifier); - if (user != null) { + + if (ctxt.authentication().lookupUser(userRecordIdentifier) != null) { throw new IllegalCommandException(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userAlreadyRegisteredWithToken"), this); } - AuthenticatedUserDisplayInfo authenticatedUserDisplayInfo = new AuthenticatedUserDisplayInfo( + + AuthenticatedUserDisplayInfo userInfo = new AuthenticatedUserDisplayInfo( userDTO.getFirstName(), userDTO.getLastName(), userDTO.getEmailAddress(), - userDTO.getAffiliation(), - userDTO.getPosition() + userDTO.getAffiliation() != null ? userDTO.getAffiliation() : "", + userDTO.getPosition() != null ? userDTO.getPosition() : "" ); - ctxt.authentication().createAuthenticatedUser(userRecordIdentifier, userDTO.getUsername(), authenticatedUserDisplayInfo, true); - } catch (AuthorizationException authorizationException) { - throw new PermissionException(authorizationException.getMessage(), this, null, null); - } catch (EJBException ejbException) { - throw new CommandException(ejbException.getMessage(), this); + + ctxt.authentication().createAuthenticatedUser(userRecordIdentifier, userDTO.getUsername(), userInfo, true); + + } catch (AuthorizationException ex) { + throw new PermissionException(ex.getMessage(), this, null, null); } } } diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index 27e96d4318f..e5993ff3fad 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -3070,7 +3070,10 @@ users.api.userRegistered=User registered. #RegisterOidcUserCommand.java registerOidcUserCommand.errors.userAlreadyRegisteredWithToken=User is already registered with this token. -registerOidcUserCommand.errors.userShouldAcceptTerms=User should accept Dataverse General Terms of Use. +registerOidcUserCommand.errors.invalidFields=The provided fields are invalid for registering a new user. +registerOidcUserCommand.errors.userShouldAcceptTerms=Should be accepted (true). +registerOidcUserCommand.errors.emailAddressInUse=Already in use. +registerOidcUserCommand.errors.usernameInUse=Already in use. #BearerTokenAuthMechanism.java bearerTokenAuthMechanism.errors.tokenValidatedButNoRegisteredUser=Bearer token is validated, but there is no linked user account. From e544221db900623637ef1087e1768ecdea1dc8ef Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 6 Nov 2024 15:14:31 +0000 Subject: [PATCH 027/101] Changed: Bundle.properties values --- src/main/java/propertyFiles/Bundle.properties | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index e5993ff3fad..1ae846c338e 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -3071,9 +3071,9 @@ users.api.userRegistered=User registered. #RegisterOidcUserCommand.java registerOidcUserCommand.errors.userAlreadyRegisteredWithToken=User is already registered with this token. registerOidcUserCommand.errors.invalidFields=The provided fields are invalid for registering a new user. -registerOidcUserCommand.errors.userShouldAcceptTerms=Should be accepted (true). -registerOidcUserCommand.errors.emailAddressInUse=Already in use. -registerOidcUserCommand.errors.usernameInUse=Already in use. +registerOidcUserCommand.errors.userShouldAcceptTerms=Terms should be accepted. +registerOidcUserCommand.errors.emailAddressInUse=Email already in use. +registerOidcUserCommand.errors.usernameInUse=Username already in use. #BearerTokenAuthMechanism.java bearerTokenAuthMechanism.errors.tokenValidatedButNoRegisteredUser=Bearer token is validated, but there is no linked user account. From 753f6ebcfa54618579f14f244103a964290867fd Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 8 Nov 2024 10:25:31 +0000 Subject: [PATCH 028/101] Added: unit tests for RegisterOidcUserCommand --- .../impl/RegisterOidcUserCommandTest.java | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommandTest.java diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommandTest.java new file mode 100644 index 00000000000..bfc693cc308 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommandTest.java @@ -0,0 +1,166 @@ +package edu.harvard.iq.dataverse.engine.command.impl; + +import edu.harvard.iq.dataverse.api.dto.UserDTO; +import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo; +import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; +import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; +import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.engine.command.CommandContext; +import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import edu.harvard.iq.dataverse.engine.command.exception.IllegalCommandException; +import edu.harvard.iq.dataverse.engine.command.exception.InvalidFieldsCommandException; +import edu.harvard.iq.dataverse.engine.command.exception.PermissionException; +import edu.harvard.iq.dataverse.util.BundleUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +import static edu.harvard.iq.dataverse.mocks.MocksFactory.makeRequest; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +class RegisterOidcUserCommandTest { + + private static final String TEST_BEARER_TOKEN = "Bearer test"; + + private UserDTO userDTO; + + @Mock + private CommandContext context; + + @Mock + private AuthenticationServiceBean authServiceMock; + + @InjectMocks + private RegisterOidcUserCommand sut; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + setUpDefaultUserDTO(); + when(context.authentication()).thenReturn(authServiceMock); + sut = new RegisterOidcUserCommand(makeRequest(), TEST_BEARER_TOKEN, userDTO); + } + + private void setUpDefaultUserDTO() { + userDTO = new UserDTO(); + userDTO.setTermsAccepted(true); + userDTO.setFirstName("FirstName"); + userDTO.setLastName("LastName"); + userDTO.setUsername("username"); + userDTO.setEmailAddress("user@example.com"); + } + + @Test + public void execute_unacceptedTerms_availableEmailAndUsername() { + userDTO.setTermsAccepted(false); + when(authServiceMock.getAuthenticatedUserByEmail(userDTO.getEmailAddress())).thenReturn(null); + when(authServiceMock.getAuthenticatedUser(userDTO.getUsername())).thenReturn(null); + + assertThatThrownBy(() -> sut.execute(context)) + .isInstanceOf(InvalidFieldsCommandException.class) + .satisfies(exception -> { + InvalidFieldsCommandException ex = (InvalidFieldsCommandException) exception; + assertThat(ex.getFieldErrors()) + .containsEntry("termsAccepted", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userShouldAcceptTerms")) + .doesNotContainEntry("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.emailAddressInUse")) + .doesNotContainEntry("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.usernameInUse")); + }); + } + + @Test + public void execute_acceptedTerms_availableEmailAndUsername() { + AuthenticatedUser existingUser = new AuthenticatedUser(); + when(authServiceMock.getAuthenticatedUserByEmail(userDTO.getEmailAddress())).thenReturn(existingUser); + when(authServiceMock.getAuthenticatedUser(userDTO.getUsername())).thenReturn(existingUser); + + assertThatThrownBy(() -> sut.execute(context)) + .isInstanceOf(InvalidFieldsCommandException.class) + .satisfies(exception -> { + InvalidFieldsCommandException ex = (InvalidFieldsCommandException) exception; + assertThat(ex.getFieldErrors()) + .containsEntry("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.emailAddressInUse")) + .containsEntry("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.usernameInUse")) + .doesNotContainKey("termsAccepted"); + }); + } + + @Test + void execute_throwsPermissionException_onAuthorizationException() throws AuthorizationException { + String testAuthorizationExceptionMessage = "Authorization failed"; + when(context.authentication().verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)) + .thenThrow(new AuthorizationException(testAuthorizationExceptionMessage)); + + assertThatThrownBy(() -> sut.execute(context)) + .isInstanceOf(PermissionException.class) + .hasMessageContaining(testAuthorizationExceptionMessage); + + Mockito.verify(context.authentication(), times(1)) + .verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN); + } + + @Test + void execute_throwsIllegalCommandException_ifUserAlreadyRegisteredWithToken() throws AuthorizationException { + UserRecordIdentifier userRecordIdentifierMock = Mockito.mock(UserRecordIdentifier.class); + when(context.authentication().verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)) + .thenReturn(userRecordIdentifierMock); + when(context.authentication().lookupUser(userRecordIdentifierMock)).thenReturn(new AuthenticatedUser()); + + assertThatThrownBy(() -> sut.execute(context)) + .isInstanceOf(IllegalCommandException.class) + .hasMessageContaining(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userAlreadyRegisteredWithToken")); + + Mockito.verify(context.authentication(), times(1)) + .lookupUser(userRecordIdentifierMock); + } + + @Test + void execute_happyPath_withoutAffiliationAndPosition() throws AuthorizationException, CommandException { + UserRecordIdentifier userRecordIdentifierMock = mock(UserRecordIdentifier.class); + when(authServiceMock.verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(userRecordIdentifierMock); + + sut.execute(context); + + verify(authServiceMock, times(1)).createAuthenticatedUser( + eq(userRecordIdentifierMock), + eq(userDTO.getUsername()), + eq(new AuthenticatedUserDisplayInfo( + userDTO.getFirstName(), + userDTO.getLastName(), + userDTO.getEmailAddress(), + "", + "") + ), + eq(true) + ); + } + + @Test + void execute_happyPath_withAffiliationAndPosition() throws AuthorizationException, CommandException { + userDTO.setPosition("test position"); + userDTO.setAffiliation("test affiliation"); + + UserRecordIdentifier userRecordIdentifierMock = mock(UserRecordIdentifier.class); + when(authServiceMock.verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(userRecordIdentifierMock); + + sut.execute(context); + + verify(authServiceMock, times(1)).createAuthenticatedUser( + eq(userRecordIdentifierMock), + eq(userDTO.getUsername()), + eq(new AuthenticatedUserDisplayInfo( + userDTO.getFirstName(), + userDTO.getLastName(), + userDTO.getEmailAddress(), + userDTO.getAffiliation(), + userDTO.getPosition()) + ), + eq(true) + ); + } +} From 15b78bb6d80d49107a48c01c853858b9407e3ffa Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 8 Nov 2024 15:03:03 +0000 Subject: [PATCH 029/101] Removed: unnecessary auth annotation on register endpoint --- .../edu/harvard/iq/dataverse/api/Users.java | 26 +++++++++---------- .../iq/dataverse/api/auth/AuthUtil.java | 14 +++++----- .../api/auth/BearerTokenAuthMechanism.java | 13 +++++++++- 3 files changed, 32 insertions(+), 21 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Users.java b/src/main/java/edu/harvard/iq/dataverse/api/Users.java index 5bc92a88180..c3aefe4746f 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Users.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Users.java @@ -8,13 +8,14 @@ import edu.harvard.iq.dataverse.api.auth.AuthRequired; import edu.harvard.iq.dataverse.authorization.users.ApiToken; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.authorization.users.GuestUser; import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.engine.command.impl.*; import edu.harvard.iq.dataverse.settings.FeatureFlags; import edu.harvard.iq.dataverse.util.BundleUtil; import edu.harvard.iq.dataverse.util.FileUtil; -import static edu.harvard.iq.dataverse.api.auth.AuthUtil.getRequestBearerToken; +import static edu.harvard.iq.dataverse.api.auth.AuthUtil.extractBearerTokenFromHeaderParam; import static edu.harvard.iq.dataverse.util.json.JsonPrinter.json; import java.text.MessageFormat; @@ -269,24 +270,23 @@ public Response getTracesElement(@Context ContainerRequestContext crc, @Context } @POST - @AuthRequired @Path("register") - public Response registerOidcUser(@Context ContainerRequestContext crc, String body) { + public Response registerOidcUser(String body) { if (!FeatureFlags.API_BEARER_AUTH.enabled()) { return error(Response.Status.INTERNAL_SERVER_ERROR, BundleUtil.getStringFromBundle("users.api.errors.bearerAuthFeatureFlagDisabled")); } - Optional bearerToken = getRequestBearerToken(crc); + Optional bearerToken = extractBearerTokenFromHeaderParam(httpRequest.getHeader(HttpHeaders.AUTHORIZATION)); if (bearerToken.isEmpty()) { return error(Response.Status.BAD_REQUEST, BundleUtil.getStringFromBundle("users.api.errors.bearerTokenRequired")); } - return response(req -> { - JsonObject userJson = JsonUtil.getJsonObject(body); - try { - execCommand(new RegisterOidcUserCommand(req, bearerToken.get(), jsonParser().parseUserDTO(userJson))); - } catch (JsonParseException e) { - return error(Response.Status.BAD_REQUEST, MessageFormat.format(BundleUtil.getStringFromBundle("users.api.errors.jsonParseToUserDTO"), e.getMessage())); - } - return ok(BundleUtil.getStringFromBundle("users.api.userRegistered")); - }, getRequestUser(crc)); + JsonObject userJson = JsonUtil.getJsonObject(body); + try { + execCommand(new RegisterOidcUserCommand(createDataverseRequest(GuestUser.get()), bearerToken.get(), jsonParser().parseUserDTO(userJson))); + } catch (JsonParseException e) { + return error(Response.Status.BAD_REQUEST, MessageFormat.format(BundleUtil.getStringFromBundle("users.api.errors.jsonParseToUserDTO"), e.getMessage())); + } catch (WrappedResponse e) { + return e.getResponse(); + } + return ok(BundleUtil.getStringFromBundle("users.api.userRegistered")); } } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthUtil.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthUtil.java index 267b6e86a8c..36cd7c7f1df 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthUtil.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthUtil.java @@ -1,8 +1,5 @@ package edu.harvard.iq.dataverse.api.auth; -import jakarta.ws.rs.container.ContainerRequestContext; -import jakarta.ws.rs.core.HttpHeaders; - import java.util.Optional; public class AuthUtil { @@ -10,12 +7,15 @@ public class AuthUtil { private static final String BEARER_AUTH_SCHEME = "Bearer"; /** - * Retrieve the raw, encoded token value from the Authorization Bearer HTTP header as defined in RFC 6750 + * Extracts the Bearer token from the provided HTTP Authorization header value. + *

+ * Validates that the header value starts with the "Bearer" scheme as defined in RFC 6750. + * If the header is null, empty, or does not start with "Bearer ", an empty {@link Optional} is returned. * - * @return An {@link Optional} either empty if not present or the raw token from the header + * @param headerParamBearerToken the raw HTTP Authorization header value containing the Bearer token + * @return An {@link Optional} containing the raw Bearer token if present and valid; otherwise, an empty {@link Optional} */ - public static Optional getRequestBearerToken(ContainerRequestContext containerRequestContext) { - String headerParamBearerToken = containerRequestContext.getHeaderString(HttpHeaders.AUTHORIZATION); + public static Optional extractBearerTokenFromHeaderParam(String headerParamBearerToken) { if (headerParamBearerToken != null && headerParamBearerToken.toLowerCase().startsWith(BEARER_AUTH_SCHEME.toLowerCase() + " ")) { return Optional.of(headerParamBearerToken); } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java index d48a25824ec..9bfcb03a72b 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java @@ -10,12 +10,13 @@ import edu.harvard.iq.dataverse.util.BundleUtil; import jakarta.inject.Inject; import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.core.HttpHeaders; import java.util.Optional; import java.util.logging.Level; import java.util.logging.Logger; -import static edu.harvard.iq.dataverse.api.auth.AuthUtil.getRequestBearerToken; +import static edu.harvard.iq.dataverse.api.auth.AuthUtil.extractBearerTokenFromHeaderParam; public class BearerTokenAuthMechanism implements AuthMechanism { private static final Logger logger = Logger.getLogger(BearerTokenAuthMechanism.class.getCanonicalName()); @@ -51,4 +52,14 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) return userSvc.updateLastApiUseTime(authUser); } + + /** + * Retrieve the raw, encoded token value from the Authorization Bearer HTTP header as defined in RFC 6750 + * + * @return An {@link Optional} either empty if not present or the raw token from the header + */ + public static Optional getRequestBearerToken(ContainerRequestContext containerRequestContext) { + String headerParamBearerToken = containerRequestContext.getHeaderString(HttpHeaders.AUTHORIZATION); + return extractBearerTokenFromHeaderParam(headerParamBearerToken); + } } From 415e23b648ead7cb6dc4f94c02b06790512f7fce Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 8 Nov 2024 17:48:56 +0000 Subject: [PATCH 030/101] Added: users register endpoint IT and fixes --- .../edu/harvard/iq/dataverse/api/Users.java | 5 +- .../edu/harvard/iq/dataverse/api/UsersIT.java | 125 ++++++++++++++++++ .../edu/harvard/iq/dataverse/api/UtilIT.java | 9 ++ 3 files changed, 137 insertions(+), 2 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Users.java b/src/main/java/edu/harvard/iq/dataverse/api/Users.java index c3aefe4746f..cc9dee3b678 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Users.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Users.java @@ -31,6 +31,7 @@ import jakarta.json.JsonArray; import jakarta.json.JsonObject; import jakarta.json.JsonObjectBuilder; +import jakarta.json.stream.JsonParsingException; import jakarta.ws.rs.*; import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.core.*; @@ -279,10 +280,10 @@ public Response registerOidcUser(String body) { if (bearerToken.isEmpty()) { return error(Response.Status.BAD_REQUEST, BundleUtil.getStringFromBundle("users.api.errors.bearerTokenRequired")); } - JsonObject userJson = JsonUtil.getJsonObject(body); try { + JsonObject userJson = JsonUtil.getJsonObject(body); execCommand(new RegisterOidcUserCommand(createDataverseRequest(GuestUser.get()), bearerToken.get(), jsonParser().parseUserDTO(userJson))); - } catch (JsonParseException e) { + } catch (JsonParseException | JsonParsingException e) { return error(Response.Status.BAD_REQUEST, MessageFormat.format(BundleUtil.getStringFromBundle("users.api.errors.jsonParseToUserDTO"), e.getMessage())); } catch (WrappedResponse e) { return e.getResponse(); diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java index ce3b8bf75ff..b91281632f3 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java @@ -1,5 +1,7 @@ package edu.harvard.iq.dataverse.api; +import edu.harvard.iq.dataverse.api.dto.UserDTO; +import edu.harvard.iq.dataverse.util.BundleUtil; import io.restassured.RestAssured; import static io.restassured.RestAssured.given; import io.restassured.http.ContentType; @@ -515,6 +517,129 @@ public void testDeleteAuthenticatedUser() { } + @Test + public void testRegisterOidcUser() { + UserDTO userDTO = new UserDTO(); + userDTO.setUsername("testRegisterOidcUserUsername"); + userDTO.setEmailAddress("testregisteroidcuser@dataverse.com"); + userDTO.setFirstName("Firstname"); + userDTO.setLastName("Lastname"); + + // Should return error when empty token is passed + Response registerOidcUserResponse = UtilIT.registerOidcUser( + "{}", + "" + ); + registerOidcUserResponse.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()) + .body("message", equalTo(BundleUtil.getStringFromBundle("users.api.errors.bearerTokenRequired"))); + + // Should return error when a required field in the User JSON is missing (username) + registerOidcUserResponse = UtilIT.registerOidcUser( + "{" + + "\"firstName\":\"YourFirstName\"," + + "\"lastName\":\"YourLastName\"," + + "\"emailAddress\":\"yourEmail@example.com\"," + + "\"affiliation\":\"YourAffiliation\"," + + "\"position\":\"YourPosition\"," + + "\"termsAccepted\":true" + + "}", + "Bearer testBearerToken" + ); + registerOidcUserResponse.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()) + .body("message", equalTo("Error parsing the POSTed User json: Field 'username' is mandatory")); + + // Should return error when a required field in the User JSON is missing (firstName) + registerOidcUserResponse = UtilIT.registerOidcUser( + "{" + + "\"username\":\"yourUsername\"," + + "\"lastName\":\"YourLastName\"," + + "\"emailAddress\":\"yourEmail@example.com\"," + + "\"affiliation\":\"YourAffiliation\"," + + "\"position\":\"YourPosition\"," + + "\"termsAccepted\":true" + + "}", + "Bearer testBearerToken" + ); + registerOidcUserResponse.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()) + .body("message", equalTo("Error parsing the POSTed User json: Field 'firstName' is mandatory")); + + // Should return error when a required field in the User JSON is missing (lastName) + registerOidcUserResponse = UtilIT.registerOidcUser( + "{" + + "\"username\":\"yourUsername\"," + + "\"firstName\":\"YourFirstName\"," + + "\"emailAddress\":\"yourEmail@example.com\"," + + "\"affiliation\":\"YourAffiliation\"," + + "\"position\":\"YourPosition\"," + + "\"termsAccepted\":true" + + "}", + "Bearer testBearerToken" + ); + registerOidcUserResponse.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()) + .body("message", equalTo("Error parsing the POSTed User json: Field 'lastName' is mandatory")); + + // Should return error when a required field in the User JSON is missing (emailAddress) + registerOidcUserResponse = UtilIT.registerOidcUser( + "{" + + "\"username\":\"yourUsername\"," + + "\"firstName\":\"YourFirstName\"," + + "\"lastName\":\"YourLastName\"," + + "\"affiliation\":\"YourAffiliation\"," + + "\"position\":\"YourPosition\"," + + "\"termsAccepted\":true" + + "}", + "Bearer testBearerToken" + ); + registerOidcUserResponse.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()) + .body("message", equalTo("Error parsing the POSTed User json: Field 'emailAddress' is mandatory")); + + // Should return error when a required field in the User JSON is missing (termsAccepted) + registerOidcUserResponse = UtilIT.registerOidcUser( + "{" + + "\"username\":\"yourUsername\"," + + "\"firstName\":\"YourFirstName\"," + + "\"lastName\":\"YourLastName\"," + + "\"emailAddress\":\"yourEmail@example.com\"," + + "\"affiliation\":\"YourAffiliation\"," + + "\"position\":\"YourPosition\"" + + "}", + "Bearer testBearerToken" + ); + registerOidcUserResponse.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()) + .body("message", equalTo("Error parsing the POSTed User json: Field 'termsAccepted' is mandatory")); + + // Should return error when a malformed User JSON is sent + registerOidcUserResponse = UtilIT.registerOidcUser( + "{{{user:abcde}", + "Bearer testBearerToken" + ); + registerOidcUserResponse.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()) + .body("message", equalTo("Error parsing the POSTed User json: Invalid token=CURLYOPEN at (line no=1, column no=2, offset=1). Expected tokens are: [STRING]")); + + // Should return error when User JSON is valid but the provided token is invalid + registerOidcUserResponse = UtilIT.registerOidcUser( + "{" + + "\"username\":\"yourUsername\"," + + "\"firstName\":\"YourFirstName\"," + + "\"lastName\":\"YourLastName\"," + + "\"emailAddress\":\"yourEmail@example.com\"," + + "\"affiliation\":\"YourAffiliation\"," + + "\"position\":\"YourPosition\"," + + "\"termsAccepted\":true" + + "}", + "Bearer testBearerToken" + ); + registerOidcUserResponse.prettyPrint(); + // TODO: Fix perms User :guest is not permitted to perform requested action. + } + private Response convertUserFromBcryptToSha1(long idOfBcryptUserToConvert, String password) { JsonObjectBuilder data = Json.createObjectBuilder(); data.add("builtinUserId", idOfBcryptUserToConvert); diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java index 502f1ecb0a8..e813f3a2f7b 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java @@ -1,5 +1,6 @@ package edu.harvard.iq.dataverse.api; +import edu.harvard.iq.dataverse.api.dto.UserDTO; import edu.harvard.iq.dataverse.util.json.NullSafeJsonBuilder; import io.restassured.http.ContentType; import io.restassured.path.json.JsonPath; @@ -24,6 +25,7 @@ import edu.harvard.iq.dataverse.api.datadeposit.SwordConfigurationImpl; import io.restassured.path.xml.XmlPath; import edu.harvard.iq.dataverse.mydata.MyDataFilterParams; +import jakarta.ws.rs.core.HttpHeaders; import org.apache.commons.lang3.StringUtils; import org.junit.jupiter.api.Test; import edu.harvard.iq.dataverse.settings.SettingsServiceBean; @@ -4241,4 +4243,11 @@ static Response deleteDatasetTypes(long doomed, String apiToken) { .delete("/api/datasets/datasetTypes/" + doomed); } + static Response registerOidcUser(String jsonIn, String bearerToken) { + return given() + .header(HttpHeaders.AUTHORIZATION, bearerToken) + .body(jsonIn) + .contentType(ContentType.JSON) + .post("/api/users/register"); + } } From b1901c243fd6c96e4803322379b15fb9f9fe946b Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 8 Nov 2024 18:33:03 +0000 Subject: [PATCH 031/101] Fixed: users register endpoint response body structure when there are field errors --- .../java/edu/harvard/iq/dataverse/api/AbstractApiBean.java | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java b/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java index d34eb1755b6..925ced8acd1 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java @@ -810,15 +810,11 @@ protected Response badRequest( String msg ) { } protected Response badRequest(String msg, Map fieldErrors) { - JsonObject fieldErrorsJson = Json.createObjectBuilder() - .add("fieldErrors", Json.createObjectBuilder(fieldErrors).build()) - .build(); - return Response.status(Status.BAD_REQUEST) .entity(NullSafeJsonBuilder.jsonObjectBuilder() .add("status", ApiConstants.STATUS_ERROR) .add("message", msg) - .add("fieldErrors", fieldErrorsJson) + .add("fieldErrors", Json.createObjectBuilder(fieldErrors).build()) .build() ) .type(MediaType.APPLICATION_JSON_TYPE) From fadebca251e7b4731b942e223a58551ea9d15044 Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 8 Nov 2024 18:34:44 +0000 Subject: [PATCH 032/101] Added: test assertions in UsersIT for register endpoint --- .../edu/harvard/iq/dataverse/api/UsersIT.java | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java index b91281632f3..ebb8d52a9fd 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java @@ -623,7 +623,27 @@ public void testRegisterOidcUser() { .statusCode(BAD_REQUEST.getStatusCode()) .body("message", equalTo("Error parsing the POSTed User json: Invalid token=CURLYOPEN at (line no=1, column no=2, offset=1). Expected tokens are: [STRING]")); - // Should return error when User JSON is valid but the provided token is invalid + // Should return error when the provided User JSON have invalid fields + registerOidcUserResponse = UtilIT.registerOidcUser( + "{" + + "\"username\":\"dataverseAdmin\"," + + "\"firstName\":\"YourFirstName\"," + + "\"lastName\":\"YourLastName\"," + + "\"emailAddress\":\"dataverse@mailinator.com\"," + + "\"affiliation\":\"YourAffiliation\"," + + "\"position\":\"YourPosition\"," + + "\"termsAccepted\":false" + + "}", + "Bearer testBearerToken" + ); + registerOidcUserResponse.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()) + .body("message", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.invalidFields"))) + .body("fieldErrors.emailAddress", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.emailAddressInUse"))) + .body("fieldErrors.termsAccepted", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userShouldAcceptTerms"))) + .body("fieldErrors.username", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.usernameInUse"))); + + // Should return error when the provided User JSON is valid but the provided Bearer token is invalid registerOidcUserResponse = UtilIT.registerOidcUser( "{" + "\"username\":\"yourUsername\"," @@ -636,8 +656,9 @@ public void testRegisterOidcUser() { + "}", "Bearer testBearerToken" ); - registerOidcUserResponse.prettyPrint(); - // TODO: Fix perms User :guest is not permitted to perform requested action. + registerOidcUserResponse.then().assertThat() + .statusCode(UNAUTHORIZED.getStatusCode()); + // TODO: Complete test assertions } private Response convertUserFromBcryptToSha1(long idOfBcryptUserToConvert, String password) { From b993ba1e72b8ad65a68e104447321e2b01ed12c8 Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 11 Nov 2024 10:44:13 +0000 Subject: [PATCH 033/101] Changed: handling more specific response messages on command PermissionException --- .../iq/dataverse/api/AbstractApiBean.java | 16 +++++-- .../exception/PermissionException.java | 46 +++++++++++-------- .../command/impl/RegisterOidcUserCommand.java | 2 +- .../edu/harvard/iq/dataverse/api/UsersIT.java | 5 +- 4 files changed, 46 insertions(+), 23 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java b/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java index 925ced8acd1..3c1074b75bb 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java @@ -629,10 +629,20 @@ protected T execCommand( Command cmd ) throws WrappedResponse { * sometimes?) doesn't have much information in it: * * "User @jsmith is not permitted to perform requested action." + * + * Update (11/11/2024): + * + * An {@code isDetailedMessageRequired} flag has been added to {@code PermissionException} to selectively return more + * specific error messages when the generic message (e.g. "User :guest is not permitted to perform requested action") + * lacks sufficient context. This approach aims to provide valuable permission-related details in cases where it + * could help users better understand their permission issues without exposing unnecessary internal information. */ - throw new WrappedResponse(error(Response.Status.UNAUTHORIZED, - "User " + cmd.getRequest().getUser().getIdentifier() + " is not permitted to perform requested action.") ); - + if (ex.isDetailedMessageRequired()) { + throw new WrappedResponse(error(Response.Status.UNAUTHORIZED, ex.getMessage())); + } else { + throw new WrappedResponse(error(Response.Status.UNAUTHORIZED, + "User " + cmd.getRequest().getUser().getIdentifier() + " is not permitted to perform requested action.")); + } } catch (InvalidFieldsCommandException ex) { throw new WrappedResponse(ex, badRequest(ex.getMessage(), ex.getFieldErrors())); } catch (CommandException ex) { diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/exception/PermissionException.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/exception/PermissionException.java index a7881fc7b6e..2ca63c9c4aa 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/exception/PermissionException.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/exception/PermissionException.java @@ -3,6 +3,7 @@ import edu.harvard.iq.dataverse.DvObject; import edu.harvard.iq.dataverse.authorization.Permission; import edu.harvard.iq.dataverse.engine.command.Command; + import java.util.Set; /** @@ -12,22 +13,31 @@ * @author michael */ public class PermissionException extends CommandException { - - private final Set required; - private final DvObject dvObject; - - public PermissionException(String message, Command failedCommand, Set required, DvObject aDvObject ) { - super(message, failedCommand); - this.required = required; - dvObject = aDvObject; - } - - public Set getRequiredPermissions() { - return required; - } - - public DvObject getDvObject() { - return dvObject; - } - + + private final Set required; + private final DvObject dvObject; + private final boolean isDetailedMessageRequired; + + public PermissionException(String message, Command failedCommand, Set required, DvObject dvObject, boolean isDetailedMessageRequired) { + super(message, failedCommand); + this.required = required; + this.dvObject = dvObject; + this.isDetailedMessageRequired = isDetailedMessageRequired; + } + + public PermissionException(String message, Command failedCommand, Set required, DvObject dvObject) { + this(message, failedCommand, required, dvObject, false); + } + + public Set getRequiredPermissions() { + return required; + } + + public DvObject getDvObject() { + return dvObject; + } + + public boolean isDetailedMessageRequired() { + return isDetailedMessageRequired; + } } diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java index 99a2d5f6c82..1e9d48e844d 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java @@ -87,7 +87,7 @@ private void createUser(CommandContext ctxt) throws CommandException { ctxt.authentication().createAuthenticatedUser(userRecordIdentifier, userDTO.getUsername(), userInfo, true); } catch (AuthorizationException ex) { - throw new PermissionException(ex.getMessage(), this, null, null); + throw new PermissionException(ex.getMessage(), this, null, null, true); } } } diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java index ebb8d52a9fd..69d94fefa68 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java @@ -656,8 +656,11 @@ public void testRegisterOidcUser() { + "}", "Bearer testBearerToken" ); + registerOidcUserResponse.prettyPrint(); registerOidcUserResponse.then().assertThat() - .statusCode(UNAUTHORIZED.getStatusCode()); + .statusCode(UNAUTHORIZED.getStatusCode()) + .body("message", equalTo("Unauthorized bearer token.")); + // TODO: Complete test assertions } From 32f5fec57d618b876f54c31615b2d96ace3b427d Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 11 Nov 2024 12:56:17 +0000 Subject: [PATCH 034/101] Changed: test-realm.json to include new admin role in test realm necessary for IT --- conf/keycloak/test-realm.json | 672 ++++++++++++++++++++-------------- 1 file changed, 398 insertions(+), 274 deletions(-) diff --git a/conf/keycloak/test-realm.json b/conf/keycloak/test-realm.json index efe71cc5d29..2e5ed1c4d69 100644 --- a/conf/keycloak/test-realm.json +++ b/conf/keycloak/test-realm.json @@ -45,287 +45,411 @@ "quickLoginCheckMilliSeconds" : 1000, "maxDeltaTimeSeconds" : 43200, "failureFactor" : 30, - "roles" : { - "realm" : [ { - "id" : "075daee1-5ab2-44b5-adbf-fa49a3da8305", - "name" : "uma_authorization", - "description" : "${role_uma_authorization}", - "composite" : false, - "clientRole" : false, - "containerId" : "80a7e04b-a2b5-4891-a2d1-5ad4e915f983", - "attributes" : { } - }, { - "id" : "b4ff9091-ddf9-4536-b175-8cfa3e331d71", - "name" : "default-roles-test", - "description" : "${role_default-roles}", - "composite" : true, - "composites" : { - "realm" : [ "offline_access", "uma_authorization" ], - "client" : { - "account" : [ "view-profile", "manage-account" ] - } + "roles": { + "realm": [ + { + "id": "075daee1-5ab2-44b5-adbf-fa49a3da8305", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "80a7e04b-a2b5-4891-a2d1-5ad4e915f983", + "attributes": {} }, - "clientRole" : false, - "containerId" : "80a7e04b-a2b5-4891-a2d1-5ad4e915f983", - "attributes" : { } - }, { - "id" : "e6d31555-6be6-4dee-bc6a-40a53108e4c2", - "name" : "offline_access", - "description" : "${role_offline-access}", - "composite" : false, - "clientRole" : false, - "containerId" : "80a7e04b-a2b5-4891-a2d1-5ad4e915f983", - "attributes" : { } - } ], - "client" : { - "realm-management" : [ { - "id" : "1955bd12-5f86-4a74-b130-d68a8ef6f0ee", - "name" : "impersonation", - "description" : "${role_impersonation}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "1109c350-9ab1-426c-9876-ef67d4310f35", - "name" : "view-authorization", - "description" : "${role_view-authorization}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "980c3fd3-1ae3-4b8f-9a00-d764c939035f", - "name" : "query-users", - "description" : "${role_query-users}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "5363e601-0f9d-4633-a8c8-28cb0f859b7b", - "name" : "query-groups", - "description" : "${role_query-groups}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "59aa7992-ad78-48db-868a-25d6e1d7db50", - "name" : "realm-admin", - "description" : "${role_realm-admin}", - "composite" : true, - "composites" : { - "client" : { - "realm-management" : [ "impersonation", "view-authorization", "query-users", "query-groups", "manage-clients", "manage-realm", "view-identity-providers", "query-realms", "manage-authorization", "manage-identity-providers", "manage-users", "view-users", "view-realm", "create-client", "view-clients", "manage-events", "query-clients", "view-events" ] + { + "id": "b4ff9091-ddf9-4536-b175-8cfa3e331d71", + "name": "default-roles-test", + "description": "${role_default-roles}", + "composite": true, + "composites": { + "realm": [ + "offline_access", + "uma_authorization" + ], + "client": { + "account": [ + "view-profile", + "manage-account" + ] } }, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "112f53c2-897d-4c01-81db-b8dc10c5b995", - "name" : "manage-clients", - "description" : "${role_manage-clients}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "c7f57bbd-ef32-4a64-9888-7b8abd90777a", - "name" : "manage-realm", - "description" : "${role_manage-realm}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "8885dac8-0af3-45af-94ce-eff5e801bb80", - "name" : "view-identity-providers", - "description" : "${role_view-identity-providers}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "2673346c-b0ef-4e01-8a90-be03866093af", - "name" : "manage-authorization", - "description" : "${role_manage-authorization}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "b7182885-9e57-445f-8dae-17c16eb31b5d", - "name" : "manage-identity-providers", - "description" : "${role_manage-identity-providers}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "ba7bfe0c-cb07-4a47-b92c-b8132b57e181", - "name" : "manage-users", - "description" : "${role_manage-users}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "13a8f0fc-647d-4bfe-b525-73956898e550", - "name" : "query-realms", - "description" : "${role_query-realms}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "ef4c57dc-78c2-4f9a-8d2b-0e97d46fc842", - "name" : "view-realm", - "description" : "${role_view-realm}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "2875da34-006c-4b7f-bfc8-9ae8e46af3a2", - "name" : "view-users", - "description" : "${role_view-users}", - "composite" : true, - "composites" : { - "client" : { - "realm-management" : [ "query-users", "query-groups" ] + "clientRole": false, + "containerId": "80a7e04b-a2b5-4891-a2d1-5ad4e915f983", + "attributes": {} + }, + { + "id": "131ff85b-0c25-491b-8e13-dde779ec0854", + "name": "admin", + "description": "", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "impersonation", + "view-authorization", + "query-users", + "manage-realm", + "view-identity-providers", + "manage-authorization", + "view-clients", + "manage-events", + "query-clients", + "view-events", + "query-groups", + "realm-admin", + "manage-clients", + "query-realms", + "manage-identity-providers", + "manage-users", + "view-users", + "view-realm", + "create-client" + ], + "broker": [ + "read-token" + ], + "account": [ + "delete-account", + "manage-consent", + "view-consent", + "view-applications", + "view-groups", + "manage-account-links", + "view-profile", + "manage-account" + ] } }, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "c8c8f7dc-876b-4263-806f-3329f7cd5fd3", - "name" : "create-client", - "description" : "${role_create-client}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "21b84f90-5a9a-4845-a7ba-bbd98ac0fcc4", - "name" : "view-clients", - "description" : "${role_view-clients}", - "composite" : true, - "composites" : { - "client" : { - "realm-management" : [ "query-clients" ] - } + "clientRole": false, + "containerId": "80a7e04b-a2b5-4891-a2d1-5ad4e915f983", + "attributes": {} + }, + { + "id": "e6d31555-6be6-4dee-bc6a-40a53108e4c2", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "80a7e04b-a2b5-4891-a2d1-5ad4e915f983", + "attributes": {} + } + ], + "client": { + "realm-management": [ + { + "id": "1955bd12-5f86-4a74-b130-d68a8ef6f0ee", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} }, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "6fd64c94-d663-4501-ad77-0dcf8887d434", - "name" : "manage-events", - "description" : "${role_manage-events}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "b321927a-023c-4d2a-99ad-24baf7ff6d83", - "name" : "query-clients", - "description" : "${role_query-clients}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - }, { - "id" : "2fc21160-78de-457b-8594-e5c76cde1d5e", - "name" : "view-events", - "description" : "${role_view-events}", - "composite" : false, - "clientRole" : true, - "containerId" : "dada0ae8-ee9f-415a-9685-42da7c563660", - "attributes" : { } - } ], - "test" : [ ], - "security-admin-console" : [ ], - "admin-cli" : [ ], - "account-console" : [ ], - "broker" : [ { - "id" : "07ee59b5-dca6-48fb-83d4-2994ef02850e", - "name" : "read-token", - "description" : "${role_read-token}", - "composite" : false, - "clientRole" : true, - "containerId" : "b57d62bb-77ff-42bd-b8ff-381c7288f327", - "attributes" : { } - } ], - "account" : [ { - "id" : "17d2f811-7bdf-4c73-83b4-1037001797b8", - "name" : "view-applications", - "description" : "${role_view-applications}", - "composite" : false, - "clientRole" : true, - "containerId" : "77f8127a-261e-4cd8-a77d-b74a389f7fd4", - "attributes" : { } - }, { - "id" : "d1ff44f9-419e-42fd-98e8-1add1169a972", - "name" : "delete-account", - "description" : "${role_delete-account}", - "composite" : false, - "clientRole" : true, - "containerId" : "77f8127a-261e-4cd8-a77d-b74a389f7fd4", - "attributes" : { } - }, { - "id" : "14c23a18-ae2d-43c9-b0c0-aaf6e0c7f5b0", - "name" : "manage-account-links", - "description" : "${role_manage-account-links}", - "composite" : false, - "clientRole" : true, - "containerId" : "77f8127a-261e-4cd8-a77d-b74a389f7fd4", - "attributes" : { } - }, { - "id" : "6fbe58af-d2fe-4d66-95fe-a2e8a818cb55", - "name" : "view-profile", - "description" : "${role_view-profile}", - "composite" : false, - "clientRole" : true, - "containerId" : "77f8127a-261e-4cd8-a77d-b74a389f7fd4", - "attributes" : { } - }, { - "id" : "bdfd02bc-6f6a-47d2-82bc-0ca52d78ff48", - "name" : "manage-consent", - "description" : "${role_manage-consent}", - "composite" : true, - "composites" : { - "client" : { - "account" : [ "view-consent" ] - } + { + "id": "1109c350-9ab1-426c-9876-ef67d4310f35", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} }, - "clientRole" : true, - "containerId" : "77f8127a-261e-4cd8-a77d-b74a389f7fd4", - "attributes" : { } - }, { - "id" : "782f3b0c-a17b-4a87-988b-1a711401f3b0", - "name" : "manage-account", - "description" : "${role_manage-account}", - "composite" : true, - "composites" : { - "client" : { - "account" : [ "manage-account-links" ] - } + { + "id": "980c3fd3-1ae3-4b8f-9a00-d764c939035f", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "5363e601-0f9d-4633-a8c8-28cb0f859b7b", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "59aa7992-ad78-48db-868a-25d6e1d7db50", + "name": "realm-admin", + "description": "${role_realm-admin}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "impersonation", + "view-authorization", + "query-users", + "query-groups", + "manage-clients", + "manage-realm", + "view-identity-providers", + "query-realms", + "manage-authorization", + "manage-identity-providers", + "manage-users", + "view-users", + "view-realm", + "create-client", + "view-clients", + "manage-events", + "query-clients", + "view-events" + ] + } + }, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "112f53c2-897d-4c01-81db-b8dc10c5b995", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "c7f57bbd-ef32-4a64-9888-7b8abd90777a", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "8885dac8-0af3-45af-94ce-eff5e801bb80", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "2673346c-b0ef-4e01-8a90-be03866093af", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "b7182885-9e57-445f-8dae-17c16eb31b5d", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "ba7bfe0c-cb07-4a47-b92c-b8132b57e181", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} }, - "clientRole" : true, - "containerId" : "77f8127a-261e-4cd8-a77d-b74a389f7fd4", - "attributes" : { } - }, { - "id" : "8a3bfe15-66d9-4f3d-83ac-801d682d42b0", - "name" : "view-consent", - "description" : "${role_view-consent}", - "composite" : false, - "clientRole" : true, - "containerId" : "77f8127a-261e-4cd8-a77d-b74a389f7fd4", - "attributes" : { } - } ] + { + "id": "13a8f0fc-647d-4bfe-b525-73956898e550", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "ef4c57dc-78c2-4f9a-8d2b-0e97d46fc842", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "2875da34-006c-4b7f-bfc8-9ae8e46af3a2", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-users", + "query-groups" + ] + } + }, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "c8c8f7dc-876b-4263-806f-3329f7cd5fd3", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "21b84f90-5a9a-4845-a7ba-bbd98ac0fcc4", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-clients" + ] + } + }, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "6fd64c94-d663-4501-ad77-0dcf8887d434", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "b321927a-023c-4d2a-99ad-24baf7ff6d83", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + }, + { + "id": "2fc21160-78de-457b-8594-e5c76cde1d5e", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "dada0ae8-ee9f-415a-9685-42da7c563660", + "attributes": {} + } + ], + "test": [], + "security-admin-console": [], + "admin-cli": [], + "account-console": [], + "broker": [ + { + "id": "07ee59b5-dca6-48fb-83d4-2994ef02850e", + "name": "read-token", + "description": "${role_read-token}", + "composite": false, + "clientRole": true, + "containerId": "b57d62bb-77ff-42bd-b8ff-381c7288f327", + "attributes": {} + } + ], + "account": [ + { + "id": "17d2f811-7bdf-4c73-83b4-1037001797b8", + "name": "view-applications", + "description": "${role_view-applications}", + "composite": false, + "clientRole": true, + "containerId": "77f8127a-261e-4cd8-a77d-b74a389f7fd4", + "attributes": {} + }, + { + "id": "f5918d56-bd4d-4035-8fa7-8622075ed690", + "name": "view-groups", + "description": "${role_view-groups}", + "composite": false, + "clientRole": true, + "containerId": "77f8127a-261e-4cd8-a77d-b74a389f7fd4", + "attributes": {} + }, + { + "id": "d1ff44f9-419e-42fd-98e8-1add1169a972", + "name": "delete-account", + "description": "${role_delete-account}", + "composite": false, + "clientRole": true, + "containerId": "77f8127a-261e-4cd8-a77d-b74a389f7fd4", + "attributes": {} + }, + { + "id": "14c23a18-ae2d-43c9-b0c0-aaf6e0c7f5b0", + "name": "manage-account-links", + "description": "${role_manage-account-links}", + "composite": false, + "clientRole": true, + "containerId": "77f8127a-261e-4cd8-a77d-b74a389f7fd4", + "attributes": {} + }, + { + "id": "6fbe58af-d2fe-4d66-95fe-a2e8a818cb55", + "name": "view-profile", + "description": "${role_view-profile}", + "composite": false, + "clientRole": true, + "containerId": "77f8127a-261e-4cd8-a77d-b74a389f7fd4", + "attributes": {} + }, + { + "id": "bdfd02bc-6f6a-47d2-82bc-0ca52d78ff48", + "name": "manage-consent", + "description": "${role_manage-consent}", + "composite": true, + "composites": { + "client": { + "account": [ + "view-consent" + ] + } + }, + "clientRole": true, + "containerId": "77f8127a-261e-4cd8-a77d-b74a389f7fd4", + "attributes": {} + }, + { + "id": "782f3b0c-a17b-4a87-988b-1a711401f3b0", + "name": "manage-account", + "description": "${role_manage-account}", + "composite": true, + "composites": { + "client": { + "account": [ + "manage-account-links" + ] + } + }, + "clientRole": true, + "containerId": "77f8127a-261e-4cd8-a77d-b74a389f7fd4", + "attributes": {} + }, + { + "id": "8a3bfe15-66d9-4f3d-83ac-801d682d42b0", + "name": "view-consent", + "description": "${role_view-consent}", + "composite": false, + "clientRole": true, + "containerId": "77f8127a-261e-4cd8-a77d-b74a389f7fd4", + "attributes": {} + } + ] } }, "groups" : [ { @@ -409,7 +533,7 @@ } ], "disableableCredentialTypes" : [ ], "requiredActions" : [ ], - "realmRoles" : [ "default-roles-test" ], + "realmRoles" : [ "default-roles-test", "admin" ], "notBefore" : 0, "groups" : [ "/admins" ] }, { From c0c6704899b382adfab93dd67d1304b601e3c1d7 Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 11 Nov 2024 12:56:37 +0000 Subject: [PATCH 035/101] Added: new test cases for registerOidcUser --- .../edu/harvard/iq/dataverse/api/UsersIT.java | 89 ++++++++++++++----- .../edu/harvard/iq/dataverse/api/UtilIT.java | 47 +++++++++- 2 files changed, 114 insertions(+), 22 deletions(-) diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java index 69d94fefa68..43f18398cc2 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java @@ -15,11 +15,9 @@ import java.util.UUID; import jakarta.json.Json; import jakarta.json.JsonObjectBuilder; -import static jakarta.ws.rs.core.Response.Status.BAD_REQUEST; -import static jakarta.ws.rs.core.Response.Status.CREATED; -import static jakarta.ws.rs.core.Response.Status.NOT_FOUND; -import static jakarta.ws.rs.core.Response.Status.OK; -import static jakarta.ws.rs.core.Response.Status.UNAUTHORIZED; + +import static jakarta.ws.rs.core.Response.Status.*; +import static org.hamcrest.Matchers.notNullValue; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.equalTo; @@ -28,6 +26,7 @@ import org.hamcrest.CoreMatchers; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; public class UsersIT { @@ -518,12 +517,33 @@ public void testDeleteAuthenticatedUser() { } @Test + // This test is disabled because it is only compatible with the containerized development environment and would cause the Jenkins job to fail. + @Disabled public void testRegisterOidcUser() { - UserDTO userDTO = new UserDTO(); - userDTO.setUsername("testRegisterOidcUserUsername"); - userDTO.setEmailAddress("testregisteroidcuser@dataverse.com"); - userDTO.setFirstName("Firstname"); - userDTO.setLastName("Lastname"); + // Set Up - Get the admin access token from the OIDC provider + Response adminOidcLoginResponse = UtilIT.performKeycloakROPCLogin("admin", "admin"); + adminOidcLoginResponse.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("access_token", notNullValue()); + String adminOidcAccessToken = adminOidcLoginResponse.jsonPath().getString("access_token"); + + // Set Up - Create random user in the OIDC provider + String randomUsername = UUID.randomUUID().toString().substring(0, 8); + String newKeycloakUserJson = "{" + + "\"username\":\"" + randomUsername + "\"," + + "\"enabled\":true," + + "\"credentials\":[" + + " {" + + " \"type\":\"password\"," + + " \"value\":\"password\"," + + " \"temporary\":false" + + " }" + + "]" + + "}"; + Response createKeycloakOidcUserResponse = UtilIT.createKeycloakUser(adminOidcAccessToken, newKeycloakUserJson); + createKeycloakOidcUserResponse.then().assertThat().statusCode(CREATED.getStatusCode()); + Response newUserOidcLoginResponse = UtilIT.performKeycloakROPCLogin(randomUsername, "password"); + String newUserOidcAccessToken = newUserOidcLoginResponse.jsonPath().getString("access_token"); // Should return error when empty token is passed Response registerOidcUserResponse = UtilIT.registerOidcUser( @@ -644,24 +664,51 @@ public void testRegisterOidcUser() { .body("fieldErrors.username", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.usernameInUse"))); // Should return error when the provided User JSON is valid but the provided Bearer token is invalid + randomUsername = UUID.randomUUID().toString().substring(0, 8); + String randomEmail = randomUsername + "@dataverse.com"; + String validUserJson = "{" + + "\"username\":\"" + randomUsername + "\"," + + "\"firstName\":\"YourFirstName\"," + + "\"lastName\":\"YourLastName\"," + + "\"emailAddress\":\"" + randomEmail + "\"," + + "\"affiliation\":\"YourAffiliation\"," + + "\"position\":\"YourPosition\"," + + "\"termsAccepted\":true" + + "}"; registerOidcUserResponse = UtilIT.registerOidcUser( - "{" - + "\"username\":\"yourUsername\"," - + "\"firstName\":\"YourFirstName\"," - + "\"lastName\":\"YourLastName\"," - + "\"emailAddress\":\"yourEmail@example.com\"," - + "\"affiliation\":\"YourAffiliation\"," - + "\"position\":\"YourPosition\"," - + "\"termsAccepted\":true" - + "}", + validUserJson, "Bearer testBearerToken" ); - registerOidcUserResponse.prettyPrint(); registerOidcUserResponse.then().assertThat() .statusCode(UNAUTHORIZED.getStatusCode()) .body("message", equalTo("Unauthorized bearer token.")); - // TODO: Complete test assertions + // Should register user when the provided User JSON is valid and the provided Bearer token is valid + registerOidcUserResponse = UtilIT.registerOidcUser( + validUserJson, + "Bearer " + newUserOidcAccessToken + ); + registerOidcUserResponse.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.message", equalTo("User registered.")); + + // Should return error when attempting to re-register with the same Bearer token but different User data + String newUserJson = "{" + + "\"username\":\"newUsername\"," + + "\"firstName\":\"NewFirstName\"," + + "\"lastName\":\"NewLastName\"," + + "\"emailAddress\":\"newEmail@example.com\"," + + "\"affiliation\":\"YourAffiliation\"," + + "\"position\":\"YourPosition\"," + + "\"termsAccepted\":true" + + "}"; + registerOidcUserResponse = UtilIT.registerOidcUser( + newUserJson, + "Bearer " + newUserOidcAccessToken + ); + registerOidcUserResponse.then().assertThat() + .statusCode(FORBIDDEN.getStatusCode()) + .body("message", equalTo("User is already registered with this token.")); } private Response convertUserFromBcryptToSha1(long idOfBcryptUserToConvert, String password) { diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java index e813f3a2f7b..5cf2059427d 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java @@ -1,6 +1,5 @@ package edu.harvard.iq.dataverse.api; -import edu.harvard.iq.dataverse.api.dto.UserDTO; import edu.harvard.iq.dataverse.util.json.NullSafeJsonBuilder; import io.restassured.http.ContentType; import io.restassured.path.json.JsonPath; @@ -4250,4 +4249,50 @@ static Response registerOidcUser(String jsonIn, String bearerToken) { .contentType(ContentType.JSON) .post("/api/users/register"); } + + /** + * Creates a new user in the development Keycloak instance. + *

This method is specifically designed for use in the containerized Keycloak development + * environment. The configured Keycloak instance must be accessible at the specified URL. + * The method sends a request to the Keycloak Admin API to create a new user in the given realm. + * + *

Refer to the {@code testRegisterOidc()} method in the {@code UsersIT} class for an example + * of this method in action. + * + * @param bearerToken The Bearer token used for authenticating the request to the Keycloak Admin API. + * @param userJson The JSON representation of the user to be created. + * @return A {@link Response} containing the result of the user creation request. + */ + static Response createKeycloakUser(String bearerToken, String userJson) { + return given() + .contentType(ContentType.JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + bearerToken) + .body(userJson) + .post("http://keycloak.mydomain.com:8090/admin/realms/test/users"); + } + + /** + * Performs an OIDC login in the development Keycloak instance using the Resource Owner Password Credentials (ROPC) + * grant type to retrieve authentication tokens from a Keycloak instance. + * + *

This method is specifically designed for use in the containerized Keycloak development + * environment. The configured Keycloak instance must be accessible at the specified URL. + * + *

Refer to the {@code testRegisterOidc()} method in the {@code UsersIT} class for an example + * of this method in action. + * + * @return A {@link Response} containing authentication tokens, including access and refresh tokens, + * if the login is successful. + */ + static Response performKeycloakROPCLogin(String username, String password) { + return given() + .contentType(ContentType.URLENC) + .formParam("client_id", "test") + .formParam("client_secret", "94XHrfNRwXsjqTqApRrwWmhDLDHpIYV8") + .formParam("username", username) + .formParam("password", password) + .formParam("grant_type", "password") + .formParam("scope", "openid") + .post("http://keycloak.mydomain.com:8090/realms/test/protocol/openid-connect/token"); + } } From a064a7b1705252b396c2aa0759599b3726b01af4 Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 11 Nov 2024 12:57:34 +0000 Subject: [PATCH 036/101] Removed: unused imports --- src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java index 43f18398cc2..ecf0e901943 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java @@ -1,18 +1,20 @@ package edu.harvard.iq.dataverse.api; -import edu.harvard.iq.dataverse.api.dto.UserDTO; import edu.harvard.iq.dataverse.util.BundleUtil; import io.restassured.RestAssured; + import static io.restassured.RestAssured.given; + import io.restassured.http.ContentType; import io.restassured.path.json.JsonPath; import io.restassured.response.Response; import edu.harvard.iq.dataverse.authorization.DataverseRole; import edu.harvard.iq.dataverse.settings.SettingsServiceBean; -import java.util.ArrayList; + import java.util.Arrays; import java.util.List; import java.util.UUID; + import jakarta.json.Json; import jakarta.json.JsonObjectBuilder; @@ -21,7 +23,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.equalTo; -import static org.hamcrest.Matchers.contains; import static org.junit.jupiter.api.Assertions.assertTrue; import org.hamcrest.CoreMatchers; From 4bc58f6a9188213b394de8fd8bbe0b207fe458e7 Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 11 Nov 2024 13:24:03 +0000 Subject: [PATCH 037/101] Fixed: OIDCAuthenticationProviderFactoryIT --- .../OIDCAuthenticationProviderFactoryIT.java | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/test/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthenticationProviderFactoryIT.java b/src/test/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthenticationProviderFactoryIT.java index ee6823ef98a..839781b6b3b 100644 --- a/src/test/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthenticationProviderFactoryIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthenticationProviderFactoryIT.java @@ -7,7 +7,6 @@ import edu.harvard.iq.dataverse.api.auth.BearerTokenAuthMechanism; import edu.harvard.iq.dataverse.api.auth.doubles.BearerTokenKeyContainerRequestTestFake; import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; -import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; import edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2Exception; import edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2UserRecord; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; @@ -38,16 +37,12 @@ import java.util.Map; import java.util.Optional; -import java.util.Set; import java.util.regex.Pattern; import java.util.stream.Collectors; import static edu.harvard.iq.dataverse.authorization.providers.oauth2.oidc.OIDCAuthenticationProviderFactoryIT.clientId; import static edu.harvard.iq.dataverse.authorization.providers.oauth2.oidc.OIDCAuthenticationProviderFactoryIT.clientSecret; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assumptions.assumeFalse; import static org.junit.jupiter.api.Assumptions.assumeTrue; import static org.mockito.Mockito.when; @@ -143,7 +138,7 @@ void testCreateProvider() throws Exception { /** * This test covers using an OIDC provider as authorization party when accessing the Dataverse API with a - * Bearer Token. See {@link BearerTokenAuthMechanism}. It needs to mock the auth services to avoid adding + * Bearer Token. See {@link BearerTokenAuthMechanism}. It needs to mock the auth service to avoid adding * more dependencies. */ @Test @@ -158,19 +153,15 @@ void testApiBearerAuth() throws Exception { String accessToken = getBearerTokenViaKeycloakAdminClient(); assumeFalse(accessToken == null); - OIDCAuthProvider oidcAuthProvider = getProvider(); // This will also receive the details from the remote Keycloak in the container - UserRecordIdentifier identifier = oidcAuthProvider.getUserIdentifier(new BearerAccessToken(accessToken)).get(); String token = "Bearer " + accessToken; BearerTokenKeyContainerRequestTestFake request = new BearerTokenKeyContainerRequestTestFake(token); AuthenticatedUser user = new MockAuthenticatedUser(); // setup mocks (we don't want or need a database here) - when(authService.getAuthenticationProviderIdsOfType(OIDCAuthProvider.class)).thenReturn(Set.of(oidcAuthProvider.getId())); - when(authService.getAuthenticationProvider(oidcAuthProvider.getId())).thenReturn(oidcAuthProvider); - when(authService.lookupUser(identifier)).thenReturn(user); + when(authService.lookupUserByOidcBearerToken(token)).thenReturn(user); when(userService.updateLastApiUseTime(user)).thenReturn(user); - + // when (let's do this again, but now with the actual subject under test!) User lookedUpUser = bearerTokenAuthMechanism.findUserFromRequest(request); From 4536f91774ebb769e6e9487f460c64f415cce4a8 Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 11 Nov 2024 16:57:42 +0000 Subject: [PATCH 038/101] Added: release notes for #10959 --- doc/release-notes/10959-bearer-token-user-registration.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/release-notes/10959-bearer-token-user-registration.md diff --git a/doc/release-notes/10959-bearer-token-user-registration.md b/doc/release-notes/10959-bearer-token-user-registration.md new file mode 100644 index 00000000000..4e34b1cbd17 --- /dev/null +++ b/doc/release-notes/10959-bearer-token-user-registration.md @@ -0,0 +1 @@ +The functionality of the OIDC Bearer token API authentication (available through a feature flag) has been extended to allow the registration of new users in Dataverse when there is no user account associated with the bearer token. Specifically, a new endpoint (users/register) has been implemented, to which the bearer token and user information are sent, allowing the identity provider user to be linked to a Dataverse account. \ No newline at end of file From 99ce9400018dbc29c078a7ea5cad701a986e8531 Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 11 Nov 2024 17:01:14 +0000 Subject: [PATCH 039/101] Changed: release notes tweaks --- doc/release-notes/10959-bearer-token-user-registration.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/doc/release-notes/10959-bearer-token-user-registration.md b/doc/release-notes/10959-bearer-token-user-registration.md index 4e34b1cbd17..329db550cc9 100644 --- a/doc/release-notes/10959-bearer-token-user-registration.md +++ b/doc/release-notes/10959-bearer-token-user-registration.md @@ -1 +1,5 @@ -The functionality of the OIDC Bearer token API authentication (available through a feature flag) has been extended to allow the registration of new users in Dataverse when there is no user account associated with the bearer token. Specifically, a new endpoint (users/register) has been implemented, to which the bearer token and user information are sent, allowing the identity provider user to be linked to a Dataverse account. \ No newline at end of file +The OIDC Bearer token API authentication feature (available through a feature flag) has been extended to allow the registration of new users in Dataverse when there is no user account associated with the bearer token. + +Specifically, a new endpoint (users/register) has been implemented, to which the bearer token and new user account information are sent, allowing the identity provider user to be linked to a Dataverse account. + +In this way, the user will be recognized in future requests using the bearer token in the BearerTokenAuthMechanism. From 386b6acee2d6b8267463f57e5523abdaf84ef7cb Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 13 Nov 2024 12:13:34 +0000 Subject: [PATCH 040/101] Added: new OidcUserInfo object for encapsulating both User record identifier and claims --- .../AuthenticationServiceBean.java | 29 +++++++++------- .../dataverse/authorization/OidcUserInfo.java | 33 ++++++++++++++++++ .../oauth2/oidc/OIDCAuthProvider.java | 2 +- .../command/impl/RegisterOidcUserCommand.java | 4 ++- .../impl/RegisterOidcUserCommandTest.java | 34 +++++++++++-------- 5 files changed, 73 insertions(+), 29 deletions(-) create mode 100644 src/main/java/edu/harvard/iq/dataverse/authorization/OidcUserInfo.java diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java index 811a46730ee..5124cb0d549 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java @@ -2,6 +2,7 @@ import com.nimbusds.oauth2.sdk.ParseException; import com.nimbusds.oauth2.sdk.token.BearerAccessToken; +import com.nimbusds.openid.connect.sdk.claims.UserInfo; import edu.harvard.iq.dataverse.DatasetVersionServiceBean; import edu.harvard.iq.dataverse.DvObjectServiceBean; import edu.harvard.iq.dataverse.GuestbookResponseServiceBean; @@ -9,6 +10,7 @@ import edu.harvard.iq.dataverse.UserNotificationServiceBean; import edu.harvard.iq.dataverse.UserServiceBean; import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; +import edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2Exception; import edu.harvard.iq.dataverse.authorization.providers.oauth2.oidc.OIDCAuthProvider; import edu.harvard.iq.dataverse.search.IndexServiceBean; import edu.harvard.iq.dataverse.actionlogging.ActionLogRecord; @@ -985,18 +987,18 @@ public ApiToken getValidApiTokenForUser(User user) { public AuthenticatedUser lookupUserByOidcBearerToken(String bearerToken) throws AuthorizationException { // TODO: Get the identifier from an invalidating cache to avoid lookup bursts of the same token. // Tokens in the cache should be removed after some (configurable) time. - UserRecordIdentifier userInfo = verifyOidcBearerTokenAndGetUserIdentifier(bearerToken); - return lookupUser(userInfo); + OidcUserInfo oidcUserInfo = verifyOidcBearerTokenAndGetUserIdentifier(bearerToken); + return lookupUser(oidcUserInfo.getUserRecordIdentifier()); } /** - * Verifies the given OIDC bearer token and retrieves the corresponding user's identifier. + * Verifies the given OIDC bearer token and retrieves the corresponding OIDC user info. * * @param bearerToken The OIDC bearer token. - * @return A {@link UserRecordIdentifier} representing the user associated with the valid token. + * @return An {@link OidcUserInfo} containing the user's identifier and user info. * @throws AuthorizationException If the token is invalid or if no OIDC providers are available. */ - public UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String bearerToken) throws AuthorizationException { + public OidcUserInfo verifyOidcBearerTokenAndGetUserIdentifier(String bearerToken) throws AuthorizationException { try { BearerAccessToken accessToken = BearerAccessToken.parse(bearerToken); List providers = getAvailableOidcProviders(); @@ -1010,15 +1012,16 @@ public UserRecordIdentifier verifyOidcBearerTokenAndGetUserIdentifier(String bea // Attempt to validate the token with each configured OIDC provider. for (OIDCAuthProvider provider : providers) { try { - Optional userInfo = provider.getUserIdentifier(accessToken); - if (userInfo.isPresent()) { - logger.log(Level.FINE, "Bearer token detected, provider {0} confirmed validity and provided identifier", provider.getId()); - return userInfo.get(); + // Retrieve both user identifier and user info + Optional userRecordIdentifier = provider.getUserIdentifier(accessToken); + Optional userInfo = provider.getUserInfo(accessToken); + + // If either is present, return the result + if (userRecordIdentifier.isPresent() || userInfo.isPresent()) { + logger.log(Level.FINE, "Bearer token detected, provider {0} confirmed validity and provided user info", provider.getId()); + return new OidcUserInfo(userRecordIdentifier.get(), userInfo.get()); } - } catch (IOException e) { - // TODO: Just logging this is not sufficient - if there is an IO error with the one provider - // which would have validated successfully, this is not the users fault. We need to - // take note and refer to that later when occurred. + } catch (IOException | OAuth2Exception e) { logger.log(Level.FINE, "Bearer token detected, provider " + provider.getId() + " indicates an invalid Token, skipping", e); } } diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/OidcUserInfo.java b/src/main/java/edu/harvard/iq/dataverse/authorization/OidcUserInfo.java new file mode 100644 index 00000000000..c89ea354172 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/OidcUserInfo.java @@ -0,0 +1,33 @@ +package edu.harvard.iq.dataverse.authorization; + +import com.nimbusds.openid.connect.sdk.claims.UserInfo; + +/** + * Encapsulates both the user's identifier ({@link UserRecordIdentifier}) and the user's claims information + * ({@link UserInfo}) retrieved from an OIDC (OpenID Connect) bearer token. + *

+ * This class serves as a container for both the {@link UserRecordIdentifier}, which uniquely identifies + * the user within the system, and the {@link UserInfo}, which holds the user's claims data provided by + * an OIDC provider. It simplifies the management of these related pieces of user data when handling + * OIDC token validation and authorization processes. + * + * @see UserRecordIdentifier + * @see UserInfo + */ +public class OidcUserInfo { + private final UserRecordIdentifier userRecordIdentifier; + private final UserInfo userClaimsInfo; + + public OidcUserInfo(UserRecordIdentifier userRecordIdentifier, UserInfo userClaimsInfo) { + this.userRecordIdentifier = userRecordIdentifier; + this.userClaimsInfo = userClaimsInfo; + } + + public UserRecordIdentifier getUserRecordIdentifier() { + return userRecordIdentifier; + } + + public UserInfo getUserClaimsInfo() { + return userClaimsInfo; + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthProvider.java b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthProvider.java index 5eb2b391eb7..675e1696844 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthProvider.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthProvider.java @@ -291,7 +291,7 @@ Optional getAccessToken(AuthorizationGrant grant) throws IOEx * Retrieve User Info from provider. Encapsulate for testing. * @param accessToken The access token to enable reading data from userinfo endpoint */ - Optional getUserInfo(BearerAccessToken accessToken) throws IOException, OAuth2Exception { + public Optional getUserInfo(BearerAccessToken accessToken) throws IOException, OAuth2Exception { // Retrieve data HTTPResponse response = new UserInfoRequest(this.idpMetadata.getUserInfoEndpointURI(), accessToken) .toHTTPRequest() diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java index 1e9d48e844d..ff059e71ec6 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java @@ -3,6 +3,7 @@ import edu.harvard.iq.dataverse.DvObject; import edu.harvard.iq.dataverse.api.dto.UserDTO; import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo; +import edu.harvard.iq.dataverse.authorization.OidcUserInfo; import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; import edu.harvard.iq.dataverse.engine.command.*; @@ -70,7 +71,8 @@ private boolean isUsernameInUse(CommandContext ctxt, String username) { private void createUser(CommandContext ctxt) throws CommandException { try { - UserRecordIdentifier userRecordIdentifier = ctxt.authentication().verifyOidcBearerTokenAndGetUserIdentifier(bearerToken); + OidcUserInfo oidcUserInfo = ctxt.authentication().verifyOidcBearerTokenAndGetUserIdentifier(bearerToken); + UserRecordIdentifier userRecordIdentifier = oidcUserInfo.getUserRecordIdentifier(); if (ctxt.authentication().lookupUser(userRecordIdentifier) != null) { throw new IllegalCommandException(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userAlreadyRegisteredWithToken"), this); diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommandTest.java index bfc693cc308..845ad8c3ed9 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommandTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommandTest.java @@ -1,8 +1,10 @@ package edu.harvard.iq.dataverse.engine.command.impl; +import com.nimbusds.openid.connect.sdk.claims.UserInfo; import edu.harvard.iq.dataverse.api.dto.UserDTO; import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo; import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; +import edu.harvard.iq.dataverse.authorization.OidcUserInfo; import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; @@ -16,7 +18,6 @@ import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.Mockito; import org.mockito.MockitoAnnotations; import static edu.harvard.iq.dataverse.mocks.MocksFactory.makeRequest; @@ -39,10 +40,21 @@ class RegisterOidcUserCommandTest { @InjectMocks private RegisterOidcUserCommand sut; + private UserRecordIdentifier userRecordIdentifierMock; + private UserInfo userInfoMock; + private OidcUserInfo oidcUserInfoMock; + private AuthenticatedUser existingTestUser; + @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); setUpDefaultUserDTO(); + + userRecordIdentifierMock = mock(UserRecordIdentifier.class); + userInfoMock = mock(UserInfo.class); + oidcUserInfoMock = new OidcUserInfo(userRecordIdentifierMock, userInfoMock); + existingTestUser = new AuthenticatedUser(); + when(context.authentication()).thenReturn(authServiceMock); sut = new RegisterOidcUserCommand(makeRequest(), TEST_BEARER_TOKEN, userDTO); } @@ -75,9 +87,8 @@ public void execute_unacceptedTerms_availableEmailAndUsername() { @Test public void execute_acceptedTerms_availableEmailAndUsername() { - AuthenticatedUser existingUser = new AuthenticatedUser(); - when(authServiceMock.getAuthenticatedUserByEmail(userDTO.getEmailAddress())).thenReturn(existingUser); - when(authServiceMock.getAuthenticatedUser(userDTO.getUsername())).thenReturn(existingUser); + when(authServiceMock.getAuthenticatedUserByEmail(userDTO.getEmailAddress())).thenReturn(existingTestUser); + when(authServiceMock.getAuthenticatedUser(userDTO.getUsername())).thenReturn(existingTestUser); assertThatThrownBy(() -> sut.execute(context)) .isInstanceOf(InvalidFieldsCommandException.class) @@ -100,29 +111,25 @@ void execute_throwsPermissionException_onAuthorizationException() throws Authori .isInstanceOf(PermissionException.class) .hasMessageContaining(testAuthorizationExceptionMessage); - Mockito.verify(context.authentication(), times(1)) - .verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN); + verify(context.authentication(), times(1)).verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN); } @Test void execute_throwsIllegalCommandException_ifUserAlreadyRegisteredWithToken() throws AuthorizationException { - UserRecordIdentifier userRecordIdentifierMock = Mockito.mock(UserRecordIdentifier.class); when(context.authentication().verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)) - .thenReturn(userRecordIdentifierMock); + .thenReturn(oidcUserInfoMock); when(context.authentication().lookupUser(userRecordIdentifierMock)).thenReturn(new AuthenticatedUser()); assertThatThrownBy(() -> sut.execute(context)) .isInstanceOf(IllegalCommandException.class) .hasMessageContaining(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userAlreadyRegisteredWithToken")); - Mockito.verify(context.authentication(), times(1)) - .lookupUser(userRecordIdentifierMock); + verify(context.authentication(), times(1)).lookupUser(userRecordIdentifierMock); } @Test void execute_happyPath_withoutAffiliationAndPosition() throws AuthorizationException, CommandException { - UserRecordIdentifier userRecordIdentifierMock = mock(UserRecordIdentifier.class); - when(authServiceMock.verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(userRecordIdentifierMock); + when(authServiceMock.verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(oidcUserInfoMock); sut.execute(context); @@ -145,8 +152,7 @@ void execute_happyPath_withAffiliationAndPosition() throws AuthorizationExceptio userDTO.setPosition("test position"); userDTO.setAffiliation("test affiliation"); - UserRecordIdentifier userRecordIdentifierMock = mock(UserRecordIdentifier.class); - when(authServiceMock.verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(userRecordIdentifierMock); + when(authServiceMock.verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(oidcUserInfoMock); sut.execute(context); From b5d40adb7f3c6782e9d7042e76e332dffc8bf263 Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 13 Nov 2024 12:21:20 +0000 Subject: [PATCH 041/101] Refactor: renamed classes and methods from 'Oidc/oidc' to 'OIDC' to be consistent with the standard --- .../edu/harvard/iq/dataverse/api/Users.java | 4 +- .../api/auth/BearerTokenAuthMechanism.java | 2 +- .../AuthenticationServiceBean.java | 10 ++--- .../{OidcUserInfo.java => OIDCUserInfo.java} | 4 +- ...mand.java => RegisterOIDCUserCommand.java} | 8 ++-- .../edu/harvard/iq/dataverse/api/UsersIT.java | 2 +- .../auth/BearerTokenAuthMechanismTest.java | 6 +-- .../AuthenticationServiceBeanTest.java | 44 ++++++++++--------- .../OIDCAuthenticationProviderFactoryIT.java | 2 +- ....java => RegisterOIDCUserCommandTest.java} | 24 +++++----- 10 files changed, 55 insertions(+), 51 deletions(-) rename src/main/java/edu/harvard/iq/dataverse/authorization/{OidcUserInfo.java => OIDCUserInfo.java} (92%) rename src/main/java/edu/harvard/iq/dataverse/engine/command/impl/{RegisterOidcUserCommand.java => RegisterOIDCUserCommand.java} (92%) rename src/test/java/edu/harvard/iq/dataverse/engine/command/impl/{RegisterOidcUserCommandTest.java => RegisterOIDCUserCommandTest.java} (89%) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Users.java b/src/main/java/edu/harvard/iq/dataverse/api/Users.java index cc9dee3b678..166465115c8 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Users.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Users.java @@ -272,7 +272,7 @@ public Response getTracesElement(@Context ContainerRequestContext crc, @Context @POST @Path("register") - public Response registerOidcUser(String body) { + public Response registerOIDCUser(String body) { if (!FeatureFlags.API_BEARER_AUTH.enabled()) { return error(Response.Status.INTERNAL_SERVER_ERROR, BundleUtil.getStringFromBundle("users.api.errors.bearerAuthFeatureFlagDisabled")); } @@ -282,7 +282,7 @@ public Response registerOidcUser(String body) { } try { JsonObject userJson = JsonUtil.getJsonObject(body); - execCommand(new RegisterOidcUserCommand(createDataverseRequest(GuestUser.get()), bearerToken.get(), jsonParser().parseUserDTO(userJson))); + execCommand(new RegisterOIDCUserCommand(createDataverseRequest(GuestUser.get()), bearerToken.get(), jsonParser().parseUserDTO(userJson))); } catch (JsonParseException | JsonParsingException e) { return error(Response.Status.BAD_REQUEST, MessageFormat.format(BundleUtil.getStringFromBundle("users.api.errors.jsonParseToUserDTO"), e.getMessage())); } catch (WrappedResponse e) { diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java index 9bfcb03a72b..3ee9bb909f2 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java @@ -39,7 +39,7 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) AuthenticatedUser authUser; try { - authUser = authSvc.lookupUserByOidcBearerToken(bearerToken.get()); + authUser = authSvc.lookupUserByOIDCBearerToken(bearerToken.get()); } catch (AuthorizationException e) { logger.log(Level.WARNING, "Authorization failed: {0}", e.getMessage()); throw new WrappedUnauthorizedAuthErrorResponse(e.getMessage()); diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java index 5124cb0d549..3d46af4f8cf 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java @@ -984,10 +984,10 @@ public ApiToken getValidApiTokenForUser(User user) { * @return An instance of {@link AuthenticatedUser} representing the authenticated user. * @throws AuthorizationException If the token is invalid or no OIDC provider is configured. */ - public AuthenticatedUser lookupUserByOidcBearerToken(String bearerToken) throws AuthorizationException { + public AuthenticatedUser lookupUserByOIDCBearerToken(String bearerToken) throws AuthorizationException { // TODO: Get the identifier from an invalidating cache to avoid lookup bursts of the same token. // Tokens in the cache should be removed after some (configurable) time. - OidcUserInfo oidcUserInfo = verifyOidcBearerTokenAndGetUserIdentifier(bearerToken); + OIDCUserInfo oidcUserInfo = verifyOIDCBearerTokenAndGetUserIdentifier(bearerToken); return lookupUser(oidcUserInfo.getUserRecordIdentifier()); } @@ -995,10 +995,10 @@ public AuthenticatedUser lookupUserByOidcBearerToken(String bearerToken) throws * Verifies the given OIDC bearer token and retrieves the corresponding OIDC user info. * * @param bearerToken The OIDC bearer token. - * @return An {@link OidcUserInfo} containing the user's identifier and user info. + * @return An {@link OIDCUserInfo} containing the user's identifier and user info. * @throws AuthorizationException If the token is invalid or if no OIDC providers are available. */ - public OidcUserInfo verifyOidcBearerTokenAndGetUserIdentifier(String bearerToken) throws AuthorizationException { + public OIDCUserInfo verifyOIDCBearerTokenAndGetUserIdentifier(String bearerToken) throws AuthorizationException { try { BearerAccessToken accessToken = BearerAccessToken.parse(bearerToken); List providers = getAvailableOidcProviders(); @@ -1019,7 +1019,7 @@ public OidcUserInfo verifyOidcBearerTokenAndGetUserIdentifier(String bearerToken // If either is present, return the result if (userRecordIdentifier.isPresent() || userInfo.isPresent()) { logger.log(Level.FINE, "Bearer token detected, provider {0} confirmed validity and provided user info", provider.getId()); - return new OidcUserInfo(userRecordIdentifier.get(), userInfo.get()); + return new OIDCUserInfo(userRecordIdentifier.get(), userInfo.get()); } } catch (IOException | OAuth2Exception e) { logger.log(Level.FINE, "Bearer token detected, provider " + provider.getId() + " indicates an invalid Token, skipping", e); diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/OidcUserInfo.java b/src/main/java/edu/harvard/iq/dataverse/authorization/OIDCUserInfo.java similarity index 92% rename from src/main/java/edu/harvard/iq/dataverse/authorization/OidcUserInfo.java rename to src/main/java/edu/harvard/iq/dataverse/authorization/OIDCUserInfo.java index c89ea354172..8c4cf165f18 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/OidcUserInfo.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/OIDCUserInfo.java @@ -14,11 +14,11 @@ * @see UserRecordIdentifier * @see UserInfo */ -public class OidcUserInfo { +public class OIDCUserInfo { private final UserRecordIdentifier userRecordIdentifier; private final UserInfo userClaimsInfo; - public OidcUserInfo(UserRecordIdentifier userRecordIdentifier, UserInfo userClaimsInfo) { + public OIDCUserInfo(UserRecordIdentifier userRecordIdentifier, UserInfo userClaimsInfo) { this.userRecordIdentifier = userRecordIdentifier; this.userClaimsInfo = userClaimsInfo; } diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java similarity index 92% rename from src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java rename to src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java index ff059e71ec6..ed58d548b8b 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java @@ -3,7 +3,7 @@ import edu.harvard.iq.dataverse.DvObject; import edu.harvard.iq.dataverse.api.dto.UserDTO; import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo; -import edu.harvard.iq.dataverse.authorization.OidcUserInfo; +import edu.harvard.iq.dataverse.authorization.OIDCUserInfo; import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; import edu.harvard.iq.dataverse.engine.command.*; @@ -17,12 +17,12 @@ import java.util.Map; @RequiredPermissions({}) -public class RegisterOidcUserCommand extends AbstractVoidCommand { +public class RegisterOIDCUserCommand extends AbstractVoidCommand { private final String bearerToken; private final UserDTO userDTO; - public RegisterOidcUserCommand(DataverseRequest aRequest, String bearerToken, UserDTO userDTO) { + public RegisterOIDCUserCommand(DataverseRequest aRequest, String bearerToken, UserDTO userDTO) { super(aRequest, (DvObject) null); this.bearerToken = bearerToken; this.userDTO = userDTO; @@ -71,7 +71,7 @@ private boolean isUsernameInUse(CommandContext ctxt, String username) { private void createUser(CommandContext ctxt) throws CommandException { try { - OidcUserInfo oidcUserInfo = ctxt.authentication().verifyOidcBearerTokenAndGetUserIdentifier(bearerToken); + OIDCUserInfo oidcUserInfo = ctxt.authentication().verifyOIDCBearerTokenAndGetUserIdentifier(bearerToken); UserRecordIdentifier userRecordIdentifier = oidcUserInfo.getUserRecordIdentifier(); if (ctxt.authentication().lookupUser(userRecordIdentifier) != null) { diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java index ecf0e901943..992281f9d70 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java @@ -520,7 +520,7 @@ public void testDeleteAuthenticatedUser() { @Test // This test is disabled because it is only compatible with the containerized development environment and would cause the Jenkins job to fail. @Disabled - public void testRegisterOidcUser() { + public void testRegisterOIDCUser() { // Set Up - Get the admin access token from the OIDC provider Response adminOidcLoginResponse = UtilIT.performKeycloakROPCLogin("admin", "admin"); adminOidcLoginResponse.then().assertThat() diff --git a/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java b/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java index b6f4ec922dd..ab4090eb0a0 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanismTest.java @@ -44,7 +44,7 @@ void testFindUserFromRequest_no_token() throws WrappedAuthErrorResponse { @Test void testFindUserFromRequest_invalid_token() throws AuthorizationException { String testErrorMessage = "test error"; - Mockito.when(sut.authSvc.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)).thenThrow(new AuthorizationException(testErrorMessage)); + Mockito.when(sut.authSvc.lookupUserByOIDCBearerToken(TEST_BEARER_TOKEN)).thenThrow(new AuthorizationException(testErrorMessage)); // when ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake(TEST_BEARER_TOKEN); @@ -57,7 +57,7 @@ void testFindUserFromRequest_invalid_token() throws AuthorizationException { @Test void testFindUserFromRequest_validToken_accountExists() throws WrappedAuthErrorResponse, AuthorizationException { AuthenticatedUser testAuthenticatedUser = new AuthenticatedUser(); - Mockito.when(sut.authSvc.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)).thenReturn(testAuthenticatedUser); + Mockito.when(sut.authSvc.lookupUserByOIDCBearerToken(TEST_BEARER_TOKEN)).thenReturn(testAuthenticatedUser); Mockito.when(sut.userSvc.updateLastApiUseTime(testAuthenticatedUser)).thenReturn(testAuthenticatedUser); // when @@ -71,7 +71,7 @@ void testFindUserFromRequest_validToken_accountExists() throws WrappedAuthErrorR @Test void testFindUserFromRequest_validToken_noAccount() throws AuthorizationException { - Mockito.when(sut.authSvc.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)).thenReturn(null); + Mockito.when(sut.authSvc.lookupUserByOIDCBearerToken(TEST_BEARER_TOKEN)).thenReturn(null); // when ContainerRequestContext testContainerRequest = new BearerTokenKeyContainerRequestTestFake(TEST_BEARER_TOKEN); diff --git a/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java b/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java index b2e4767a27d..a1e51fb3e01 100644 --- a/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java @@ -2,7 +2,9 @@ import com.nimbusds.oauth2.sdk.ParseException; import com.nimbusds.oauth2.sdk.token.BearerAccessToken; +import com.nimbusds.openid.connect.sdk.claims.UserInfo; import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; +import edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2Exception; import edu.harvard.iq.dataverse.authorization.providers.oauth2.oidc.OIDCAuthProvider; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.User; @@ -33,81 +35,83 @@ public void setUp() { } @Test - void testLookupUserByOidcBearerToken_no_OidcProvider() { + void testLookupUserByOIDCBearerToken_no_OIDCProvider() { // Given no OIDC providers are configured Mockito.when(sut.authProvidersRegistrationService.getAuthenticationProvidersMap()).thenReturn(Map.of()); - // When invoking lookupUserByOidcBearerToken + // When invoking lookupUserByOIDCBearerToken AuthorizationException exception = assertThrows(AuthorizationException.class, - () -> sut.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)); + () -> sut.lookupUserByOIDCBearerToken(TEST_BEARER_TOKEN)); // Then the exception message should indicate no OIDC provider is configured assertEquals(BundleUtil.getStringFromBundle("authenticationServiceBean.errors.bearerTokenDetectedNoOIDCProviderConfigured"), exception.getMessage()); } @Test - void testLookupUserByOidcBearerToken_oneProvider_invalidToken_1() throws ParseException, IOException { + void testLookupUserByOIDCBearerToken_oneProvider_invalidToken_1() throws ParseException, IOException { // Given a single OIDC provider that cannot find a user - OIDCAuthProvider oidcAuthProvider = mockOidcAuthProvider("OIEDC"); + OIDCAuthProvider oidcAuthProvider = mockOIDCAuthProvider("OIEDC"); BearerAccessToken token = BearerAccessToken.parse(TEST_BEARER_TOKEN); Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenReturn(Optional.empty()); - // When invoking lookupUserByOidcBearerToken + // When invoking lookupUserByOIDCBearerToken AuthorizationException exception = assertThrows(AuthorizationException.class, - () -> sut.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)); + () -> sut.lookupUserByOIDCBearerToken(TEST_BEARER_TOKEN)); // Then the exception message should indicate an unauthorized token assertEquals(BundleUtil.getStringFromBundle("authenticationServiceBean.errors.unauthorizedBearerToken"), exception.getMessage()); } @Test - void testLookupUserByOidcBearerToken_oneProvider_invalidToken_2() throws ParseException, IOException { + void testLookupUserByOIDCBearerToken_oneProvider_invalidToken_2() throws ParseException, IOException { // Given a single OIDC provider that throws an IOException - OIDCAuthProvider oidcAuthProvider = mockOidcAuthProvider("OIEDC"); + OIDCAuthProvider oidcAuthProvider = mockOIDCAuthProvider("OIEDC"); BearerAccessToken token = BearerAccessToken.parse(TEST_BEARER_TOKEN); Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenThrow(IOException.class); - // When invoking lookupUserByOidcBearerToken + // When invoking lookupUserByOIDCBearerToken AuthorizationException exception = assertThrows(AuthorizationException.class, - () -> sut.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN)); + () -> sut.lookupUserByOIDCBearerToken(TEST_BEARER_TOKEN)); // Then the exception message should indicate an unauthorized token assertEquals(BundleUtil.getStringFromBundle("authenticationServiceBean.errors.unauthorizedBearerToken"), exception.getMessage()); } @Test - void testLookupUserByOidcBearerToken_oneProvider_validToken() throws ParseException, IOException, AuthorizationException { + void testLookupUserByOIDCBearerToken_oneProvider_validToken() throws ParseException, IOException, AuthorizationException, OAuth2Exception { // Given a single OIDC provider that returns a valid user identifier - OIDCAuthProvider oidcAuthProvider = mockOidcAuthProvider("OIEDC"); + OIDCAuthProvider oidcAuthProvider = mockOIDCAuthProvider("OIEDC"); AuthenticatedUser authenticatedUser = setupAuthenticatedUserQueryWithResult(new AuthenticatedUser()); UserRecordIdentifier userInfo = new UserRecordIdentifier("OIEDC", "KEY"); BearerAccessToken token = BearerAccessToken.parse(TEST_BEARER_TOKEN); Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenReturn(Optional.of(userInfo)); + Mockito.when(oidcAuthProvider.getUserInfo(token)).thenReturn(Optional.of(Mockito.mock(UserInfo.class))); - // When invoking lookupUserByOidcBearerToken - User actualUser = sut.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN); + // When invoking lookupUserByOIDCBearerToken + User actualUser = sut.lookupUserByOIDCBearerToken(TEST_BEARER_TOKEN); // Then the actual user should match the expected authenticated user assertEquals(authenticatedUser, actualUser); } @Test - void testLookupUserByOidcBearerToken_oneProvider_validToken_noAccount() throws ParseException, IOException, AuthorizationException { + void testLookupUserByOIDCBearerToken_oneProvider_validToken_noAccount() throws ParseException, IOException, AuthorizationException, OAuth2Exception { // Given a single OIDC provider with a valid user identifier but no account exists - OIDCAuthProvider oidcAuthProvider = mockOidcAuthProvider("OIEDC"); + OIDCAuthProvider oidcAuthProvider = mockOIDCAuthProvider("OIEDC"); setupAuthenticatedUserQueryWithNoResult(); UserRecordIdentifier userInfo = new UserRecordIdentifier("OIEDC", "KEY"); BearerAccessToken token = BearerAccessToken.parse(TEST_BEARER_TOKEN); Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenReturn(Optional.of(userInfo)); + Mockito.when(oidcAuthProvider.getUserInfo(token)).thenReturn(Optional.of(Mockito.mock(UserInfo.class))); - // When invoking lookupUserByOidcBearerToken - User actualUser = sut.lookupUserByOidcBearerToken(TEST_BEARER_TOKEN); + // When invoking lookupUserByOIDCBearerToken + User actualUser = sut.lookupUserByOIDCBearerToken(TEST_BEARER_TOKEN); // Then no user should be found, and result should be null assertNull(actualUser); } - private OIDCAuthProvider mockOidcAuthProvider(String providerID) { + private OIDCAuthProvider mockOIDCAuthProvider(String providerID) { OIDCAuthProvider oidcAuthProvider = Mockito.mock(OIDCAuthProvider.class); Mockito.when(oidcAuthProvider.getId()).thenReturn(providerID); Mockito.when(sut.authProvidersRegistrationService.getAuthenticationProvidersMap()).thenReturn(Map.of(providerID, oidcAuthProvider)); diff --git a/src/test/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthenticationProviderFactoryIT.java b/src/test/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthenticationProviderFactoryIT.java index 839781b6b3b..58b792691b9 100644 --- a/src/test/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthenticationProviderFactoryIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthenticationProviderFactoryIT.java @@ -159,7 +159,7 @@ void testApiBearerAuth() throws Exception { AuthenticatedUser user = new MockAuthenticatedUser(); // setup mocks (we don't want or need a database here) - when(authService.lookupUserByOidcBearerToken(token)).thenReturn(user); + when(authService.lookupUserByOIDCBearerToken(token)).thenReturn(user); when(userService.updateLastApiUseTime(user)).thenReturn(user); // when (let's do this again, but now with the actual subject under test!) diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java similarity index 89% rename from src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommandTest.java rename to src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java index 845ad8c3ed9..fb07f24b924 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOidcUserCommandTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java @@ -4,7 +4,7 @@ import edu.harvard.iq.dataverse.api.dto.UserDTO; import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo; import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; -import edu.harvard.iq.dataverse.authorization.OidcUserInfo; +import edu.harvard.iq.dataverse.authorization.OIDCUserInfo; import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; @@ -25,7 +25,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.*; -class RegisterOidcUserCommandTest { +class RegisterOIDCUserCommandTest { private static final String TEST_BEARER_TOKEN = "Bearer test"; @@ -38,11 +38,11 @@ class RegisterOidcUserCommandTest { private AuthenticationServiceBean authServiceMock; @InjectMocks - private RegisterOidcUserCommand sut; + private RegisterOIDCUserCommand sut; private UserRecordIdentifier userRecordIdentifierMock; private UserInfo userInfoMock; - private OidcUserInfo oidcUserInfoMock; + private OIDCUserInfo OIDCUserInfoMock; private AuthenticatedUser existingTestUser; @BeforeEach @@ -52,11 +52,11 @@ void setUp() { userRecordIdentifierMock = mock(UserRecordIdentifier.class); userInfoMock = mock(UserInfo.class); - oidcUserInfoMock = new OidcUserInfo(userRecordIdentifierMock, userInfoMock); + OIDCUserInfoMock = new OIDCUserInfo(userRecordIdentifierMock, userInfoMock); existingTestUser = new AuthenticatedUser(); when(context.authentication()).thenReturn(authServiceMock); - sut = new RegisterOidcUserCommand(makeRequest(), TEST_BEARER_TOKEN, userDTO); + sut = new RegisterOIDCUserCommand(makeRequest(), TEST_BEARER_TOKEN, userDTO); } private void setUpDefaultUserDTO() { @@ -104,20 +104,20 @@ public void execute_acceptedTerms_availableEmailAndUsername() { @Test void execute_throwsPermissionException_onAuthorizationException() throws AuthorizationException { String testAuthorizationExceptionMessage = "Authorization failed"; - when(context.authentication().verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)) + when(context.authentication().verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)) .thenThrow(new AuthorizationException(testAuthorizationExceptionMessage)); assertThatThrownBy(() -> sut.execute(context)) .isInstanceOf(PermissionException.class) .hasMessageContaining(testAuthorizationExceptionMessage); - verify(context.authentication(), times(1)).verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN); + verify(context.authentication(), times(1)).verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN); } @Test void execute_throwsIllegalCommandException_ifUserAlreadyRegisteredWithToken() throws AuthorizationException { - when(context.authentication().verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)) - .thenReturn(oidcUserInfoMock); + when(context.authentication().verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)) + .thenReturn(OIDCUserInfoMock); when(context.authentication().lookupUser(userRecordIdentifierMock)).thenReturn(new AuthenticatedUser()); assertThatThrownBy(() -> sut.execute(context)) @@ -129,7 +129,7 @@ void execute_throwsIllegalCommandException_ifUserAlreadyRegisteredWithToken() th @Test void execute_happyPath_withoutAffiliationAndPosition() throws AuthorizationException, CommandException { - when(authServiceMock.verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(oidcUserInfoMock); + when(authServiceMock.verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(OIDCUserInfoMock); sut.execute(context); @@ -152,7 +152,7 @@ void execute_happyPath_withAffiliationAndPosition() throws AuthorizationExceptio userDTO.setPosition("test position"); userDTO.setAffiliation("test affiliation"); - when(authServiceMock.verifyOidcBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(oidcUserInfoMock); + when(authServiceMock.verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(OIDCUserInfoMock); sut.execute(context); From dce7edf437b8dc3414e0e10890ccfba835652b55 Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 13 Nov 2024 16:53:40 +0000 Subject: [PATCH 042/101] Changed: using claims as UserDTO fields when available from the IdP --- .../command/impl/RegisterOIDCUserCommand.java | 101 ++++++---- .../iq/dataverse/util/json/JsonParser.java | 150 +++++++-------- src/main/java/propertyFiles/Bundle.properties | 4 + .../edu/harvard/iq/dataverse/api/UsersIT.java | 180 +++++++----------- .../impl/RegisterOIDCUserCommandTest.java | 6 +- 5 files changed, 220 insertions(+), 221 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java index ed58d548b8b..a82e6b57b68 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java @@ -1,5 +1,6 @@ package edu.harvard.iq.dataverse.engine.command.impl; +import com.nimbusds.openid.connect.sdk.claims.UserInfo; import edu.harvard.iq.dataverse.DvObject; import edu.harvard.iq.dataverse.api.dto.UserDTO; import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo; @@ -30,35 +31,91 @@ public RegisterOIDCUserCommand(DataverseRequest aRequest, String bearerToken, Us @Override protected void executeImpl(CommandContext ctxt) throws CommandException { - Map fieldErrors = validateUserFields(ctxt); + try { + OIDCUserInfo oidcUserInfo = ctxt.authentication().verifyOIDCBearerTokenAndGetUserIdentifier(bearerToken); + UserRecordIdentifier userRecordIdentifier = oidcUserInfo.getUserRecordIdentifier(); + + if (ctxt.authentication().lookupUser(userRecordIdentifier) != null) { + throw new IllegalCommandException(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userAlreadyRegisteredWithToken"), this); + } - if (!fieldErrors.isEmpty()) { - throw new InvalidFieldsCommandException( - BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.invalidFields"), - this, - fieldErrors + UserInfo userClaimsInfo = oidcUserInfo.getUserClaimsInfo(); + + // Update the UserDTO object with available OIDC user claims; keep existing values if claims are absent + userDTO.setUsername(getValueOrDefault(userClaimsInfo.getPreferredUsername(), userDTO.getUsername())); + userDTO.setFirstName(getValueOrDefault(userClaimsInfo.getGivenName(), userDTO.getFirstName())); + userDTO.setLastName(getValueOrDefault(userClaimsInfo.getFamilyName(), userDTO.getLastName())); + userDTO.setEmailAddress(getValueOrDefault(userClaimsInfo.getEmailAddress(), userDTO.getEmailAddress())); + + AuthenticatedUserDisplayInfo userDisplayInfo = new AuthenticatedUserDisplayInfo( + userDTO.getFirstName(), + userDTO.getLastName(), + userDTO.getEmailAddress(), + userDTO.getAffiliation() != null ? userDTO.getAffiliation() : "", + userDTO.getPosition() != null ? userDTO.getPosition() : "" ); + + Map fieldErrors = validateUserFields(ctxt); + if (!fieldErrors.isEmpty()) { + throw new InvalidFieldsCommandException( + BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.invalidFields"), + this, + fieldErrors + ); + } + + ctxt.authentication().createAuthenticatedUser(userRecordIdentifier, userDTO.getUsername(), userDisplayInfo, true); + + } catch (AuthorizationException ex) { + throw new PermissionException(ex.getMessage(), this, null, null, true); } + } - createUser(ctxt); + private String getValueOrDefault(String oidcValue, String dtoValue) { + return (oidcValue == null || oidcValue.isEmpty()) ? dtoValue : oidcValue; } private Map validateUserFields(CommandContext ctxt) { Map fieldErrors = new HashMap<>(); + validateTermsAccepted(fieldErrors); + validateEmailAddress(ctxt, fieldErrors); + validateUsername(ctxt, fieldErrors); + + validateRequiredField("firstName", userDTO.getFirstName(), "registerOidcUserCommand.errors.firstNameFieldRequired", fieldErrors); + validateRequiredField("lastName", userDTO.getLastName(), "registerOidcUserCommand.errors.lastNameFieldRequired", fieldErrors); + + return fieldErrors; + } + + private void validateTermsAccepted(Map fieldErrors) { if (!userDTO.isTermsAccepted()) { fieldErrors.put("termsAccepted", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userShouldAcceptTerms")); } + } - if (isEmailInUse(ctxt, userDTO.getEmailAddress())) { + private void validateEmailAddress(CommandContext ctxt, Map fieldErrors) { + String emailAddress = userDTO.getEmailAddress(); + if (emailAddress == null || emailAddress.isEmpty()) { + fieldErrors.put("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.emailFieldRequired")); + } else if (isEmailInUse(ctxt, emailAddress)) { fieldErrors.put("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.emailAddressInUse")); } + } - if (isUsernameInUse(ctxt, userDTO.getUsername())) { + private void validateUsername(CommandContext ctxt, Map fieldErrors) { + String username = userDTO.getUsername(); + if (username == null || username.isEmpty()) { + fieldErrors.put("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.usernameFieldRequired")); + } else if (isUsernameInUse(ctxt, username)) { fieldErrors.put("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.usernameInUse")); } + } - return fieldErrors; + private void validateRequiredField(String fieldName, String fieldValue, String bundleKey, Map fieldErrors) { + if (fieldValue == null || fieldValue.isEmpty()) { + fieldErrors.put(fieldName, BundleUtil.getStringFromBundle(bundleKey)); + } } private boolean isEmailInUse(CommandContext ctxt, String emailAddress) { @@ -68,28 +125,4 @@ private boolean isEmailInUse(CommandContext ctxt, String emailAddress) { private boolean isUsernameInUse(CommandContext ctxt, String username) { return ctxt.authentication().getAuthenticatedUser(username) != null; } - - private void createUser(CommandContext ctxt) throws CommandException { - try { - OIDCUserInfo oidcUserInfo = ctxt.authentication().verifyOIDCBearerTokenAndGetUserIdentifier(bearerToken); - UserRecordIdentifier userRecordIdentifier = oidcUserInfo.getUserRecordIdentifier(); - - if (ctxt.authentication().lookupUser(userRecordIdentifier) != null) { - throw new IllegalCommandException(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userAlreadyRegisteredWithToken"), this); - } - - AuthenticatedUserDisplayInfo userInfo = new AuthenticatedUserDisplayInfo( - userDTO.getFirstName(), - userDTO.getLastName(), - userDTO.getEmailAddress(), - userDTO.getAffiliation() != null ? userDTO.getAffiliation() : "", - userDTO.getPosition() != null ? userDTO.getPosition() : "" - ); - - ctxt.authentication().createAuthenticatedUser(userRecordIdentifier, userDTO.getUsername(), userInfo, true); - - } catch (AuthorizationException ex) { - throw new PermissionException(ex.getMessage(), this, null, null, true); - } - } } diff --git a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java index f23ea7dda4f..1656c897ea1 100644 --- a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java +++ b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java @@ -78,11 +78,11 @@ public class JsonParser { DatasetTypeServiceBean datasetTypeService; HarvestingClient harvestingClient = null; boolean allowHarvestingMissingCVV = false; - + /** * if lenient, we will accept alternate spellings for controlled vocabulary values */ - boolean lenient = false; + boolean lenient = false; @Deprecated public JsonParser(DatasetFieldServiceBean datasetFieldSvc, MetadataBlockServiceBean blockService, SettingsServiceBean settingsService) { @@ -94,7 +94,7 @@ public JsonParser(DatasetFieldServiceBean datasetFieldSvc, MetadataBlockServiceB public JsonParser(DatasetFieldServiceBean datasetFieldSvc, MetadataBlockServiceBean blockService, SettingsServiceBean settingsService, LicenseServiceBean licenseService, DatasetTypeServiceBean datasetTypeService) { this(datasetFieldSvc, blockService, settingsService, licenseService, datasetTypeService, null); } - + public JsonParser(DatasetFieldServiceBean datasetFieldSvc, MetadataBlockServiceBean blockService, SettingsServiceBean settingsService, LicenseServiceBean licenseService, DatasetTypeServiceBean datasetTypeService, HarvestingClient harvestingClient) { this.datasetFieldSvc = datasetFieldSvc; this.blockService = blockService; @@ -108,7 +108,7 @@ public JsonParser(DatasetFieldServiceBean datasetFieldSvc, MetadataBlockServiceB public JsonParser() { this( null,null,null ); } - + public boolean isLenient() { return lenient; } @@ -328,10 +328,10 @@ public IpGroup parseIpGroup(JsonObject obj) { return retVal; } - + public MailDomainGroup parseMailDomainGroup(JsonObject obj) throws JsonParseException { MailDomainGroup grp = new MailDomainGroup(); - + if (obj.containsKey("id")) { grp.setId(obj.getJsonNumber("id").longValue()); } @@ -355,7 +355,7 @@ public MailDomainGroup parseMailDomainGroup(JsonObject obj) throws JsonParseExce } else { throw new JsonParseException("Field domains is mandatory."); } - + return grp; } @@ -393,7 +393,7 @@ public Dataset parseDataset(JsonObject obj) throws JsonParseException { throw new JsonParseException("Invalid dataset type: " + datasetTypeIn); } - DatasetVersion dsv = new DatasetVersion(); + DatasetVersion dsv = new DatasetVersion(); dsv.setDataset(dataset); dsv = parseDatasetVersion(obj.getJsonObject("datasetVersion"), dsv); List versions = new ArrayList<>(1); @@ -424,7 +424,7 @@ public DatasetVersion parseDatasetVersion(JsonObject obj, DatasetVersion dsv) th if (dsv.getId()==null) { dsv.setId(parseLong(obj.getString("id", null))); } - + String versionStateStr = obj.getString("versionState", null); if (versionStateStr != null) { dsv.setVersionState(DatasetVersion.VersionState.valueOf(versionStateStr)); @@ -437,8 +437,8 @@ public DatasetVersion parseDatasetVersion(JsonObject obj, DatasetVersion dsv) th // Terms of Use related fields TermsOfUseAndAccess terms = new TermsOfUseAndAccess(); - License license = null; - + License license = null; + try { // This method will attempt to parse the license in the format // in which it appears in our json exports, as a compound @@ -457,7 +457,7 @@ public DatasetVersion parseDatasetVersion(JsonObject obj, DatasetVersion dsv) th // "license" : "CC0 1.0" license = parseLicense(obj.getString("license", null)); } - + if (license == null) { terms.setLicense(license); terms.setTermsOfUse(obj.getString("termsOfUse", null)); @@ -495,13 +495,13 @@ public DatasetVersion parseDatasetVersion(JsonObject obj, DatasetVersion dsv) th dsv.setFileMetadatas(parseFiles(filesJson, dsv)); } return dsv; - } catch (ParseException ex) { + } catch (ParseException ex) { throw new JsonParseException(BundleUtil.getStringFromBundle("jsonparser.error.parsing.date", Arrays.asList(ex.getMessage())) , ex); } catch (NumberFormatException ex) { throw new JsonParseException(BundleUtil.getStringFromBundle("jsonparser.error.parsing.number", Arrays.asList(ex.getMessage())), ex); } } - + private edu.harvard.iq.dataverse.license.License parseLicense(String licenseNameOrUri) throws JsonParseException { if (licenseNameOrUri == null){ boolean safeDefaultIfKeyNotFound = true; @@ -515,7 +515,7 @@ private edu.harvard.iq.dataverse.license.License parseLicense(String licenseName if (license == null) throw new JsonParseException("Invalid license: " + licenseNameOrUri); return license; } - + private edu.harvard.iq.dataverse.license.License parseLicense(JsonObject licenseObj) throws JsonParseException { if (licenseObj == null){ boolean safeDefaultIfKeyNotFound = true; @@ -525,12 +525,12 @@ private edu.harvard.iq.dataverse.license.License parseLicense(JsonObject license return licenseService.getDefault(); } } - + String licenseName = licenseObj.getString("name", null); String licenseUri = licenseObj.getString("uri", null); - - License license = null; - + + License license = null; + // If uri is provided, we'll try that first. This is an easier lookup // method; the uri is always the same. The name may have been customized // (translated) on this instance, so we may be dealing with such translated @@ -540,17 +540,17 @@ private edu.harvard.iq.dataverse.license.License parseLicense(JsonObject license if (licenseUri != null) { license = licenseService.getByNameOrUri(licenseUri); } - + if (license != null) { return license; } - + if (licenseName == null) { - String exMsg = "Invalid or unsupported license section submitted" + String exMsg = "Invalid or unsupported license section submitted" + (licenseUri != null ? ": " + licenseUri : "."); - throw new JsonParseException("Invalid or unsupported license section submitted."); + throw new JsonParseException("Invalid or unsupported license section submitted."); } - + license = licenseService.getByPotentiallyLocalizedName(licenseName); if (license == null) { throw new JsonParseException("Invalid or unsupported license: " + licenseName); @@ -569,13 +569,13 @@ public List parseMetadataBlocks(JsonObject json) throws JsonParseE } return fields; } - + public List parseMultipleFields(JsonObject json) throws JsonParseException { JsonArray fieldsJson = json.getJsonArray("fields"); List fields = parseFieldsFromArray(fieldsJson, false); return fields; } - + public List parseMultipleFieldsForDelete(JsonObject json) throws JsonParseException { List fields = new LinkedList<>(); for (JsonObject fieldJson : json.getJsonArray("fields").getValuesAs(JsonObject.class)) { @@ -583,7 +583,7 @@ public List parseMultipleFieldsForDelete(JsonObject json) throws J } return fields; } - + private List parseFieldsFromArray(JsonArray fieldsArray, Boolean testType) throws JsonParseException { List fields = new LinkedList<>(); for (JsonObject fieldJson : fieldsArray.getValuesAs(JsonObject.class)) { @@ -595,18 +595,18 @@ private List parseFieldsFromArray(JsonArray fieldsArray, Boolean t } catch (CompoundVocabularyException ex) { DatasetFieldType fieldType = datasetFieldSvc.findByNameOpt(fieldJson.getString("typeName", "")); if (lenient && (DatasetFieldConstant.geographicCoverage).equals(fieldType.getName())) { - fields.add(remapGeographicCoverage( ex)); + fields.add(remapGeographicCoverage( ex)); } else { // if not lenient mode, re-throw exception throw ex; } - } + } } return fields; - + } - + public List parseFiles(JsonArray metadatasJson, DatasetVersion dsv) throws JsonParseException { List fileMetadatas = new LinkedList<>(); if (metadatasJson != null) { @@ -620,7 +620,7 @@ public List parseFiles(JsonArray metadatasJson, DatasetVersion dsv fileMetadata.setDirectoryLabel(directoryLabel); fileMetadata.setDescription(description); fileMetadata.setDatasetVersion(dsv); - + if ( filemetadataJson.containsKey("dataFile") ) { DataFile dataFile = parseDataFile(filemetadataJson.getJsonObject("dataFile")); dataFile.getFileMetadatas().add(fileMetadata); @@ -633,7 +633,7 @@ public List parseFiles(JsonArray metadatasJson, DatasetVersion dsv dsv.getDataset().getFiles().add(dataFile); } } - + fileMetadatas.add(fileMetadata); fileMetadata.setCategories(getCategories(filemetadataJson, dsv.getDataset())); } @@ -641,19 +641,19 @@ public List parseFiles(JsonArray metadatasJson, DatasetVersion dsv return fileMetadatas; } - + public DataFile parseDataFile(JsonObject datafileJson) { DataFile dataFile = new DataFile(); - + Timestamp timestamp = new Timestamp(new Date().getTime()); dataFile.setCreateDate(timestamp); dataFile.setModificationTime(timestamp); dataFile.setPermissionModificationTime(timestamp); - + if ( datafileJson.containsKey("filesize") ) { dataFile.setFilesize(datafileJson.getJsonNumber("filesize").longValueExact()); } - + String contentType = datafileJson.getString("contentType", null); if (contentType == null) { contentType = "application/octet-stream"; @@ -716,21 +716,21 @@ public DataFile parseDataFile(JsonObject datafileJson) { // TODO: // unf (if available)... etc.? - + dataFile.setContentType(contentType); dataFile.setStorageIdentifier(storageIdentifier); - + return dataFile; } /** * Special processing for GeographicCoverage compound field: * Handle parsing exceptions caused by invalid controlled vocabulary in the "country" field by * putting the invalid data in "otherGeographicCoverage" in a new compound value. - * + * * @param ex - contains the invalid values to be processed - * @return a compound DatasetField that contains the newly created values, in addition to + * @return a compound DatasetField that contains the newly created values, in addition to * the original valid values. - * @throws JsonParseException + * @throws JsonParseException */ private DatasetField remapGeographicCoverage(CompoundVocabularyException ex) throws JsonParseException{ List> geoCoverageList = new ArrayList<>(); @@ -757,23 +757,23 @@ private DatasetField remapGeographicCoverage(CompoundVocabularyException ex) thr } return geoCoverageField; } - - + + public DatasetField parseFieldForDelete(JsonObject json) throws JsonParseException{ DatasetField ret = new DatasetField(); - DatasetFieldType type = datasetFieldSvc.findByNameOpt(json.getString("typeName", "")); + DatasetFieldType type = datasetFieldSvc.findByNameOpt(json.getString("typeName", "")); if (type == null) { throw new JsonParseException("Can't find type '" + json.getString("typeName", "") + "'"); } return ret; } - - + + public DatasetField parseField(JsonObject json) throws JsonParseException{ return parseField(json, true); } - - + + public DatasetField parseField(JsonObject json, Boolean testType) throws JsonParseException { if (json == null) { return null; @@ -781,7 +781,7 @@ public DatasetField parseField(JsonObject json, Boolean testType) throws JsonPar DatasetField ret = new DatasetField(); DatasetFieldType type = datasetFieldSvc.findByNameOpt(json.getString("typeName", "")); - + if (type == null) { logger.fine("Can't find type '" + json.getString("typeName", "") + "'"); @@ -799,8 +799,8 @@ public DatasetField parseField(JsonObject json, Boolean testType) throws JsonPar if (testType && type.isControlledVocabulary() && !json.getString("typeClass").equals("controlledVocabulary")) { throw new JsonParseException("incorrect typeClass for field " + json.getString("typeName", "") + ", should be controlledVocabulary"); } - - + + ret.setDatasetFieldType(type); if (type.isCompound()) { @@ -813,11 +813,11 @@ public DatasetField parseField(JsonObject json, Boolean testType) throws JsonPar return ret; } - + public void parseCompoundValue(DatasetField dsf, DatasetFieldType compoundType, JsonObject json) throws JsonParseException { parseCompoundValue(dsf, compoundType, json, true); } - + public void parseCompoundValue(DatasetField dsf, DatasetFieldType compoundType, JsonObject json, Boolean testType) throws JsonParseException { List vocabExceptions = new ArrayList<>(); List vals = new LinkedList<>(); @@ -839,7 +839,7 @@ public void parseCompoundValue(DatasetField dsf, DatasetFieldType compoundType, } catch(ControlledVocabularyException ex) { vocabExceptions.add(ex); } - + if (f!=null) { if (!compoundType.getChildDatasetFieldTypes().contains(f.getDatasetFieldType())) { throw new JsonParseException("field " + f.getDatasetFieldType().getName() + " is not a child of " + compoundType.getName()); @@ -856,10 +856,10 @@ public void parseCompoundValue(DatasetField dsf, DatasetFieldType compoundType, order++; } - + } else { - + DatasetFieldCompoundValue cv = new DatasetFieldCompoundValue(); List fields = new LinkedList<>(); JsonObject value = json.getJsonObject("value"); @@ -880,7 +880,7 @@ public void parseCompoundValue(DatasetField dsf, DatasetFieldType compoundType, cv.setChildDatasetFields(fields); vals.add(cv); } - + } if (!vocabExceptions.isEmpty()) { throw new CompoundVocabularyException( "Invalid controlled vocabulary in compound field ", vocabExceptions, vals); @@ -919,7 +919,7 @@ public void parsePrimitiveValue(DatasetField dsf, DatasetFieldType dft , JsonObj try {json.getString("value");} catch (ClassCastException cce) { throw new JsonParseException("Invalid value submitted for " + dft.getName() + ". It should be a single value."); - } + } DatasetFieldValue datasetFieldValue = new DatasetFieldValue(); datasetFieldValue.setValue(json.getString("value", "").trim()); datasetFieldValue.setDatasetField(dsf); @@ -933,7 +933,7 @@ public void parsePrimitiveValue(DatasetField dsf, DatasetFieldType dft , JsonObj dsf.setDatasetFieldValues(vals); } - + public Workflow parseWorkflow(JsonObject json) throws JsonParseException { Workflow retVal = new Workflow(); validate("", json, "name", ValueType.STRING); @@ -947,12 +947,12 @@ public Workflow parseWorkflow(JsonObject json) throws JsonParseException { retVal.setSteps(steps); return retVal; } - + public WorkflowStepData parseStepData( JsonObject json ) throws JsonParseException { WorkflowStepData wsd = new WorkflowStepData(); validate("step", json, "provider", ValueType.STRING); validate("step", json, "stepType", ValueType.STRING); - + wsd.setProviderId(json.getString("provider")); wsd.setStepType(json.getString("stepType")); if ( json.containsKey("parameters") ) { @@ -969,7 +969,7 @@ public WorkflowStepData parseStepData( JsonObject json ) throws JsonParseExcepti } return wsd; } - + private String jsonValueToString(JsonValue jv) { switch ( jv.getValueType() ) { case STRING: return ((JsonString)jv).getString(); @@ -1049,11 +1049,11 @@ Long parseLong(String str) throws NumberFormatException { int parsePrimitiveInt(String str, int defaultValue) { return str == null ? defaultValue : Integer.parseInt(str); } - + public String parseHarvestingClient(JsonObject obj, HarvestingClient harvestingClient) throws JsonParseException { - + String dataverseAlias = obj.getString("dataverseAlias",null); - + harvestingClient.setName(obj.getString("nickName",null)); harvestingClient.setHarvestStyle(obj.getString("style", "default")); harvestingClient.setHarvestingUrl(obj.getString("harvestUrl",null)); @@ -1088,7 +1088,7 @@ private List getCategories(JsonObject filemetadataJson, Datase } return dataFileCategories; } - + /** * Validate than a JSON object has a field of an expected type, or throw an * inforamtive exception. @@ -1096,10 +1096,10 @@ private List getCategories(JsonObject filemetadataJson, Datase * @param jobject * @param fieldName * @param expectedValueType - * @throws JsonParseException + * @throws JsonParseException */ private void validate(String objectName, JsonObject jobject, String fieldName, ValueType expectedValueType) throws JsonParseException { - if ( (!jobject.containsKey(fieldName)) + if ( (!jobject.containsKey(fieldName)) || (jobject.get(fieldName).getValueType()!=expectedValueType) ) { throw new JsonParseException( objectName + " missing a field named '"+fieldName+"' of type " + expectedValueType ); } @@ -1107,13 +1107,13 @@ private void validate(String objectName, JsonObject jobject, String fieldName, V public UserDTO parseUserDTO(JsonObject jobj) throws JsonParseException { UserDTO userDTO = new UserDTO(); - userDTO.setUsername(getMandatoryString(jobj, "username")); - userDTO.setEmailAddress(getMandatoryString(jobj, "emailAddress")); - userDTO.setFirstName(getMandatoryString(jobj, "firstName")); - userDTO.setLastName(getMandatoryString(jobj, "lastName")); + userDTO.setUsername(jobj.getString("username", null)); + userDTO.setEmailAddress(jobj.getString("emailAddress", null)); + userDTO.setFirstName(jobj.getString("firstName", null)); + userDTO.setLastName(jobj.getString("lastName", null)); userDTO.setTermsAccepted(getMandatoryBoolean(jobj, "termsAccepted")); - userDTO.setAffiliation(jobj.getString("affiliation")); - userDTO.setPosition(jobj.getString("position")); + userDTO.setAffiliation(jobj.getString("affiliation", null)); + userDTO.setPosition(jobj.getString("position", null)); return userDTO; } } diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index 1ae846c338e..34c4334dbdb 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -3072,6 +3072,10 @@ users.api.userRegistered=User registered. registerOidcUserCommand.errors.userAlreadyRegisteredWithToken=User is already registered with this token. registerOidcUserCommand.errors.invalidFields=The provided fields are invalid for registering a new user. registerOidcUserCommand.errors.userShouldAcceptTerms=Terms should be accepted. +registerOidcUserCommand.errors.emailFieldRequired=It is required to include an emailAddress field in the request JSON for registering the user. +registerOidcUserCommand.errors.usernameFieldRequired=It is required to include a username field in the request JSON for registering the user. +registerOidcUserCommand.errors.firstNameFieldRequired=It is required to include a firstName field in the request JSON for registering the user. +registerOidcUserCommand.errors.lastNameFieldRequired=It is required to include a lastName field in the request JSON for registering the user. registerOidcUserCommand.errors.emailAddressInUse=Email already in use. registerOidcUserCommand.errors.usernameInUse=Username already in use. diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java index 992281f9d70..acd5bd658e0 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java @@ -528,9 +528,10 @@ public void testRegisterOIDCUser() { .body("access_token", notNullValue()); String adminOidcAccessToken = adminOidcLoginResponse.jsonPath().getString("access_token"); - // Set Up - Create random user in the OIDC provider + // Set Up - Create random user in the OIDC provider without some necessary claims (email, firstName and lastName) String randomUsername = UUID.randomUUID().toString().substring(0, 8); - String newKeycloakUserJson = "{" + + String newKeycloakUserWithoutClaimsJson = "{" + "\"username\":\"" + randomUsername + "\"," + "\"enabled\":true," + "\"credentials\":[" @@ -541,10 +542,39 @@ public void testRegisterOIDCUser() { + " }" + "]" + "}"; - Response createKeycloakOidcUserResponse = UtilIT.createKeycloakUser(adminOidcAccessToken, newKeycloakUserJson); + + Response createKeycloakOidcUserResponse = UtilIT.createKeycloakUser(adminOidcAccessToken, newKeycloakUserWithoutClaimsJson); createKeycloakOidcUserResponse.then().assertThat().statusCode(CREATED.getStatusCode()); + Response newUserOidcLoginResponse = UtilIT.performKeycloakROPCLogin(randomUsername, "password"); - String newUserOidcAccessToken = newUserOidcLoginResponse.jsonPath().getString("access_token"); + String userWithoutClaimsAccessToken = newUserOidcLoginResponse.jsonPath().getString("access_token"); + + // Set Up - Create a second random user in the OIDC provider with all necessary claims (including email, firstName and lastName) + randomUsername = UUID.randomUUID().toString().substring(0, 8); + String email = randomUsername + "@dataverse.org"; + String firstName = "John"; + String lastName = "Doe"; + + String newKeycloakUserWithClaimsJson = "{" + + "\"username\":\"" + randomUsername + "\"," + + "\"enabled\":true," + + "\"email\":\"" + email + "\"," + + "\"firstName\":\"" + firstName + "\"," + + "\"lastName\":\"" + lastName + "\"," + + "\"credentials\":[" + + " {" + + " \"type\":\"password\"," + + " \"value\":\"password\"," + + " \"temporary\":false" + + " }" + + "]" + + "}"; + + Response createKeycloakOidcUserWithClaimsResponse = UtilIT.createKeycloakUser(adminOidcAccessToken, newKeycloakUserWithClaimsJson); + createKeycloakOidcUserWithClaimsResponse.then().assertThat().statusCode(CREATED.getStatusCode()); + + Response newUserWithClaimsOidcLoginResponse = UtilIT.performKeycloakROPCLogin(randomUsername, "password"); + String userWithClaimsAccessToken = newUserWithClaimsOidcLoginResponse.jsonPath().getString("access_token"); // Should return error when empty token is passed Response registerOidcUserResponse = UtilIT.registerOidcUser( @@ -555,77 +585,29 @@ public void testRegisterOIDCUser() { .statusCode(BAD_REQUEST.getStatusCode()) .body("message", equalTo(BundleUtil.getStringFromBundle("users.api.errors.bearerTokenRequired"))); - // Should return error when a required field in the User JSON is missing (username) - registerOidcUserResponse = UtilIT.registerOidcUser( - "{" - + "\"firstName\":\"YourFirstName\"," - + "\"lastName\":\"YourLastName\"," - + "\"emailAddress\":\"yourEmail@example.com\"," - + "\"affiliation\":\"YourAffiliation\"," - + "\"position\":\"YourPosition\"," - + "\"termsAccepted\":true" - + "}", - "Bearer testBearerToken" - ); - registerOidcUserResponse.then().assertThat() - .statusCode(BAD_REQUEST.getStatusCode()) - .body("message", equalTo("Error parsing the POSTed User json: Field 'username' is mandatory")); - - // Should return error when a required field in the User JSON is missing (firstName) - registerOidcUserResponse = UtilIT.registerOidcUser( - "{" - + "\"username\":\"yourUsername\"," - + "\"lastName\":\"YourLastName\"," - + "\"emailAddress\":\"yourEmail@example.com\"," - + "\"affiliation\":\"YourAffiliation\"," - + "\"position\":\"YourPosition\"," - + "\"termsAccepted\":true" - + "}", - "Bearer testBearerToken" - ); - registerOidcUserResponse.then().assertThat() - .statusCode(BAD_REQUEST.getStatusCode()) - .body("message", equalTo("Error parsing the POSTed User json: Field 'firstName' is mandatory")); - - // Should return error when a required field in the User JSON is missing (lastName) + // Should return error when a malformed User JSON is sent registerOidcUserResponse = UtilIT.registerOidcUser( - "{" - + "\"username\":\"yourUsername\"," - + "\"firstName\":\"YourFirstName\"," - + "\"emailAddress\":\"yourEmail@example.com\"," - + "\"affiliation\":\"YourAffiliation\"," - + "\"position\":\"YourPosition\"," - + "\"termsAccepted\":true" - + "}", + "{{{user:abcde}", "Bearer testBearerToken" ); registerOidcUserResponse.then().assertThat() .statusCode(BAD_REQUEST.getStatusCode()) - .body("message", equalTo("Error parsing the POSTed User json: Field 'lastName' is mandatory")); + .body("message", equalTo("Error parsing the POSTed User json: Invalid token=CURLYOPEN at (line no=1, column no=2, offset=1). Expected tokens are: [STRING]")); - // Should return error when a required field in the User JSON is missing (emailAddress) + // Should return error when the provided User JSON is valid but the provided Bearer token is invalid registerOidcUserResponse = UtilIT.registerOidcUser( "{" - + "\"username\":\"yourUsername\"," - + "\"firstName\":\"YourFirstName\"," - + "\"lastName\":\"YourLastName\"," - + "\"affiliation\":\"YourAffiliation\"," - + "\"position\":\"YourPosition\"," + "\"termsAccepted\":true" + "}", "Bearer testBearerToken" ); registerOidcUserResponse.then().assertThat() - .statusCode(BAD_REQUEST.getStatusCode()) - .body("message", equalTo("Error parsing the POSTed User json: Field 'emailAddress' is mandatory")); + .statusCode(UNAUTHORIZED.getStatusCode()) + .body("message", equalTo("Unauthorized bearer token.")); - // Should return error when a required field in the User JSON is missing (termsAccepted) + // Should return an error when the termsAccepted field is missing in the User JSON registerOidcUserResponse = UtilIT.registerOidcUser( "{" - + "\"username\":\"yourUsername\"," - + "\"firstName\":\"YourFirstName\"," - + "\"lastName\":\"YourLastName\"," - + "\"emailAddress\":\"yourEmail@example.com\"," + "\"affiliation\":\"YourAffiliation\"," + "\"position\":\"YourPosition\"" + "}", @@ -635,59 +617,29 @@ public void testRegisterOIDCUser() { .statusCode(BAD_REQUEST.getStatusCode()) .body("message", equalTo("Error parsing the POSTed User json: Field 'termsAccepted' is mandatory")); - // Should return error when a malformed User JSON is sent - registerOidcUserResponse = UtilIT.registerOidcUser( - "{{{user:abcde}", - "Bearer testBearerToken" - ); - registerOidcUserResponse.then().assertThat() - .statusCode(BAD_REQUEST.getStatusCode()) - .body("message", equalTo("Error parsing the POSTed User json: Invalid token=CURLYOPEN at (line no=1, column no=2, offset=1). Expected tokens are: [STRING]")); - - // Should return error when the provided User JSON have invalid fields + // Should return an error when the Bearer token is valid but required claims are missing in the IdP, needing completion from the request JSON registerOidcUserResponse = UtilIT.registerOidcUser( "{" - + "\"username\":\"dataverseAdmin\"," - + "\"firstName\":\"YourFirstName\"," - + "\"lastName\":\"YourLastName\"," - + "\"emailAddress\":\"dataverse@mailinator.com\"," - + "\"affiliation\":\"YourAffiliation\"," - + "\"position\":\"YourPosition\"," - + "\"termsAccepted\":false" + + "\"termsAccepted\":true" + "}", - "Bearer testBearerToken" + "Bearer " + userWithoutClaimsAccessToken ); registerOidcUserResponse.then().assertThat() .statusCode(BAD_REQUEST.getStatusCode()) .body("message", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.invalidFields"))) - .body("fieldErrors.emailAddress", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.emailAddressInUse"))) - .body("fieldErrors.termsAccepted", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userShouldAcceptTerms"))) - .body("fieldErrors.username", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.usernameInUse"))); + .body("fieldErrors.firstName", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.firstNameFieldRequired"))) + .body("fieldErrors.lastName", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.lastNameFieldRequired"))) + .body("fieldErrors.emailAddress", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.emailFieldRequired"))); - // Should return error when the provided User JSON is valid but the provided Bearer token is invalid - randomUsername = UUID.randomUUID().toString().substring(0, 8); - String randomEmail = randomUsername + "@dataverse.com"; - String validUserJson = "{" - + "\"username\":\"" + randomUsername + "\"," - + "\"firstName\":\"YourFirstName\"," - + "\"lastName\":\"YourLastName\"," - + "\"emailAddress\":\"" + randomEmail + "\"," - + "\"affiliation\":\"YourAffiliation\"," - + "\"position\":\"YourPosition\"," - + "\"termsAccepted\":true" - + "}"; + // Should register user when the Bearer token is valid and the provided User JSON contains the missing claims in the IdP registerOidcUserResponse = UtilIT.registerOidcUser( - validUserJson, - "Bearer testBearerToken" - ); - registerOidcUserResponse.then().assertThat() - .statusCode(UNAUTHORIZED.getStatusCode()) - .body("message", equalTo("Unauthorized bearer token.")); - - // Should register user when the provided User JSON is valid and the provided Bearer token is valid - registerOidcUserResponse = UtilIT.registerOidcUser( - validUserJson, - "Bearer " + newUserOidcAccessToken + "{" + + "\"firstName\":\"testFirstName\"," + + "\"lastName\":\"testLastName\"," + + "\"emailAddress\":\"" + UUID.randomUUID().toString().substring(0, 8) + "@dataverse.org\"," + + "\"termsAccepted\":true" + + "}", + "Bearer " + userWithoutClaimsAccessToken ); registerOidcUserResponse.then().assertThat() .statusCode(OK.getStatusCode()) @@ -695,21 +647,29 @@ public void testRegisterOIDCUser() { // Should return error when attempting to re-register with the same Bearer token but different User data String newUserJson = "{" - + "\"username\":\"newUsername\"," - + "\"firstName\":\"NewFirstName\"," - + "\"lastName\":\"NewLastName\"," - + "\"emailAddress\":\"newEmail@example.com\"," - + "\"affiliation\":\"YourAffiliation\"," - + "\"position\":\"YourPosition\"," + + "\"firstName\":\"newFirstName\"," + + "\"lastName\":\"newLastName\"," + + "\"emailAddress\":\"newEmail@dataverse.com\"," + "\"termsAccepted\":true" + "}"; registerOidcUserResponse = UtilIT.registerOidcUser( newUserJson, - "Bearer " + newUserOidcAccessToken + "Bearer " + userWithoutClaimsAccessToken ); registerOidcUserResponse.then().assertThat() .statusCode(FORBIDDEN.getStatusCode()) .body("message", equalTo("User is already registered with this token.")); + + // Should register user when the Bearer token is valid and all required claims are present in the IdP, requiring only minimal data in the User JSON + registerOidcUserResponse = UtilIT.registerOidcUser( + "{" + + "\"termsAccepted\":true" + + "}", + "Bearer " + userWithClaimsAccessToken + ); + registerOidcUserResponse.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.message", equalTo("User registered.")); } private Response convertUserFromBcryptToSha1(long idOfBcryptUserToConvert, String password) { diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java index fb07f24b924..bd9edf150f6 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java @@ -69,10 +69,11 @@ private void setUpDefaultUserDTO() { } @Test - public void execute_unacceptedTerms_availableEmailAndUsername() { + public void execute_unacceptedTerms_availableEmailAndUsername() throws AuthorizationException { userDTO.setTermsAccepted(false); when(authServiceMock.getAuthenticatedUserByEmail(userDTO.getEmailAddress())).thenReturn(null); when(authServiceMock.getAuthenticatedUser(userDTO.getUsername())).thenReturn(null); + when(authServiceMock.verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(OIDCUserInfoMock); assertThatThrownBy(() -> sut.execute(context)) .isInstanceOf(InvalidFieldsCommandException.class) @@ -86,9 +87,10 @@ public void execute_unacceptedTerms_availableEmailAndUsername() { } @Test - public void execute_acceptedTerms_availableEmailAndUsername() { + public void execute_acceptedTerms_availableEmailAndUsername() throws AuthorizationException { when(authServiceMock.getAuthenticatedUserByEmail(userDTO.getEmailAddress())).thenReturn(existingTestUser); when(authServiceMock.getAuthenticatedUser(userDTO.getUsername())).thenReturn(existingTestUser); + when(authServiceMock.verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(OIDCUserInfoMock); assertThatThrownBy(() -> sut.execute(context)) .isInstanceOf(InvalidFieldsCommandException.class) From 9a62528e704b65878b6b309e5400f6d0c1a93848 Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 15 Nov 2024 12:06:20 +0000 Subject: [PATCH 043/101] Added: API_BEARER_AUTH_JSON_CLAIMS feature flag --- .../harvard/iq/dataverse/settings/FeatureFlags.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java index 20632c170e4..5c9e1d6279c 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java @@ -36,6 +36,18 @@ public enum FeatureFlags { * @since Dataverse @TODO: */ API_BEARER_AUTH("api-bearer-auth"), + /** + * Enables sending the missing user claims from the JSON provided during OIDC user registration + * (see API endpoint /users/register) when these claims are not returned by the identity provider + * but are necessary for registering the IdP user in Dataverse. + * + *

The value of this feature flag is only considered when the feature flag + * {@link #API_BEARER_AUTH} is enabled.

+ * + * @apiNote Raise flag by setting "dataverse.feature.api-bearer-auth-json-claims" + * @since Dataverse @TODO: + */ + API_BEARER_AUTH_JSON_CLAIMS("api-bearer-auth-json-claims"), /** * For published (public) objects, don't use a join when searching Solr. * Experimental! Requires a reindex with the following feature flag enabled, From 0f2cfdcfe50ea9caca7b47023b70b7aff2a562ca Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 15 Nov 2024 12:38:52 +0000 Subject: [PATCH 044/101] Changed: renamed flag API_BEARER_AUTH_PROVIDE_MISSING_CLAIMS --- .../edu/harvard/iq/dataverse/settings/FeatureFlags.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java index 5c9e1d6279c..42f37034d90 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java @@ -37,17 +37,17 @@ public enum FeatureFlags { */ API_BEARER_AUTH("api-bearer-auth"), /** - * Enables sending the missing user claims from the JSON provided during OIDC user registration + * Enables sending the missing user claims in the request JSON provided during OIDC user registration * (see API endpoint /users/register) when these claims are not returned by the identity provider - * but are necessary for registering the IdP user in Dataverse. + * but are necessary for registering the user in Dataverse. * *

The value of this feature flag is only considered when the feature flag * {@link #API_BEARER_AUTH} is enabled.

* - * @apiNote Raise flag by setting "dataverse.feature.api-bearer-auth-json-claims" + * @apiNote Raise flag by setting "dataverse.feature.api-bearer-auth-provide-missing-claims" * @since Dataverse @TODO: */ - API_BEARER_AUTH_JSON_CLAIMS("api-bearer-auth-json-claims"), + API_BEARER_AUTH_PROVIDE_MISSING_CLAIMS("api-bearer-auth-provide-missing-claims"), /** * For published (public) objects, don't use a join when searching Solr. * Experimental! Requires a reindex with the following feature flag enabled, From 52a5a9e8d59ed041814269bb16a7f3fad990472c Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 15 Nov 2024 12:39:51 +0000 Subject: [PATCH 045/101] Added: API_BEARER_AUTH_PROVIDE_MISSING_CLAIMS management an different logic paths depending on the value to RegisterOIDCUserCommand --- .../command/impl/RegisterOIDCUserCommand.java | 77 ++++++++++--------- src/main/java/propertyFiles/Bundle.properties | 12 ++- 2 files changed, 47 insertions(+), 42 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java index a82e6b57b68..57bf7832f62 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java @@ -12,6 +12,7 @@ import edu.harvard.iq.dataverse.engine.command.exception.IllegalCommandException; import edu.harvard.iq.dataverse.engine.command.exception.PermissionException; import edu.harvard.iq.dataverse.engine.command.exception.InvalidFieldsCommandException; +import edu.harvard.iq.dataverse.settings.FeatureFlags; import edu.harvard.iq.dataverse.util.BundleUtil; import java.util.HashMap; @@ -40,12 +41,9 @@ protected void executeImpl(CommandContext ctxt) throws CommandException { } UserInfo userClaimsInfo = oidcUserInfo.getUserClaimsInfo(); + boolean provideMissingClaimsEnabled = FeatureFlags.API_BEARER_AUTH_PROVIDE_MISSING_CLAIMS.enabled(); - // Update the UserDTO object with available OIDC user claims; keep existing values if claims are absent - userDTO.setUsername(getValueOrDefault(userClaimsInfo.getPreferredUsername(), userDTO.getUsername())); - userDTO.setFirstName(getValueOrDefault(userClaimsInfo.getGivenName(), userDTO.getFirstName())); - userDTO.setLastName(getValueOrDefault(userClaimsInfo.getFamilyName(), userDTO.getLastName())); - userDTO.setEmailAddress(getValueOrDefault(userClaimsInfo.getEmailAddress(), userDTO.getEmailAddress())); + updateUserDTO(userClaimsInfo, provideMissingClaimsEnabled); AuthenticatedUserDisplayInfo userDisplayInfo = new AuthenticatedUserDisplayInfo( userDTO.getFirstName(), @@ -55,7 +53,7 @@ protected void executeImpl(CommandContext ctxt) throws CommandException { userDTO.getPosition() != null ? userDTO.getPosition() : "" ); - Map fieldErrors = validateUserFields(ctxt); + Map fieldErrors = validateUserFields(ctxt, provideMissingClaimsEnabled); if (!fieldErrors.isEmpty()) { throw new InvalidFieldsCommandException( BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.invalidFields"), @@ -71,19 +69,34 @@ protected void executeImpl(CommandContext ctxt) throws CommandException { } } + private void updateUserDTO(UserInfo userClaimsInfo, boolean provideMissingClaimsEnabled) { + if (provideMissingClaimsEnabled) { + // Update with available OIDC claims, keep existing values if claims are absent + userDTO.setUsername(getValueOrDefault(userClaimsInfo.getPreferredUsername(), userDTO.getUsername())); + userDTO.setFirstName(getValueOrDefault(userClaimsInfo.getGivenName(), userDTO.getFirstName())); + userDTO.setLastName(getValueOrDefault(userClaimsInfo.getFamilyName(), userDTO.getLastName())); + userDTO.setEmailAddress(getValueOrDefault(userClaimsInfo.getEmailAddress(), userDTO.getEmailAddress())); + } else { + // Always use the claims from the IdP provider + userDTO.setUsername(userClaimsInfo.getPreferredUsername()); + userDTO.setFirstName(userClaimsInfo.getGivenName()); + userDTO.setLastName(userClaimsInfo.getFamilyName()); + userDTO.setEmailAddress(userClaimsInfo.getEmailAddress()); + } + } + private String getValueOrDefault(String oidcValue, String dtoValue) { return (oidcValue == null || oidcValue.isEmpty()) ? dtoValue : oidcValue; } - private Map validateUserFields(CommandContext ctxt) { + private Map validateUserFields(CommandContext ctxt, boolean provideMissingClaimsEnabled) { Map fieldErrors = new HashMap<>(); validateTermsAccepted(fieldErrors); - validateEmailAddress(ctxt, fieldErrors); - validateUsername(ctxt, fieldErrors); - - validateRequiredField("firstName", userDTO.getFirstName(), "registerOidcUserCommand.errors.firstNameFieldRequired", fieldErrors); - validateRequiredField("lastName", userDTO.getLastName(), "registerOidcUserCommand.errors.lastNameFieldRequired", fieldErrors); + validateField(fieldErrors, "emailAddress", userDTO.getEmailAddress(), ctxt, provideMissingClaimsEnabled); + validateField(fieldErrors, "username", userDTO.getUsername(), ctxt, provideMissingClaimsEnabled); + validateField(fieldErrors, "firstName", userDTO.getFirstName(), ctxt, provideMissingClaimsEnabled); + validateField(fieldErrors, "lastName", userDTO.getLastName(), ctxt, provideMissingClaimsEnabled); return fieldErrors; } @@ -94,35 +107,23 @@ private void validateTermsAccepted(Map fieldErrors) { } } - private void validateEmailAddress(CommandContext ctxt, Map fieldErrors) { - String emailAddress = userDTO.getEmailAddress(); - if (emailAddress == null || emailAddress.isEmpty()) { - fieldErrors.put("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.emailFieldRequired")); - } else if (isEmailInUse(ctxt, emailAddress)) { - fieldErrors.put("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.emailAddressInUse")); - } - } - - private void validateUsername(CommandContext ctxt, Map fieldErrors) { - String username = userDTO.getUsername(); - if (username == null || username.isEmpty()) { - fieldErrors.put("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.usernameFieldRequired")); - } else if (isUsernameInUse(ctxt, username)) { - fieldErrors.put("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.usernameInUse")); - } - } - - private void validateRequiredField(String fieldName, String fieldValue, String bundleKey, Map fieldErrors) { + private void validateField(Map fieldErrors, String fieldName, String fieldValue, CommandContext ctxt, boolean provideMissingClaimsEnabled) { if (fieldValue == null || fieldValue.isEmpty()) { - fieldErrors.put(fieldName, BundleUtil.getStringFromBundle(bundleKey)); + String errorKey = provideMissingClaimsEnabled ? + "registerOidcUserCommand.errors.provideMissingClaimsEnabled." + fieldName + "FieldRequired" : + "registerOidcUserCommand.errors.provideMissingClaimsDisabled." + fieldName + "FieldRequired"; + fieldErrors.put(fieldName, BundleUtil.getStringFromBundle(errorKey)); + } else if (isFieldInUse(ctxt, fieldName, fieldValue)) { + fieldErrors.put(fieldName, BundleUtil.getStringFromBundle("registerOidcUserCommand.errors." + fieldName + "InUse")); } } - private boolean isEmailInUse(CommandContext ctxt, String emailAddress) { - return ctxt.authentication().getAuthenticatedUserByEmail(emailAddress) != null; - } - - private boolean isUsernameInUse(CommandContext ctxt, String username) { - return ctxt.authentication().getAuthenticatedUser(username) != null; + private boolean isFieldInUse(CommandContext ctxt, String fieldName, String value) { + if ("emailAddress".equals(fieldName)) { + return ctxt.authentication().getAuthenticatedUserByEmail(value) != null; + } else if ("username".equals(fieldName)) { + return ctxt.authentication().getAuthenticatedUser(value) != null; + } + return false; } } diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index 9ea87440535..e2fc48054e6 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -3072,10 +3072,14 @@ users.api.userRegistered=User registered. registerOidcUserCommand.errors.userAlreadyRegisteredWithToken=User is already registered with this token. registerOidcUserCommand.errors.invalidFields=The provided fields are invalid for registering a new user. registerOidcUserCommand.errors.userShouldAcceptTerms=Terms should be accepted. -registerOidcUserCommand.errors.emailFieldRequired=It is required to include an emailAddress field in the request JSON for registering the user. -registerOidcUserCommand.errors.usernameFieldRequired=It is required to include a username field in the request JSON for registering the user. -registerOidcUserCommand.errors.firstNameFieldRequired=It is required to include a firstName field in the request JSON for registering the user. -registerOidcUserCommand.errors.lastNameFieldRequired=It is required to include a lastName field in the request JSON for registering the user. +registerOidcUserCommand.errors.provideMissingClaimsEnabled.emailAddressFieldRequired=It is required to include an emailAddress field in the request JSON for registering the user. +registerOidcUserCommand.errors.provideMissingClaimsDisabled.emailAddressFieldRequired=The OIDC identity provider does not provide the user claim 'email', which is required for user registration. Please contact your identity provider. +registerOidcUserCommand.errors.provideMissingClaimsEnabled.usernameFieldRequired=It is required to include a username field in the request JSON for registering the user. +registerOidcUserCommand.errors.provideMissingClaimsDisabled.usernameFieldRequired=The OIDC identity provider does not provide the user claim 'preferred_username', which is required for user registration. Please contact your identity provider. +registerOidcUserCommand.errors.provideMissingClaimsEnabled.firstNameFieldRequired=It is required to include a firstName field in the request JSON for registering the user. +registerOidcUserCommand.errors.provideMissingClaimsDisabled.firstNameFieldRequired=The OIDC identity provider does not provide the user claim 'given_name', which is required for user registration. Please contact your identity provider. +registerOidcUserCommand.errors.provideMissingClaimsEnabled.lastNameFieldRequired=It is required to include a lastName field in the request JSON for registering the user. +registerOidcUserCommand.errors.provideMissingClaimsDisabled.lastNameFieldRequired=The OIDC identity provider does not provide the user claim 'family_name', which is required for user registration. Please contact your identity provider. registerOidcUserCommand.errors.emailAddressInUse=Email already in use. registerOidcUserCommand.errors.usernameInUse=Username already in use. From 047a14c48e0a6a29cf844190ac43dd235e4b842b Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 15 Nov 2024 13:02:24 +0000 Subject: [PATCH 046/101] Fixed: RegisterOIDCUserCommandTest --- .../impl/RegisterOIDCUserCommandTest.java | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java index bd9edf150f6..30fc7687c55 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java @@ -13,7 +13,10 @@ import edu.harvard.iq.dataverse.engine.command.exception.IllegalCommandException; import edu.harvard.iq.dataverse.engine.command.exception.InvalidFieldsCommandException; import edu.harvard.iq.dataverse.engine.command.exception.PermissionException; +import edu.harvard.iq.dataverse.settings.JvmSettings; import edu.harvard.iq.dataverse.util.BundleUtil; +import edu.harvard.iq.dataverse.util.testing.JvmSetting; +import edu.harvard.iq.dataverse.util.testing.LocalJvmSettings; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; @@ -25,6 +28,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.*; +@LocalJvmSettings class RegisterOIDCUserCommandTest { private static final String TEST_BEARER_TOKEN = "Bearer test"; @@ -69,7 +73,7 @@ private void setUpDefaultUserDTO() { } @Test - public void execute_unacceptedTerms_availableEmailAndUsername() throws AuthorizationException { + public void execute_completedUserDTOWithUnacceptedTerms_provideMissingClaimsDisabled() throws AuthorizationException { userDTO.setTermsAccepted(false); when(authServiceMock.getAuthenticatedUserByEmail(userDTO.getEmailAddress())).thenReturn(null); when(authServiceMock.getAuthenticatedUser(userDTO.getUsername())).thenReturn(null); @@ -81,13 +85,41 @@ public void execute_unacceptedTerms_availableEmailAndUsername() throws Authoriza InvalidFieldsCommandException ex = (InvalidFieldsCommandException) exception; assertThat(ex.getFieldErrors()) .containsEntry("termsAccepted", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userShouldAcceptTerms")) - .doesNotContainEntry("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.emailAddressInUse")) - .doesNotContainEntry("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.usernameInUse")); + .containsEntry("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.emailAddressFieldRequired")) + .containsEntry("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.usernameFieldRequired")) + .containsEntry("firstName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.firstNameFieldRequired")) + .containsEntry("lastName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.lastNameFieldRequired")); }); } @Test - public void execute_acceptedTerms_availableEmailAndUsername() throws AuthorizationException { + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-provide-missing-claims") + public void execute_uncompletedUserDTOWithUnacceptedTerms_provideMissingClaimsEnabled() throws AuthorizationException { + userDTO.setTermsAccepted(false); + userDTO.setEmailAddress(null); + userDTO.setUsername(null); + userDTO.setFirstName(null); + userDTO.setLastName(null); + when(authServiceMock.getAuthenticatedUserByEmail(userDTO.getEmailAddress())).thenReturn(null); + when(authServiceMock.getAuthenticatedUser(userDTO.getUsername())).thenReturn(null); + when(authServiceMock.verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(OIDCUserInfoMock); + + assertThatThrownBy(() -> sut.execute(context)) + .isInstanceOf(InvalidFieldsCommandException.class) + .satisfies(exception -> { + InvalidFieldsCommandException ex = (InvalidFieldsCommandException) exception; + assertThat(ex.getFieldErrors()) + .containsEntry("termsAccepted", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userShouldAcceptTerms")) + .containsEntry("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.emailAddressFieldRequired")) + .containsEntry("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.usernameFieldRequired")) + .containsEntry("firstName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.firstNameFieldRequired")) + .containsEntry("lastName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.lastNameFieldRequired")); + }); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-provide-missing-claims") + public void execute_acceptedTerms_unavailableEmailAndUsername_provideMissingClaimsEnabled() throws AuthorizationException { when(authServiceMock.getAuthenticatedUserByEmail(userDTO.getEmailAddress())).thenReturn(existingTestUser); when(authServiceMock.getAuthenticatedUser(userDTO.getUsername())).thenReturn(existingTestUser); when(authServiceMock.verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(OIDCUserInfoMock); @@ -130,7 +162,8 @@ void execute_throwsIllegalCommandException_ifUserAlreadyRegisteredWithToken() th } @Test - void execute_happyPath_withoutAffiliationAndPosition() throws AuthorizationException, CommandException { + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-provide-missing-claims") + void execute_happyPath_withoutAffiliationAndPosition_provideMissingClaimsEnabled() throws AuthorizationException, CommandException { when(authServiceMock.verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(OIDCUserInfoMock); sut.execute(context); @@ -150,7 +183,8 @@ void execute_happyPath_withoutAffiliationAndPosition() throws AuthorizationExcep } @Test - void execute_happyPath_withAffiliationAndPosition() throws AuthorizationException, CommandException { + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-provide-missing-claims") + void execute_happyPath_withAffiliationAndPosition_provideMissingClaimsEnabled() throws AuthorizationException, CommandException { userDTO.setPosition("test position"); userDTO.setAffiliation("test affiliation"); From cc86a8307405344bcd1881a42d3d7a8f8ab26b5a Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 15 Nov 2024 13:14:48 +0000 Subject: [PATCH 047/101] Added: explanatory comment tweak --- .../dataverse/engine/command/impl/RegisterOIDCUserCommand.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java index 57bf7832f62..e580c1ad7cc 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java @@ -77,7 +77,7 @@ private void updateUserDTO(UserInfo userClaimsInfo, boolean provideMissingClaims userDTO.setLastName(getValueOrDefault(userClaimsInfo.getFamilyName(), userDTO.getLastName())); userDTO.setEmailAddress(getValueOrDefault(userClaimsInfo.getEmailAddress(), userDTO.getEmailAddress())); } else { - // Always use the claims from the IdP provider + // Always use the claims provided by the OIDC provider, regardless of whether they are null or not userDTO.setUsername(userClaimsInfo.getPreferredUsername()); userDTO.setFirstName(userClaimsInfo.getGivenName()); userDTO.setLastName(userClaimsInfo.getFamilyName()); From c3de7d735cdebcbbec69511928369683b9c7c5fa Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 15 Nov 2024 13:27:42 +0000 Subject: [PATCH 048/101] Added: DATAVERSE_FEATURE_API_BEARER_AUTH_PROVIDE_MISSING_CLAIMS enabled in docker-compose-dev --- docker-compose-dev.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 384b70b7a7b..3f5cae1b263 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -17,6 +17,7 @@ services: SKIP_DEPLOY: "${SKIP_DEPLOY}" DATAVERSE_JSF_REFRESH_PERIOD: "1" DATAVERSE_FEATURE_API_BEARER_AUTH: "1" + DATAVERSE_FEATURE_API_BEARER_AUTH_PROVIDE_MISSING_CLAIMS: "1" DATAVERSE_MAIL_SYSTEM_EMAIL: "dataverse@localhost" DATAVERSE_MAIL_MTA_HOST: "smtp" DATAVERSE_AUTH_OIDC_ENABLED: "1" From 25cdf98d2cba0dc672161f8cafa8d363c65c8c10 Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 15 Nov 2024 13:28:00 +0000 Subject: [PATCH 049/101] Fixed: UsersIT registerOidcUser --- src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java index acd5bd658e0..cb4a2b862c9 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java @@ -627,9 +627,9 @@ public void testRegisterOIDCUser() { registerOidcUserResponse.then().assertThat() .statusCode(BAD_REQUEST.getStatusCode()) .body("message", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.invalidFields"))) - .body("fieldErrors.firstName", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.firstNameFieldRequired"))) - .body("fieldErrors.lastName", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.lastNameFieldRequired"))) - .body("fieldErrors.emailAddress", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.emailFieldRequired"))); + .body("fieldErrors.firstName", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.firstNameFieldRequired"))) + .body("fieldErrors.lastName", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.lastNameFieldRequired"))) + .body("fieldErrors.emailAddress", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.emailAddressFieldRequired"))); // Should register user when the Bearer token is valid and the provided User JSON contains the missing claims in the IdP registerOidcUserResponse = UtilIT.registerOidcUser( From 5d39ac187e3823e0b6ac914c7c341558e9da5d75 Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 15 Nov 2024 13:56:22 +0000 Subject: [PATCH 050/101] Added: #10959 docs to auth.rst --- doc/sphinx-guides/source/api/auth.rst | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/doc/sphinx-guides/source/api/auth.rst b/doc/sphinx-guides/source/api/auth.rst index eae3bd3c969..d30d0097802 100644 --- a/doc/sphinx-guides/source/api/auth.rst +++ b/doc/sphinx-guides/source/api/auth.rst @@ -81,6 +81,29 @@ To test if bearer tokens are working, you can try something like the following ( curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/users/:me +It may happen that when you try to authenticate a user for the first time with a bearer token, it does not have an associated user account in Dataverse. In this case, it is necessary to register the user using the following endpoint: + +.. code-block:: bash + + curl -H "Authorization: Bearer $TOKEN" -X POST http://localhost:8080/api/users/register --data '{"termsAccepted":true}' + +It is essential to send a JSON that includes the property ``termsAccepted`` set to true, which indicates that you accept the terms of service of Dataverse. Otherwise, you will not be able to create an account. + +In this JSON, we can also include the fields ``position`` or ``affiliation``, in the same way as when we register a user through the Dataverse UI. These fields are optional, and if not provided, they will be persisted as empty in Dataverse. + +Beyond the ``api-bearer-auth`` feature flag, there is another flag called ``api-bearer-auth-json-claims`` that can be enabled to allow sending missing user claims in the registration JSON. This is useful when the identity provider does not supply the necessary claims. However, this flag will only be considered if the ``api-bearer-auth`` feature flag is enabled. If the latter is not enabled, the ``api-bearer-auth-json-claims`` flag will be ignored. + +With the ``api-bearer-auth`` feature flag enabled, you can include the following properties in the request JSON: + +- ``username`` +- ``firstName`` +- ``lastName`` +- ``emailAddress`` + +Note that even if they are included in the JSON, if it is possible to retrieve the corresponding claims from the identity provider, these values will be ignored and the ones from the IdP will be used instead. + +This functionality is included under a feature flag because using it may introduce potential security risks, such as user impersonation, if the identity provider does not provide an email field and the user submits an email address they do not own. + Signed URLs ----------- From a438c8a8bc6d7ea1762cb3b117d781478f4d8959 Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 15 Nov 2024 14:03:28 +0000 Subject: [PATCH 051/101] Added: docs for #10959 --- doc/sphinx-guides/source/api/auth.rst | 2 +- doc/sphinx-guides/source/installation/config.rst | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/doc/sphinx-guides/source/api/auth.rst b/doc/sphinx-guides/source/api/auth.rst index d30d0097802..101e283d5b1 100644 --- a/doc/sphinx-guides/source/api/auth.rst +++ b/doc/sphinx-guides/source/api/auth.rst @@ -102,7 +102,7 @@ With the ``api-bearer-auth`` feature flag enabled, you can include the following Note that even if they are included in the JSON, if it is possible to retrieve the corresponding claims from the identity provider, these values will be ignored and the ones from the IdP will be used instead. -This functionality is included under a feature flag because using it may introduce potential security risks, such as user impersonation, if the identity provider does not provide an email field and the user submits an email address they do not own. +This functionality is included under a feature flag because using it may introduce user impersonation issues, for example if the identity provider does not provide an email field and the user submits an email address they do not own. Signed URLs ----------- diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst index e3965e3cd7c..f7ccf7e1698 100644 --- a/doc/sphinx-guides/source/installation/config.rst +++ b/doc/sphinx-guides/source/installation/config.rst @@ -3343,6 +3343,12 @@ please find all known feature flags below. Any of these flags can be activated u * - api-session-auth - Enables API authentication via session cookie (JSESSIONID). **Caution: Enabling this feature flag exposes the installation to CSRF risks!** We expect this feature flag to be temporary (only used by frontend developers, see `#9063 `_) and for the feature to be removed in the future. - ``Off`` + * - api-bearer-auth + - Enables API authentication via Bearer Token. + - ``Off`` + * - api-bearer-auth-provide-missing-claims + - Enables sending missing user claims in the request JSON provided during OIDC user registration, when these claims are not returned by the identity provider and are required for registration. This feature only works when the feature flag ``api-bearer-auth`` is also enabled. **Caution: Enabling this feature flag exposes the installation to potential user impersonation issues.** + - ``Off`` * - avoid-expensive-solr-join - Changes the way Solr queries are constructed for public content (published Collections, Datasets and Files). It removes a very expensive Solr join on all such documents, improving overall performance, especially for large instances under heavy load. Before this feature flag is enabled, the corresponding indexing feature (see next feature flag) must be turned on and a full reindex performed (otherwise public objects are not going to be shown in search results). See :doc:`/admin/solr-search-index`. - ``Off`` From 9ba377ee05e0cc671eee14900de84cc15f50a431 Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 15 Nov 2024 14:05:37 +0000 Subject: [PATCH 052/101] Fixed: doc tweak --- doc/sphinx-guides/source/api/auth.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/sphinx-guides/source/api/auth.rst b/doc/sphinx-guides/source/api/auth.rst index 101e283d5b1..ca68e507b9b 100644 --- a/doc/sphinx-guides/source/api/auth.rst +++ b/doc/sphinx-guides/source/api/auth.rst @@ -100,7 +100,7 @@ With the ``api-bearer-auth`` feature flag enabled, you can include the following - ``lastName`` - ``emailAddress`` -Note that even if they are included in the JSON, if it is possible to retrieve the corresponding claims from the identity provider, these values will be ignored and the ones from the IdP will be used instead. +Note that even if they are included in the JSON, if it is possible to retrieve the corresponding claims from the identity provider, these values will be ignored and the ones from the identity provider will be used instead. This functionality is included under a feature flag because using it may introduce user impersonation issues, for example if the identity provider does not provide an email field and the user submits an email address they do not own. From b00ac7f3f76d3f5564186b68a2255fac5a4156a4 Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 15 Nov 2024 14:09:10 +0000 Subject: [PATCH 053/101] Changed: replaced version TODO with 5.14 for api-bearer-auth feature flag doc --- .../java/edu/harvard/iq/dataverse/settings/FeatureFlags.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java index 42f37034d90..b3774c3fe06 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java @@ -33,7 +33,7 @@ public enum FeatureFlags { /** * Enables API authentication via Bearer Token. * @apiNote Raise flag by setting "dataverse.feature.api-bearer-auth" - * @since Dataverse @TODO: + * @since Dataverse 5.14: */ API_BEARER_AUTH("api-bearer-auth"), /** From 73c407997b0a9972aa1e1989aef7bc4056f2128b Mon Sep 17 00:00:00 2001 From: GPortas Date: Tue, 19 Nov 2024 11:02:08 +0000 Subject: [PATCH 054/101] Changed: simpler statement in auth.rst --- doc/sphinx-guides/source/api/auth.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/sphinx-guides/source/api/auth.rst b/doc/sphinx-guides/source/api/auth.rst index ca68e507b9b..a033579d590 100644 --- a/doc/sphinx-guides/source/api/auth.rst +++ b/doc/sphinx-guides/source/api/auth.rst @@ -81,7 +81,7 @@ To test if bearer tokens are working, you can try something like the following ( curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/users/:me -It may happen that when you try to authenticate a user for the first time with a bearer token, it does not have an associated user account in Dataverse. In this case, it is necessary to register the user using the following endpoint: +To register a new user who has authenticated via an OIDC provider, the following endpoint should be used: .. code-block:: bash From f99732b2b6f7abb7a9055244a8d0e8515fbe2634 Mon Sep 17 00:00:00 2001 From: GPortas Date: Tue, 19 Nov 2024 12:22:29 +0000 Subject: [PATCH 055/101] Changed: doc tweak in auth.rst --- doc/sphinx-guides/source/api/auth.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/sphinx-guides/source/api/auth.rst b/doc/sphinx-guides/source/api/auth.rst index a033579d590..fc2aa994597 100644 --- a/doc/sphinx-guides/source/api/auth.rst +++ b/doc/sphinx-guides/source/api/auth.rst @@ -87,7 +87,7 @@ To register a new user who has authenticated via an OIDC provider, the following curl -H "Authorization: Bearer $TOKEN" -X POST http://localhost:8080/api/users/register --data '{"termsAccepted":true}' -It is essential to send a JSON that includes the property ``termsAccepted`` set to true, which indicates that you accept the terms of service of Dataverse. Otherwise, you will not be able to create an account. +It is essential to send a JSON that includes the property ``termsAccepted`` set to true, which indicates that you accept the Terms of Use of the installation. Otherwise, you will not be able to create an account. In this JSON, we can also include the fields ``position`` or ``affiliation``, in the same way as when we register a user through the Dataverse UI. These fields are optional, and if not provided, they will be persisted as empty in Dataverse. From dbfe40d59fd95e6ba4c2eda18720ef13afc16164 Mon Sep 17 00:00:00 2001 From: GPortas Date: Tue, 19 Nov 2024 13:29:34 +0000 Subject: [PATCH 056/101] Refactor: registerOidcUserCommand Bundle strings --- .../command/impl/RegisterOIDCUserCommand.java | 7 ++++--- src/main/java/propertyFiles/Bundle.properties | 10 ++-------- .../edu/harvard/iq/dataverse/api/UsersIT.java | 6 +++--- .../impl/RegisterOIDCUserCommandTest.java | 18 ++++++++++-------- 4 files changed, 19 insertions(+), 22 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java index e580c1ad7cc..0fb8b5de848 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java @@ -16,6 +16,7 @@ import edu.harvard.iq.dataverse.util.BundleUtil; import java.util.HashMap; +import java.util.List; import java.util.Map; @RequiredPermissions({}) @@ -110,9 +111,9 @@ private void validateTermsAccepted(Map fieldErrors) { private void validateField(Map fieldErrors, String fieldName, String fieldValue, CommandContext ctxt, boolean provideMissingClaimsEnabled) { if (fieldValue == null || fieldValue.isEmpty()) { String errorKey = provideMissingClaimsEnabled ? - "registerOidcUserCommand.errors.provideMissingClaimsEnabled." + fieldName + "FieldRequired" : - "registerOidcUserCommand.errors.provideMissingClaimsDisabled." + fieldName + "FieldRequired"; - fieldErrors.put(fieldName, BundleUtil.getStringFromBundle(errorKey)); + "registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldRequired" : + "registerOidcUserCommand.errors.provideMissingClaimsDisabled.fieldRequired"; + fieldErrors.put(fieldName, BundleUtil.getStringFromBundle(errorKey, List.of(fieldName))); } else if (isFieldInUse(ctxt, fieldName, fieldValue)) { fieldErrors.put(fieldName, BundleUtil.getStringFromBundle("registerOidcUserCommand.errors." + fieldName + "InUse")); } diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index e2fc48054e6..f814e08c49e 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -3072,14 +3072,8 @@ users.api.userRegistered=User registered. registerOidcUserCommand.errors.userAlreadyRegisteredWithToken=User is already registered with this token. registerOidcUserCommand.errors.invalidFields=The provided fields are invalid for registering a new user. registerOidcUserCommand.errors.userShouldAcceptTerms=Terms should be accepted. -registerOidcUserCommand.errors.provideMissingClaimsEnabled.emailAddressFieldRequired=It is required to include an emailAddress field in the request JSON for registering the user. -registerOidcUserCommand.errors.provideMissingClaimsDisabled.emailAddressFieldRequired=The OIDC identity provider does not provide the user claim 'email', which is required for user registration. Please contact your identity provider. -registerOidcUserCommand.errors.provideMissingClaimsEnabled.usernameFieldRequired=It is required to include a username field in the request JSON for registering the user. -registerOidcUserCommand.errors.provideMissingClaimsDisabled.usernameFieldRequired=The OIDC identity provider does not provide the user claim 'preferred_username', which is required for user registration. Please contact your identity provider. -registerOidcUserCommand.errors.provideMissingClaimsEnabled.firstNameFieldRequired=It is required to include a firstName field in the request JSON for registering the user. -registerOidcUserCommand.errors.provideMissingClaimsDisabled.firstNameFieldRequired=The OIDC identity provider does not provide the user claim 'given_name', which is required for user registration. Please contact your identity provider. -registerOidcUserCommand.errors.provideMissingClaimsEnabled.lastNameFieldRequired=It is required to include a lastName field in the request JSON for registering the user. -registerOidcUserCommand.errors.provideMissingClaimsDisabled.lastNameFieldRequired=The OIDC identity provider does not provide the user claim 'family_name', which is required for user registration. Please contact your identity provider. +registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldRequired=It is required to include the field {0} in the request JSON for registering the user. +registerOidcUserCommand.errors.provideMissingClaimsDisabled.fieldRequired=The OIDC identity provider does not provide the user claim {0}, which is required for user registration. Please contact an administrator. registerOidcUserCommand.errors.emailAddressInUse=Email already in use. registerOidcUserCommand.errors.usernameInUse=Username already in use. diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java index cb4a2b862c9..bc9b7f756f7 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java @@ -627,9 +627,9 @@ public void testRegisterOIDCUser() { registerOidcUserResponse.then().assertThat() .statusCode(BAD_REQUEST.getStatusCode()) .body("message", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.invalidFields"))) - .body("fieldErrors.firstName", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.firstNameFieldRequired"))) - .body("fieldErrors.lastName", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.lastNameFieldRequired"))) - .body("fieldErrors.emailAddress", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.emailAddressFieldRequired"))); + .body("fieldErrors.firstName", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldRequired", List.of("firstName")))) + .body("fieldErrors.lastName", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldRequired", List.of("lastName")))) + .body("fieldErrors.emailAddress", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldRequired", List.of("emailAddress")))); // Should register user when the Bearer token is valid and the provided User JSON contains the missing claims in the IdP registerOidcUserResponse = UtilIT.registerOidcUser( diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java index 30fc7687c55..5ee5bf443fa 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java @@ -23,6 +23,8 @@ import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import java.util.List; + import static edu.harvard.iq.dataverse.mocks.MocksFactory.makeRequest; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -85,10 +87,10 @@ public void execute_completedUserDTOWithUnacceptedTerms_provideMissingClaimsDisa InvalidFieldsCommandException ex = (InvalidFieldsCommandException) exception; assertThat(ex.getFieldErrors()) .containsEntry("termsAccepted", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userShouldAcceptTerms")) - .containsEntry("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.emailAddressFieldRequired")) - .containsEntry("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.usernameFieldRequired")) - .containsEntry("firstName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.firstNameFieldRequired")) - .containsEntry("lastName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.lastNameFieldRequired")); + .containsEntry("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.fieldRequired", List.of("emailAddress"))) + .containsEntry("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.fieldRequired", List.of("username"))) + .containsEntry("firstName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.fieldRequired", List.of("firstName"))) + .containsEntry("lastName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.fieldRequired", List.of("lastName"))); }); } @@ -110,10 +112,10 @@ public void execute_uncompletedUserDTOWithUnacceptedTerms_provideMissingClaimsEn InvalidFieldsCommandException ex = (InvalidFieldsCommandException) exception; assertThat(ex.getFieldErrors()) .containsEntry("termsAccepted", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userShouldAcceptTerms")) - .containsEntry("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.emailAddressFieldRequired")) - .containsEntry("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.usernameFieldRequired")) - .containsEntry("firstName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.firstNameFieldRequired")) - .containsEntry("lastName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.lastNameFieldRequired")); + .containsEntry("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldRequired", List.of("emailAddress"))) + .containsEntry("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldRequired", List.of("username"))) + .containsEntry("firstName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldRequired", List.of("firstName"))) + .containsEntry("lastName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldRequired", List.of("lastName"))); }); } From abc9c818d71bd2e167ff359ecd898cbc35a82f58 Mon Sep 17 00:00:00 2001 From: Jim Myers Date: Tue, 19 Nov 2024 13:31:01 -0500 Subject: [PATCH 057/101] another entry from QDR --- pom.xml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pom.xml b/pom.xml index b543438715f..cb16f16c229 100644 --- a/pom.xml +++ b/pom.xml @@ -51,6 +51,16 @@ org.apache.abdera abdera-core 1.1.3 + + + org.apache.geronimo.specs + geronimo-stax-api_1.0_spec + + + org.apache.james + apache-mime4j-core + +
org.apache.abdera From 4ae119c0e3b43a597eead60f5e27bc733f71d7f2 Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 20 Nov 2024 11:12:45 +0000 Subject: [PATCH 058/101] Changed: throwing an error when registering an OIDC user and attempting to set JSON properties that conflict with existing claims in the IdP --- .../command/impl/RegisterOIDCUserCommand.java | 94 +++++++++++++------ src/main/java/propertyFiles/Bundle.properties | 1 + .../edu/harvard/iq/dataverse/api/UsersIT.java | 16 ++++ .../impl/RegisterOIDCUserCommandTest.java | 27 ++++++ 4 files changed, 110 insertions(+), 28 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java index 0fb8b5de848..3c4bf4f097b 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java @@ -22,6 +22,12 @@ @RequiredPermissions({}) public class RegisterOIDCUserCommand extends AbstractVoidCommand { + private static final String FIELD_USERNAME = "username"; + private static final String FIELD_FIRST_NAME = "firstName"; + private static final String FIELD_LAST_NAME = "lastName"; + private static final String FIELD_EMAIL_ADDRESS = "emailAddress"; + private static final String FIELD_TERMS_ACCEPTED = "termsAccepted"; + private final String bearerToken; private final UserDTO userDTO; @@ -54,14 +60,7 @@ protected void executeImpl(CommandContext ctxt) throws CommandException { userDTO.getPosition() != null ? userDTO.getPosition() : "" ); - Map fieldErrors = validateUserFields(ctxt, provideMissingClaimsEnabled); - if (!fieldErrors.isEmpty()) { - throw new InvalidFieldsCommandException( - BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.invalidFields"), - this, - fieldErrors - ); - } + validateUserFields(ctxt, provideMissingClaimsEnabled); ctxt.authentication().createAuthenticatedUser(userRecordIdentifier, userDTO.getUsername(), userDisplayInfo, true); @@ -70,19 +69,58 @@ protected void executeImpl(CommandContext ctxt) throws CommandException { } } - private void updateUserDTO(UserInfo userClaimsInfo, boolean provideMissingClaimsEnabled) { + private void updateUserDTO(UserInfo userClaimsInfo, boolean provideMissingClaimsEnabled) throws InvalidFieldsCommandException { if (provideMissingClaimsEnabled) { - // Update with available OIDC claims, keep existing values if claims are absent - userDTO.setUsername(getValueOrDefault(userClaimsInfo.getPreferredUsername(), userDTO.getUsername())); - userDTO.setFirstName(getValueOrDefault(userClaimsInfo.getGivenName(), userDTO.getFirstName())); - userDTO.setLastName(getValueOrDefault(userClaimsInfo.getFamilyName(), userDTO.getLastName())); - userDTO.setEmailAddress(getValueOrDefault(userClaimsInfo.getEmailAddress(), userDTO.getEmailAddress())); + Map fieldErrors = validateConflictingClaims(userClaimsInfo); + throwInvalidFieldsCommandExceptionIfErrorsExist(fieldErrors); + updateUserDTOWithClaims(userClaimsInfo); } else { - // Always use the claims provided by the OIDC provider, regardless of whether they are null or not - userDTO.setUsername(userClaimsInfo.getPreferredUsername()); - userDTO.setFirstName(userClaimsInfo.getGivenName()); - userDTO.setLastName(userClaimsInfo.getFamilyName()); - userDTO.setEmailAddress(userClaimsInfo.getEmailAddress()); + overwriteUserDTOWithClaims(userClaimsInfo); + } + } + + private Map validateConflictingClaims(UserInfo userClaimsInfo) { + Map fieldErrors = new HashMap<>(); + + addFieldErrorIfConflict(FIELD_USERNAME, userClaimsInfo.getPreferredUsername(), userDTO.getUsername(), fieldErrors); + addFieldErrorIfConflict(FIELD_FIRST_NAME, userClaimsInfo.getGivenName(), userDTO.getFirstName(), fieldErrors); + addFieldErrorIfConflict(FIELD_LAST_NAME, userClaimsInfo.getFamilyName(), userDTO.getLastName(), fieldErrors); + addFieldErrorIfConflict(FIELD_EMAIL_ADDRESS, userClaimsInfo.getEmailAddress(), userDTO.getEmailAddress(), fieldErrors); + + return fieldErrors; + } + + private void addFieldErrorIfConflict(String fieldName, String claimValue, String existingValue, Map fieldErrors) { + if (claimValue != null && existingValue != null && !claimValue.equals(existingValue)) { + String errorMessage = BundleUtil.getStringFromBundle( + "registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider", + List.of(fieldName) + ); + fieldErrors.put(fieldName, errorMessage); + } + } + + private void updateUserDTOWithClaims(UserInfo userClaimsInfo) { + userDTO.setUsername(getValueOrDefault(userClaimsInfo.getPreferredUsername(), userDTO.getUsername())); + userDTO.setFirstName(getValueOrDefault(userClaimsInfo.getGivenName(), userDTO.getFirstName())); + userDTO.setLastName(getValueOrDefault(userClaimsInfo.getFamilyName(), userDTO.getLastName())); + userDTO.setEmailAddress(getValueOrDefault(userClaimsInfo.getEmailAddress(), userDTO.getEmailAddress())); + } + + private void overwriteUserDTOWithClaims(UserInfo userClaimsInfo) { + userDTO.setUsername(userClaimsInfo.getPreferredUsername()); + userDTO.setFirstName(userClaimsInfo.getGivenName()); + userDTO.setLastName(userClaimsInfo.getFamilyName()); + userDTO.setEmailAddress(userClaimsInfo.getEmailAddress()); + } + + private void throwInvalidFieldsCommandExceptionIfErrorsExist(Map fieldErrors) throws InvalidFieldsCommandException { + if (!fieldErrors.isEmpty()) { + throw new InvalidFieldsCommandException( + BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.invalidFields"), + this, + fieldErrors + ); } } @@ -90,21 +128,21 @@ private String getValueOrDefault(String oidcValue, String dtoValue) { return (oidcValue == null || oidcValue.isEmpty()) ? dtoValue : oidcValue; } - private Map validateUserFields(CommandContext ctxt, boolean provideMissingClaimsEnabled) { + private void validateUserFields(CommandContext ctxt, boolean provideMissingClaimsEnabled) throws InvalidFieldsCommandException { Map fieldErrors = new HashMap<>(); validateTermsAccepted(fieldErrors); - validateField(fieldErrors, "emailAddress", userDTO.getEmailAddress(), ctxt, provideMissingClaimsEnabled); - validateField(fieldErrors, "username", userDTO.getUsername(), ctxt, provideMissingClaimsEnabled); - validateField(fieldErrors, "firstName", userDTO.getFirstName(), ctxt, provideMissingClaimsEnabled); - validateField(fieldErrors, "lastName", userDTO.getLastName(), ctxt, provideMissingClaimsEnabled); + validateField(fieldErrors, FIELD_EMAIL_ADDRESS, userDTO.getEmailAddress(), ctxt, provideMissingClaimsEnabled); + validateField(fieldErrors, FIELD_USERNAME, userDTO.getUsername(), ctxt, provideMissingClaimsEnabled); + validateField(fieldErrors, FIELD_FIRST_NAME, userDTO.getFirstName(), ctxt, provideMissingClaimsEnabled); + validateField(fieldErrors, FIELD_LAST_NAME, userDTO.getLastName(), ctxt, provideMissingClaimsEnabled); - return fieldErrors; + throwInvalidFieldsCommandExceptionIfErrorsExist(fieldErrors); } private void validateTermsAccepted(Map fieldErrors) { if (!userDTO.isTermsAccepted()) { - fieldErrors.put("termsAccepted", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userShouldAcceptTerms")); + fieldErrors.put(FIELD_TERMS_ACCEPTED, BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userShouldAcceptTerms")); } } @@ -120,9 +158,9 @@ private void validateField(Map fieldErrors, String fieldName, St } private boolean isFieldInUse(CommandContext ctxt, String fieldName, String value) { - if ("emailAddress".equals(fieldName)) { + if (FIELD_EMAIL_ADDRESS.equals(fieldName)) { return ctxt.authentication().getAuthenticatedUserByEmail(value) != null; - } else if ("username".equals(fieldName)) { + } else if (FIELD_USERNAME.equals(fieldName)) { return ctxt.authentication().getAuthenticatedUser(value) != null; } return false; diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index f814e08c49e..62b1c3ed3cd 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -3072,6 +3072,7 @@ users.api.userRegistered=User registered. registerOidcUserCommand.errors.userAlreadyRegisteredWithToken=User is already registered with this token. registerOidcUserCommand.errors.invalidFields=The provided fields are invalid for registering a new user. registerOidcUserCommand.errors.userShouldAcceptTerms=Terms should be accepted. +registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider=Unable to set {0} because it conflicts with an existing claim from the OIDC identity provider. registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldRequired=It is required to include the field {0} in the request JSON for registering the user. registerOidcUserCommand.errors.provideMissingClaimsDisabled.fieldRequired=The OIDC identity provider does not provide the user claim {0}, which is required for user registration. Please contact an administrator. registerOidcUserCommand.errors.emailAddressInUse=Email already in use. diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java index bc9b7f756f7..eb78a216626 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UsersIT.java @@ -660,6 +660,22 @@ public void testRegisterOIDCUser() { .statusCode(FORBIDDEN.getStatusCode()) .body("message", equalTo("User is already registered with this token.")); + // Should return an error when the Bearer token is valid and attempting to set JSON properties that conflict with existing claims in the IdP + registerOidcUserResponse = UtilIT.registerOidcUser( + "{" + + "\"firstName\":\"testFirstName\"," + + "\"lastName\":\"testLastName\"," + + "\"emailAddress\":\"" + UUID.randomUUID().toString().substring(0, 8) + "@dataverse.org\"," + + "\"termsAccepted\":true" + + "}", + "Bearer " + userWithClaimsAccessToken + ); + registerOidcUserResponse.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()) + .body("fieldErrors.firstName", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider", List.of("firstName")))) + .body("fieldErrors.lastName", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider", List.of("lastName")))) + .body("fieldErrors.emailAddress", equalTo(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider", List.of("emailAddress")))); + // Should register user when the Bearer token is valid and all required claims are present in the IdP, requiring only minimal data in the User JSON registerOidcUserResponse = UtilIT.registerOidcUser( "{" diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java index 5ee5bf443fa..bb6d2e609ae 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java @@ -207,4 +207,31 @@ void execute_happyPath_withAffiliationAndPosition_provideMissingClaimsEnabled() eq(true) ); } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-provide-missing-claims") + void execute_conflictingClaims_provideMissingClaimsEnabled() throws AuthorizationException { + when(authServiceMock.verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(OIDCUserInfoMock); + + when(userInfoMock.getPreferredUsername()).thenReturn("conflictingUsername"); + when(userInfoMock.getGivenName()).thenReturn("conflictingFirstName"); + when(userInfoMock.getFamilyName()).thenReturn("conflictingLastName"); + when(userInfoMock.getEmailAddress()).thenReturn("conflicting@example.com"); + + userDTO.setUsername("username"); + userDTO.setFirstName("FirstName"); + userDTO.setLastName("LastName"); + userDTO.setEmailAddress("user@example.com"); + + assertThatThrownBy(() -> sut.execute(context)) + .isInstanceOf(InvalidFieldsCommandException.class) + .satisfies(exception -> { + InvalidFieldsCommandException ex = (InvalidFieldsCommandException) exception; + assertThat(ex.getFieldErrors()) + .containsEntry("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider", List.of("username"))) + .containsEntry("firstName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider", List.of("firstName"))) + .containsEntry("lastName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider", List.of("lastName"))) + .containsEntry("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider", List.of("emailAddress"))); + }); + } } From 335e40a50340bb5d74e25e17dc86926a36657a79 Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 20 Nov 2024 11:40:32 +0000 Subject: [PATCH 059/101] Changed: doc tweak for api-bearer-auth-json-claims --- doc/sphinx-guides/source/api/auth.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/sphinx-guides/source/api/auth.rst b/doc/sphinx-guides/source/api/auth.rst index fc2aa994597..51234ad08bc 100644 --- a/doc/sphinx-guides/source/api/auth.rst +++ b/doc/sphinx-guides/source/api/auth.rst @@ -100,7 +100,7 @@ With the ``api-bearer-auth`` feature flag enabled, you can include the following - ``lastName`` - ``emailAddress`` -Note that even if they are included in the JSON, if it is possible to retrieve the corresponding claims from the identity provider, these values will be ignored and the ones from the identity provider will be used instead. +If properties are provided in the JSON, but corresponding claims already exist in the identity provider, an error will be thrown, outlining the conflicting properties. This functionality is included under a feature flag because using it may introduce user impersonation issues, for example if the identity provider does not provide an email field and the user submits an email address they do not own. From 4ca607025e20a264260b5c18f40d1da177e072fe Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 20 Nov 2024 14:46:04 +0000 Subject: [PATCH 060/101] Refactor: using OAuth2UserRecord instead of OIDCUserInfo --- .../AuthenticationServiceBean.java | 18 +- .../oauth2/oidc/OIDCAuthProvider.java | 42 +--- .../command/impl/RegisterOIDCUserCommand.java | 48 ++-- .../AuthenticationServiceBeanTest.java | 81 ++++--- .../impl/RegisterOIDCUserCommandTest.java | 218 +++++++++++------- 5 files changed, 220 insertions(+), 187 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java index 3d46af4f8cf..f5c354defeb 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java @@ -11,6 +11,7 @@ import edu.harvard.iq.dataverse.UserServiceBean; import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; import edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2Exception; +import edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2UserRecord; import edu.harvard.iq.dataverse.authorization.providers.oauth2.oidc.OIDCAuthProvider; import edu.harvard.iq.dataverse.search.IndexServiceBean; import edu.harvard.iq.dataverse.actionlogging.ActionLogRecord; @@ -987,18 +988,18 @@ public ApiToken getValidApiTokenForUser(User user) { public AuthenticatedUser lookupUserByOIDCBearerToken(String bearerToken) throws AuthorizationException { // TODO: Get the identifier from an invalidating cache to avoid lookup bursts of the same token. // Tokens in the cache should be removed after some (configurable) time. - OIDCUserInfo oidcUserInfo = verifyOIDCBearerTokenAndGetUserIdentifier(bearerToken); - return lookupUser(oidcUserInfo.getUserRecordIdentifier()); + OAuth2UserRecord oAuth2UserRecord = verifyOIDCBearerTokenAndGetOAuth2UserRecord(bearerToken); + return lookupUser(oAuth2UserRecord.getUserRecordIdentifier()); } /** - * Verifies the given OIDC bearer token and retrieves the corresponding OIDC user info. + * Verifies the given OIDC bearer token and retrieves the corresponding OAuth2UserRecord. * * @param bearerToken The OIDC bearer token. * @return An {@link OIDCUserInfo} containing the user's identifier and user info. * @throws AuthorizationException If the token is invalid or if no OIDC providers are available. */ - public OIDCUserInfo verifyOIDCBearerTokenAndGetUserIdentifier(String bearerToken) throws AuthorizationException { + public OAuth2UserRecord verifyOIDCBearerTokenAndGetOAuth2UserRecord(String bearerToken) throws AuthorizationException { try { BearerAccessToken accessToken = BearerAccessToken.parse(bearerToken); List providers = getAvailableOidcProviders(); @@ -1012,14 +1013,11 @@ public OIDCUserInfo verifyOIDCBearerTokenAndGetUserIdentifier(String bearerToken // Attempt to validate the token with each configured OIDC provider. for (OIDCAuthProvider provider : providers) { try { - // Retrieve both user identifier and user info - Optional userRecordIdentifier = provider.getUserIdentifier(accessToken); + // Retrieve OAuth2UserRecord if UserInfo is present Optional userInfo = provider.getUserInfo(accessToken); - - // If either is present, return the result - if (userRecordIdentifier.isPresent() || userInfo.isPresent()) { + if (userInfo.isPresent()) { logger.log(Level.FINE, "Bearer token detected, provider {0} confirmed validity and provided user info", provider.getId()); - return new OIDCUserInfo(userRecordIdentifier.get(), userInfo.get()); + return provider.getUserRecord(userInfo.get()); } } catch (IOException | OAuth2Exception e) { logger.log(Level.FINE, "Bearer token detected, provider " + provider.getId() + " indicates an invalid Token, skipping", e); diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthProvider.java b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthProvider.java index 675e1696844..f396ebf6487 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthProvider.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthProvider.java @@ -242,7 +242,7 @@ public OAuth2UserRecord getUserRecord(String code, String state, String redirect * @param userInfo * @return the usable user record for processing ing {@link edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2LoginBackingBean} */ - OAuth2UserRecord getUserRecord(UserInfo userInfo) { + public OAuth2UserRecord getUserRecord(UserInfo userInfo) { return new OAuth2UserRecord( this.getId(), userInfo.getSubject().getValue(), @@ -316,44 +316,4 @@ public Optional getUserInfo(BearerAccessToken accessToken) throws IOEx throw new OAuth2Exception(-1, ex.getMessage(), BundleUtil.getStringFromBundle("auth.providers.exception.userinfo", Arrays.asList(this.getTitle()))); } } - - /** - * Trades an access token for an {@link UserRecordIdentifier} (if valid). - * - * @apiNote The resulting {@link UserRecordIdentifier} may be used with - * {@link edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean#lookupUser(UserRecordIdentifier)} - * to look up an {@link edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser} from the database. - * @see edu.harvard.iq.dataverse.api.auth.BearerTokenAuthMechanism - * - * @param accessToken The token to use when requesting user information from the provider - * @return Returns an {@link UserRecordIdentifier} for a valid access token or an empty {@link Optional}. - * @throws IOException In case communication with the endpoint fails to succeed for an I/O reason - */ - public Optional getUserIdentifier(BearerAccessToken accessToken) throws IOException { - OAuth2UserRecord userRecord; - try { - // Try to retrieve with given token (throws if invalid token) - Optional userInfo = getUserInfo(accessToken); - - if (userInfo.isPresent()) { - // Take this detour to avoid code duplication and potentially hard to track conversion errors. - userRecord = getUserRecord(userInfo.get()); - } else { - // This should not happen - an error at the provider side will lead to an exception. - logger.log(Level.WARNING, - "User info retrieval from {0} returned empty optional but expected exception for token {1}.", - List.of(getId(), accessToken).toArray() - ); - return Optional.empty(); - } - } catch (OAuth2Exception e) { - logger.log(Level.FINE, - "Could not retrieve user info with token {0} at provider {1}: {2}", - List.of(accessToken, getId(), e.getMessage()).toArray()); - logger.log(Level.FINER, "Retrieval failed, details as follows: ", e); - return Optional.empty(); - } - - return Optional.of(userRecord.getUserRecordIdentifier()); - } } diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java index 3c4bf4f097b..2c94a08b088 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java @@ -1,12 +1,11 @@ package edu.harvard.iq.dataverse.engine.command.impl; -import com.nimbusds.openid.connect.sdk.claims.UserInfo; import edu.harvard.iq.dataverse.DvObject; import edu.harvard.iq.dataverse.api.dto.UserDTO; import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo; -import edu.harvard.iq.dataverse.authorization.OIDCUserInfo; import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; +import edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2UserRecord; import edu.harvard.iq.dataverse.engine.command.*; import edu.harvard.iq.dataverse.engine.command.exception.CommandException; import edu.harvard.iq.dataverse.engine.command.exception.IllegalCommandException; @@ -40,17 +39,16 @@ public RegisterOIDCUserCommand(DataverseRequest aRequest, String bearerToken, Us @Override protected void executeImpl(CommandContext ctxt) throws CommandException { try { - OIDCUserInfo oidcUserInfo = ctxt.authentication().verifyOIDCBearerTokenAndGetUserIdentifier(bearerToken); - UserRecordIdentifier userRecordIdentifier = oidcUserInfo.getUserRecordIdentifier(); + OAuth2UserRecord oAuth2UserRecord = ctxt.authentication().verifyOIDCBearerTokenAndGetOAuth2UserRecord(bearerToken); + UserRecordIdentifier userRecordIdentifier = oAuth2UserRecord.getUserRecordIdentifier(); if (ctxt.authentication().lookupUser(userRecordIdentifier) != null) { throw new IllegalCommandException(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userAlreadyRegisteredWithToken"), this); } - UserInfo userClaimsInfo = oidcUserInfo.getUserClaimsInfo(); boolean provideMissingClaimsEnabled = FeatureFlags.API_BEARER_AUTH_PROVIDE_MISSING_CLAIMS.enabled(); - updateUserDTO(userClaimsInfo, provideMissingClaimsEnabled); + updateUserDTO(oAuth2UserRecord, provideMissingClaimsEnabled); AuthenticatedUserDisplayInfo userDisplayInfo = new AuthenticatedUserDisplayInfo( userDTO.getFirstName(), @@ -69,23 +67,23 @@ protected void executeImpl(CommandContext ctxt) throws CommandException { } } - private void updateUserDTO(UserInfo userClaimsInfo, boolean provideMissingClaimsEnabled) throws InvalidFieldsCommandException { + private void updateUserDTO(OAuth2UserRecord oAuth2UserRecord, boolean provideMissingClaimsEnabled) throws InvalidFieldsCommandException { if (provideMissingClaimsEnabled) { - Map fieldErrors = validateConflictingClaims(userClaimsInfo); + Map fieldErrors = validateConflictingClaims(oAuth2UserRecord); throwInvalidFieldsCommandExceptionIfErrorsExist(fieldErrors); - updateUserDTOWithClaims(userClaimsInfo); + updateUserDTOWithClaims(oAuth2UserRecord); } else { - overwriteUserDTOWithClaims(userClaimsInfo); + overwriteUserDTOWithClaims(oAuth2UserRecord); } } - private Map validateConflictingClaims(UserInfo userClaimsInfo) { + private Map validateConflictingClaims(OAuth2UserRecord oAuth2UserRecord) { Map fieldErrors = new HashMap<>(); - addFieldErrorIfConflict(FIELD_USERNAME, userClaimsInfo.getPreferredUsername(), userDTO.getUsername(), fieldErrors); - addFieldErrorIfConflict(FIELD_FIRST_NAME, userClaimsInfo.getGivenName(), userDTO.getFirstName(), fieldErrors); - addFieldErrorIfConflict(FIELD_LAST_NAME, userClaimsInfo.getFamilyName(), userDTO.getLastName(), fieldErrors); - addFieldErrorIfConflict(FIELD_EMAIL_ADDRESS, userClaimsInfo.getEmailAddress(), userDTO.getEmailAddress(), fieldErrors); + addFieldErrorIfConflict(FIELD_USERNAME, oAuth2UserRecord.getUsername(), userDTO.getUsername(), fieldErrors); + addFieldErrorIfConflict(FIELD_FIRST_NAME, oAuth2UserRecord.getDisplayInfo().getFirstName(), userDTO.getFirstName(), fieldErrors); + addFieldErrorIfConflict(FIELD_LAST_NAME, oAuth2UserRecord.getDisplayInfo().getLastName(), userDTO.getLastName(), fieldErrors); + addFieldErrorIfConflict(FIELD_EMAIL_ADDRESS, oAuth2UserRecord.getDisplayInfo().getEmailAddress(), userDTO.getEmailAddress(), fieldErrors); return fieldErrors; } @@ -100,18 +98,18 @@ private void addFieldErrorIfConflict(String fieldName, String claimValue, String } } - private void updateUserDTOWithClaims(UserInfo userClaimsInfo) { - userDTO.setUsername(getValueOrDefault(userClaimsInfo.getPreferredUsername(), userDTO.getUsername())); - userDTO.setFirstName(getValueOrDefault(userClaimsInfo.getGivenName(), userDTO.getFirstName())); - userDTO.setLastName(getValueOrDefault(userClaimsInfo.getFamilyName(), userDTO.getLastName())); - userDTO.setEmailAddress(getValueOrDefault(userClaimsInfo.getEmailAddress(), userDTO.getEmailAddress())); + private void updateUserDTOWithClaims(OAuth2UserRecord oAuth2UserRecord) { + userDTO.setUsername(getValueOrDefault(oAuth2UserRecord.getUsername(), userDTO.getUsername())); + userDTO.setFirstName(getValueOrDefault(oAuth2UserRecord.getDisplayInfo().getFirstName(), userDTO.getFirstName())); + userDTO.setLastName(getValueOrDefault(oAuth2UserRecord.getDisplayInfo().getLastName(), userDTO.getLastName())); + userDTO.setEmailAddress(getValueOrDefault(oAuth2UserRecord.getDisplayInfo().getEmailAddress(), userDTO.getEmailAddress())); } - private void overwriteUserDTOWithClaims(UserInfo userClaimsInfo) { - userDTO.setUsername(userClaimsInfo.getPreferredUsername()); - userDTO.setFirstName(userClaimsInfo.getGivenName()); - userDTO.setLastName(userClaimsInfo.getFamilyName()); - userDTO.setEmailAddress(userClaimsInfo.getEmailAddress()); + private void overwriteUserDTOWithClaims(OAuth2UserRecord oAuth2UserRecord) { + userDTO.setUsername(oAuth2UserRecord.getUsername()); + userDTO.setFirstName(oAuth2UserRecord.getDisplayInfo().getFirstName()); + userDTO.setLastName(oAuth2UserRecord.getDisplayInfo().getLastName()); + userDTO.setEmailAddress(oAuth2UserRecord.getDisplayInfo().getEmailAddress()); } private void throwInvalidFieldsCommandExceptionIfErrorsExist(Map fieldErrors) throws InvalidFieldsCommandException { diff --git a/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java b/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java index a1e51fb3e01..56ac4eefb3d 100644 --- a/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java @@ -5,6 +5,7 @@ import com.nimbusds.openid.connect.sdk.claims.UserInfo; import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; import edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2Exception; +import edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2UserRecord; import edu.harvard.iq.dataverse.authorization.providers.oauth2.oidc.OIDCAuthProvider; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.User; @@ -48,11 +49,11 @@ void testLookupUserByOIDCBearerToken_no_OIDCProvider() { } @Test - void testLookupUserByOIDCBearerToken_oneProvider_invalidToken_1() throws ParseException, IOException { + void testLookupUserByOIDCBearerToken_oneProvider_invalidToken_1() throws ParseException, OAuth2Exception, IOException { // Given a single OIDC provider that cannot find a user - OIDCAuthProvider oidcAuthProvider = mockOIDCAuthProvider("OIEDC"); + OIDCAuthProvider oidcAuthProviderStub = stubOIDCAuthProvider("OIEDC"); BearerAccessToken token = BearerAccessToken.parse(TEST_BEARER_TOKEN); - Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenReturn(Optional.empty()); + Mockito.when(oidcAuthProviderStub.getUserInfo(token)).thenReturn(Optional.empty()); // When invoking lookupUserByOIDCBearerToken AuthorizationException exception = assertThrows(AuthorizationException.class, @@ -63,11 +64,11 @@ void testLookupUserByOIDCBearerToken_oneProvider_invalidToken_1() throws ParseEx } @Test - void testLookupUserByOIDCBearerToken_oneProvider_invalidToken_2() throws ParseException, IOException { + void testLookupUserByOIDCBearerToken_oneProvider_invalidToken_2() throws ParseException, IOException, OAuth2Exception { // Given a single OIDC provider that throws an IOException - OIDCAuthProvider oidcAuthProvider = mockOIDCAuthProvider("OIEDC"); + OIDCAuthProvider oidcAuthProviderStub = stubOIDCAuthProvider("OIEDC"); BearerAccessToken token = BearerAccessToken.parse(TEST_BEARER_TOKEN); - Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenThrow(IOException.class); + Mockito.when(oidcAuthProviderStub.getUserInfo(token)).thenThrow(IOException.class); // When invoking lookupUserByOIDCBearerToken AuthorizationException exception = assertThrows(AuthorizationException.class, @@ -80,12 +81,10 @@ void testLookupUserByOIDCBearerToken_oneProvider_invalidToken_2() throws ParseEx @Test void testLookupUserByOIDCBearerToken_oneProvider_validToken() throws ParseException, IOException, AuthorizationException, OAuth2Exception { // Given a single OIDC provider that returns a valid user identifier - OIDCAuthProvider oidcAuthProvider = mockOIDCAuthProvider("OIEDC"); + setUpOIDCProviderWhichValidatesToken(); + + // Setting up an authenticated user is found AuthenticatedUser authenticatedUser = setupAuthenticatedUserQueryWithResult(new AuthenticatedUser()); - UserRecordIdentifier userInfo = new UserRecordIdentifier("OIEDC", "KEY"); - BearerAccessToken token = BearerAccessToken.parse(TEST_BEARER_TOKEN); - Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenReturn(Optional.of(userInfo)); - Mockito.when(oidcAuthProvider.getUserInfo(token)).thenReturn(Optional.of(Mockito.mock(UserInfo.class))); // When invoking lookupUserByOIDCBearerToken User actualUser = sut.lookupUserByOIDCBearerToken(TEST_BEARER_TOKEN); @@ -96,13 +95,11 @@ void testLookupUserByOIDCBearerToken_oneProvider_validToken() throws ParseExcept @Test void testLookupUserByOIDCBearerToken_oneProvider_validToken_noAccount() throws ParseException, IOException, AuthorizationException, OAuth2Exception { - // Given a single OIDC provider with a valid user identifier but no account exists - OIDCAuthProvider oidcAuthProvider = mockOIDCAuthProvider("OIEDC"); + // Given a single OIDC provider that returns a valid user identifier + setUpOIDCProviderWhichValidatesToken(); + + // Setting up an authenticated user is not found setupAuthenticatedUserQueryWithNoResult(); - UserRecordIdentifier userInfo = new UserRecordIdentifier("OIEDC", "KEY"); - BearerAccessToken token = BearerAccessToken.parse(TEST_BEARER_TOKEN); - Mockito.when(oidcAuthProvider.getUserIdentifier(token)).thenReturn(Optional.of(userInfo)); - Mockito.when(oidcAuthProvider.getUserInfo(token)).thenReturn(Optional.of(Mockito.mock(UserInfo.class))); // When invoking lookupUserByOIDCBearerToken User actualUser = sut.lookupUserByOIDCBearerToken(TEST_BEARER_TOKEN); @@ -111,25 +108,45 @@ void testLookupUserByOIDCBearerToken_oneProvider_validToken_noAccount() throws P assertNull(actualUser); } - private OIDCAuthProvider mockOIDCAuthProvider(String providerID) { - OIDCAuthProvider oidcAuthProvider = Mockito.mock(OIDCAuthProvider.class); - Mockito.when(oidcAuthProvider.getId()).thenReturn(providerID); - Mockito.when(sut.authProvidersRegistrationService.getAuthenticationProvidersMap()).thenReturn(Map.of(providerID, oidcAuthProvider)); - return oidcAuthProvider; - } - private AuthenticatedUser setupAuthenticatedUserQueryWithResult(AuthenticatedUser authenticatedUser) { - TypedQuery queryMock = Mockito.mock(TypedQuery.class); - AuthenticatedUserLookup lookupMock = Mockito.mock(AuthenticatedUserLookup.class); - Mockito.when(lookupMock.getAuthenticatedUser()).thenReturn(authenticatedUser); - Mockito.when(queryMock.getSingleResult()).thenReturn(lookupMock); - Mockito.when(sut.em.createNamedQuery("AuthenticatedUserLookup.findByAuthPrvID_PersUserId", AuthenticatedUserLookup.class)).thenReturn(queryMock); + TypedQuery queryStub = Mockito.mock(TypedQuery.class); + AuthenticatedUserLookup lookupStub = Mockito.mock(AuthenticatedUserLookup.class); + Mockito.when(lookupStub.getAuthenticatedUser()).thenReturn(authenticatedUser); + Mockito.when(queryStub.getSingleResult()).thenReturn(lookupStub); + Mockito.when(sut.em.createNamedQuery("AuthenticatedUserLookup.findByAuthPrvID_PersUserId", AuthenticatedUserLookup.class)).thenReturn(queryStub); return authenticatedUser; } private void setupAuthenticatedUserQueryWithNoResult() { - TypedQuery queryMock = Mockito.mock(TypedQuery.class); - Mockito.when(queryMock.getSingleResult()).thenThrow(new NoResultException()); - Mockito.when(sut.em.createNamedQuery("AuthenticatedUserLookup.findByAuthPrvID_PersUserId", AuthenticatedUserLookup.class)).thenReturn(queryMock); + TypedQuery queryStub = Mockito.mock(TypedQuery.class); + Mockito.when(queryStub.getSingleResult()).thenThrow(new NoResultException()); + Mockito.when(sut.em.createNamedQuery("AuthenticatedUserLookup.findByAuthPrvID_PersUserId", AuthenticatedUserLookup.class)).thenReturn(queryStub); + } + + private void setUpOIDCProviderWhichValidatesToken() throws ParseException, IOException, OAuth2Exception { + OIDCAuthProvider oidcAuthProviderStub = stubOIDCAuthProvider("OIDC"); + + BearerAccessToken token = BearerAccessToken.parse(TEST_BEARER_TOKEN); + + // Stub the UserInfo returned by the provider + UserInfo userInfoStub = Mockito.mock(UserInfo.class); + Mockito.when(oidcAuthProviderStub.getUserInfo(token)).thenReturn(Optional.of(userInfoStub)); + + // Stub OAuth2UserRecord and its associated UserRecordIdentifier + OAuth2UserRecord oAuth2UserRecordStub = Mockito.mock(OAuth2UserRecord.class); + UserRecordIdentifier userRecordIdentifierStub = Mockito.mock(UserRecordIdentifier.class); + Mockito.when(userRecordIdentifierStub.getUserIdInRepo()).thenReturn("testUserId"); + Mockito.when(userRecordIdentifierStub.getUserRepoId()).thenReturn("testRepoId"); + Mockito.when(oAuth2UserRecordStub.getUserRecordIdentifier()).thenReturn(userRecordIdentifierStub); + + // Stub the OIDCAuthProvider to return OAuth2UserRecord + Mockito.when(oidcAuthProviderStub.getUserRecord(userInfoStub)).thenReturn(oAuth2UserRecordStub); + } + + private OIDCAuthProvider stubOIDCAuthProvider(String providerID) { + OIDCAuthProvider oidcAuthProviderStub = Mockito.mock(OIDCAuthProvider.class); + Mockito.when(oidcAuthProviderStub.getId()).thenReturn(providerID); + Mockito.when(sut.authProvidersRegistrationService.getAuthenticationProvidersMap()).thenReturn(Map.of(providerID, oidcAuthProviderStub)); + return oidcAuthProviderStub; } } diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java index bb6d2e609ae..a626e155336 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java @@ -1,12 +1,11 @@ package edu.harvard.iq.dataverse.engine.command.impl; -import com.nimbusds.openid.connect.sdk.claims.UserInfo; import edu.harvard.iq.dataverse.api.dto.UserDTO; import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo; import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; -import edu.harvard.iq.dataverse.authorization.OIDCUserInfo; import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; +import edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2UserRecord; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.engine.command.CommandContext; import edu.harvard.iq.dataverse.engine.command.exception.CommandException; @@ -34,21 +33,35 @@ class RegisterOIDCUserCommandTest { private static final String TEST_BEARER_TOKEN = "Bearer test"; - - private UserDTO userDTO; + private static final String TEST_USERNAME = "username"; + private static final AuthenticatedUserDisplayInfo TEST_MISSING_CLAIMS_DISPLAY_INFO = new AuthenticatedUserDisplayInfo( + null, + null, + null, + "", + "" + ); + private static final AuthenticatedUserDisplayInfo TEST_VALID_DISPLAY_INFO = new AuthenticatedUserDisplayInfo( + "FirstName", + "LastName", + "user@example.com", + "", + "" + ); + + private UserDTO testUserDTO; @Mock - private CommandContext context; + private CommandContext contextStub; @Mock - private AuthenticationServiceBean authServiceMock; + private AuthenticationServiceBean authServiceStub; @InjectMocks private RegisterOIDCUserCommand sut; + private OAuth2UserRecord oAuth2UserRecordStub; private UserRecordIdentifier userRecordIdentifierMock; - private UserInfo userInfoMock; - private OIDCUserInfo OIDCUserInfoMock; private AuthenticatedUser existingTestUser; @BeforeEach @@ -57,31 +70,36 @@ void setUp() { setUpDefaultUserDTO(); userRecordIdentifierMock = mock(UserRecordIdentifier.class); - userInfoMock = mock(UserInfo.class); - OIDCUserInfoMock = new OIDCUserInfo(userRecordIdentifierMock, userInfoMock); + oAuth2UserRecordStub = mock(OAuth2UserRecord.class); existingTestUser = new AuthenticatedUser(); - when(context.authentication()).thenReturn(authServiceMock); - sut = new RegisterOIDCUserCommand(makeRequest(), TEST_BEARER_TOKEN, userDTO); + when(oAuth2UserRecordStub.getUserRecordIdentifier()).thenReturn(userRecordIdentifierMock); + when(contextStub.authentication()).thenReturn(authServiceStub); + + sut = new RegisterOIDCUserCommand(makeRequest(), TEST_BEARER_TOKEN, testUserDTO); } private void setUpDefaultUserDTO() { - userDTO = new UserDTO(); - userDTO.setTermsAccepted(true); - userDTO.setFirstName("FirstName"); - userDTO.setLastName("LastName"); - userDTO.setUsername("username"); - userDTO.setEmailAddress("user@example.com"); + testUserDTO = new UserDTO(); + testUserDTO.setTermsAccepted(true); + testUserDTO.setFirstName("FirstName"); + testUserDTO.setLastName("LastName"); + testUserDTO.setUsername("username"); + testUserDTO.setEmailAddress("user@example.com"); } @Test - public void execute_completedUserDTOWithUnacceptedTerms_provideMissingClaimsDisabled() throws AuthorizationException { - userDTO.setTermsAccepted(false); - when(authServiceMock.getAuthenticatedUserByEmail(userDTO.getEmailAddress())).thenReturn(null); - when(authServiceMock.getAuthenticatedUser(userDTO.getUsername())).thenReturn(null); - when(authServiceMock.verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(OIDCUserInfoMock); + public void execute_completedUserDTOWithUnacceptedTerms_missingClaimsInProvider_provideMissingClaimsFeatureFlagDisabled() throws AuthorizationException { + testUserDTO.setTermsAccepted(false); + + when(authServiceStub.getAuthenticatedUserByEmail(testUserDTO.getEmailAddress())).thenReturn(null); + when(authServiceStub.getAuthenticatedUser(testUserDTO.getUsername())).thenReturn(null); + when(authServiceStub.verifyOIDCBearerTokenAndGetOAuth2UserRecord(TEST_BEARER_TOKEN)).thenReturn(oAuth2UserRecordStub); + + when(oAuth2UserRecordStub.getUsername()).thenReturn(null); + when(oAuth2UserRecordStub.getDisplayInfo()).thenReturn(TEST_MISSING_CLAIMS_DISPLAY_INFO); - assertThatThrownBy(() -> sut.execute(context)) + assertThatThrownBy(() -> sut.execute(contextStub)) .isInstanceOf(InvalidFieldsCommandException.class) .satisfies(exception -> { InvalidFieldsCommandException ex = (InvalidFieldsCommandException) exception; @@ -96,17 +114,21 @@ public void execute_completedUserDTOWithUnacceptedTerms_provideMissingClaimsDisa @Test @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-provide-missing-claims") - public void execute_uncompletedUserDTOWithUnacceptedTerms_provideMissingClaimsEnabled() throws AuthorizationException { - userDTO.setTermsAccepted(false); - userDTO.setEmailAddress(null); - userDTO.setUsername(null); - userDTO.setFirstName(null); - userDTO.setLastName(null); - when(authServiceMock.getAuthenticatedUserByEmail(userDTO.getEmailAddress())).thenReturn(null); - when(authServiceMock.getAuthenticatedUser(userDTO.getUsername())).thenReturn(null); - when(authServiceMock.verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(OIDCUserInfoMock); - - assertThatThrownBy(() -> sut.execute(context)) + public void execute_uncompletedUserDTOWithUnacceptedTerms_missingClaimsInProvider_provideMissingClaimsFeatureFlagEnabled() throws AuthorizationException { + testUserDTO.setTermsAccepted(false); + testUserDTO.setEmailAddress(null); + testUserDTO.setUsername(null); + testUserDTO.setFirstName(null); + testUserDTO.setLastName(null); + + when(oAuth2UserRecordStub.getUsername()).thenReturn(null); + when(oAuth2UserRecordStub.getDisplayInfo()).thenReturn(TEST_MISSING_CLAIMS_DISPLAY_INFO); + + when(authServiceStub.getAuthenticatedUserByEmail(testUserDTO.getEmailAddress())).thenReturn(null); + when(authServiceStub.getAuthenticatedUser(testUserDTO.getUsername())).thenReturn(null); + when(authServiceStub.verifyOIDCBearerTokenAndGetOAuth2UserRecord(TEST_BEARER_TOKEN)).thenReturn(oAuth2UserRecordStub); + + assertThatThrownBy(() -> sut.execute(contextStub)) .isInstanceOf(InvalidFieldsCommandException.class) .satisfies(exception -> { InvalidFieldsCommandException ex = (InvalidFieldsCommandException) exception; @@ -121,12 +143,15 @@ public void execute_uncompletedUserDTOWithUnacceptedTerms_provideMissingClaimsEn @Test @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-provide-missing-claims") - public void execute_acceptedTerms_unavailableEmailAndUsername_provideMissingClaimsEnabled() throws AuthorizationException { - when(authServiceMock.getAuthenticatedUserByEmail(userDTO.getEmailAddress())).thenReturn(existingTestUser); - when(authServiceMock.getAuthenticatedUser(userDTO.getUsername())).thenReturn(existingTestUser); - when(authServiceMock.verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(OIDCUserInfoMock); + public void execute_acceptedTerms_unavailableEmailAndUsername_missingClaimsInProvider_provideMissingClaimsFeatureFlagEnabled() throws AuthorizationException { + when(authServiceStub.getAuthenticatedUserByEmail(testUserDTO.getEmailAddress())).thenReturn(existingTestUser); + when(authServiceStub.getAuthenticatedUser(testUserDTO.getUsername())).thenReturn(existingTestUser); + when(authServiceStub.verifyOIDCBearerTokenAndGetOAuth2UserRecord(TEST_BEARER_TOKEN)).thenReturn(oAuth2UserRecordStub); - assertThatThrownBy(() -> sut.execute(context)) + when(oAuth2UserRecordStub.getUsername()).thenReturn(null); + when(oAuth2UserRecordStub.getDisplayInfo()).thenReturn(TEST_MISSING_CLAIMS_DISPLAY_INFO); + + assertThatThrownBy(() -> sut.execute(contextStub)) .isInstanceOf(InvalidFieldsCommandException.class) .satisfies(exception -> { InvalidFieldsCommandException ex = (InvalidFieldsCommandException) exception; @@ -140,43 +165,46 @@ public void execute_acceptedTerms_unavailableEmailAndUsername_provideMissingClai @Test void execute_throwsPermissionException_onAuthorizationException() throws AuthorizationException { String testAuthorizationExceptionMessage = "Authorization failed"; - when(context.authentication().verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)) + when(contextStub.authentication().verifyOIDCBearerTokenAndGetOAuth2UserRecord(TEST_BEARER_TOKEN)) .thenThrow(new AuthorizationException(testAuthorizationExceptionMessage)); - assertThatThrownBy(() -> sut.execute(context)) + assertThatThrownBy(() -> sut.execute(contextStub)) .isInstanceOf(PermissionException.class) .hasMessageContaining(testAuthorizationExceptionMessage); - verify(context.authentication(), times(1)).verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN); + verify(contextStub.authentication(), times(1)).verifyOIDCBearerTokenAndGetOAuth2UserRecord(TEST_BEARER_TOKEN); } @Test void execute_throwsIllegalCommandException_ifUserAlreadyRegisteredWithToken() throws AuthorizationException { - when(context.authentication().verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)) - .thenReturn(OIDCUserInfoMock); - when(context.authentication().lookupUser(userRecordIdentifierMock)).thenReturn(new AuthenticatedUser()); + when(contextStub.authentication().verifyOIDCBearerTokenAndGetOAuth2UserRecord(TEST_BEARER_TOKEN)) + .thenReturn(oAuth2UserRecordStub); + when(contextStub.authentication().lookupUser(userRecordIdentifierMock)).thenReturn(new AuthenticatedUser()); - assertThatThrownBy(() -> sut.execute(context)) + assertThatThrownBy(() -> sut.execute(contextStub)) .isInstanceOf(IllegalCommandException.class) .hasMessageContaining(BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.userAlreadyRegisteredWithToken")); - verify(context.authentication(), times(1)).lookupUser(userRecordIdentifierMock); + verify(contextStub.authentication(), times(1)).lookupUser(userRecordIdentifierMock); } @Test @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-provide-missing-claims") - void execute_happyPath_withoutAffiliationAndPosition_provideMissingClaimsEnabled() throws AuthorizationException, CommandException { - when(authServiceMock.verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(OIDCUserInfoMock); + void execute_happyPath_withoutAffiliationAndPosition_missingClaimsInProvider_provideMissingClaimsFeatureFlagEnabled() throws AuthorizationException, CommandException { + when(authServiceStub.verifyOIDCBearerTokenAndGetOAuth2UserRecord(TEST_BEARER_TOKEN)).thenReturn(oAuth2UserRecordStub); + + when(oAuth2UserRecordStub.getUsername()).thenReturn(null); + when(oAuth2UserRecordStub.getDisplayInfo()).thenReturn(TEST_MISSING_CLAIMS_DISPLAY_INFO); - sut.execute(context); + sut.execute(contextStub); - verify(authServiceMock, times(1)).createAuthenticatedUser( + verify(authServiceStub, times(1)).createAuthenticatedUser( eq(userRecordIdentifierMock), - eq(userDTO.getUsername()), + eq(testUserDTO.getUsername()), eq(new AuthenticatedUserDisplayInfo( - userDTO.getFirstName(), - userDTO.getLastName(), - userDTO.getEmailAddress(), + testUserDTO.getFirstName(), + testUserDTO.getLastName(), + testUserDTO.getEmailAddress(), "", "") ), @@ -186,23 +214,26 @@ void execute_happyPath_withoutAffiliationAndPosition_provideMissingClaimsEnabled @Test @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-provide-missing-claims") - void execute_happyPath_withAffiliationAndPosition_provideMissingClaimsEnabled() throws AuthorizationException, CommandException { - userDTO.setPosition("test position"); - userDTO.setAffiliation("test affiliation"); + void execute_happyPath_withAffiliationAndPosition_missingClaimsInProvider_provideMissingClaimsFeatureFlagEnabled() throws AuthorizationException, CommandException { + testUserDTO.setPosition("test position"); + testUserDTO.setAffiliation("test affiliation"); + + when(authServiceStub.verifyOIDCBearerTokenAndGetOAuth2UserRecord(TEST_BEARER_TOKEN)).thenReturn(oAuth2UserRecordStub); - when(authServiceMock.verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(OIDCUserInfoMock); + when(oAuth2UserRecordStub.getUsername()).thenReturn(null); + when(oAuth2UserRecordStub.getDisplayInfo()).thenReturn(TEST_MISSING_CLAIMS_DISPLAY_INFO); - sut.execute(context); + sut.execute(contextStub); - verify(authServiceMock, times(1)).createAuthenticatedUser( + verify(authServiceStub, times(1)).createAuthenticatedUser( eq(userRecordIdentifierMock), - eq(userDTO.getUsername()), + eq(testUserDTO.getUsername()), eq(new AuthenticatedUserDisplayInfo( - userDTO.getFirstName(), - userDTO.getLastName(), - userDTO.getEmailAddress(), - userDTO.getAffiliation(), - userDTO.getPosition()) + testUserDTO.getFirstName(), + testUserDTO.getLastName(), + testUserDTO.getEmailAddress(), + testUserDTO.getAffiliation(), + testUserDTO.getPosition()) ), eq(true) ); @@ -210,28 +241,57 @@ void execute_happyPath_withAffiliationAndPosition_provideMissingClaimsEnabled() @Test @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-provide-missing-claims") - void execute_conflictingClaims_provideMissingClaimsEnabled() throws AuthorizationException { - when(authServiceMock.verifyOIDCBearerTokenAndGetUserIdentifier(TEST_BEARER_TOKEN)).thenReturn(OIDCUserInfoMock); + void execute_conflictingClaimsInProvider_provideMissingClaimsFeatureFlagEnabled() throws AuthorizationException { + when(authServiceStub.verifyOIDCBearerTokenAndGetOAuth2UserRecord(TEST_BEARER_TOKEN)).thenReturn(oAuth2UserRecordStub); - when(userInfoMock.getPreferredUsername()).thenReturn("conflictingUsername"); - when(userInfoMock.getGivenName()).thenReturn("conflictingFirstName"); - when(userInfoMock.getFamilyName()).thenReturn("conflictingLastName"); - when(userInfoMock.getEmailAddress()).thenReturn("conflicting@example.com"); + when(oAuth2UserRecordStub.getUsername()).thenReturn(TEST_USERNAME); + when(oAuth2UserRecordStub.getDisplayInfo()).thenReturn(TEST_VALID_DISPLAY_INFO); - userDTO.setUsername("username"); - userDTO.setFirstName("FirstName"); - userDTO.setLastName("LastName"); - userDTO.setEmailAddress("user@example.com"); + testUserDTO.setUsername("conflictingUsername"); + testUserDTO.setFirstName("conflictingFirstName"); + testUserDTO.setLastName("conflictingLastName"); + testUserDTO.setEmailAddress("conflictingemail@example.com"); - assertThatThrownBy(() -> sut.execute(context)) + assertThatThrownBy(() -> sut.execute(contextStub)) .isInstanceOf(InvalidFieldsCommandException.class) .satisfies(exception -> { InvalidFieldsCommandException ex = (InvalidFieldsCommandException) exception; assertThat(ex.getFieldErrors()) .containsEntry("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider", List.of("username"))) + .containsEntry("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider", List.of("emailAddress"))) .containsEntry("firstName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider", List.of("firstName"))) .containsEntry("lastName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider", List.of("lastName"))) .containsEntry("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider", List.of("emailAddress"))); }); } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-provide-missing-claims") + void execute_happyPath_withoutAffiliationAndPosition_claimsInProvider_provideMissingClaimsFeatureFlagEnabled() throws AuthorizationException, CommandException { + testUserDTO.setTermsAccepted(true); + testUserDTO.setEmailAddress(null); + testUserDTO.setUsername(null); + testUserDTO.setFirstName(null); + testUserDTO.setLastName(null); + + when(authServiceStub.verifyOIDCBearerTokenAndGetOAuth2UserRecord(TEST_BEARER_TOKEN)).thenReturn(oAuth2UserRecordStub); + + when(oAuth2UserRecordStub.getUsername()).thenReturn(TEST_USERNAME); + when(oAuth2UserRecordStub.getDisplayInfo()).thenReturn(TEST_VALID_DISPLAY_INFO); + + sut.execute(contextStub); + + verify(authServiceStub, times(1)).createAuthenticatedUser( + eq(userRecordIdentifierMock), + eq(TEST_USERNAME), + eq(new AuthenticatedUserDisplayInfo( + TEST_VALID_DISPLAY_INFO.getFirstName(), + TEST_VALID_DISPLAY_INFO.getLastName(), + TEST_VALID_DISPLAY_INFO.getEmailAddress(), + "", + "") + ), + eq(true) + ); + } } From 07794f39da45f1ea6b72a013f7a848ed1e1e3e5a Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 20 Nov 2024 14:47:13 +0000 Subject: [PATCH 061/101] Removed: unused OIDCUserInfo --- .../AuthenticationServiceBean.java | 2 +- .../dataverse/authorization/OIDCUserInfo.java | 33 ------------------- 2 files changed, 1 insertion(+), 34 deletions(-) delete mode 100644 src/main/java/edu/harvard/iq/dataverse/authorization/OIDCUserInfo.java diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java index f5c354defeb..032c1dd5164 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java @@ -996,7 +996,7 @@ public AuthenticatedUser lookupUserByOIDCBearerToken(String bearerToken) throws * Verifies the given OIDC bearer token and retrieves the corresponding OAuth2UserRecord. * * @param bearerToken The OIDC bearer token. - * @return An {@link OIDCUserInfo} containing the user's identifier and user info. + * @return An {@link OAuth2UserRecord} containing the user's info. * @throws AuthorizationException If the token is invalid or if no OIDC providers are available. */ public OAuth2UserRecord verifyOIDCBearerTokenAndGetOAuth2UserRecord(String bearerToken) throws AuthorizationException { diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/OIDCUserInfo.java b/src/main/java/edu/harvard/iq/dataverse/authorization/OIDCUserInfo.java deleted file mode 100644 index 8c4cf165f18..00000000000 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/OIDCUserInfo.java +++ /dev/null @@ -1,33 +0,0 @@ -package edu.harvard.iq.dataverse.authorization; - -import com.nimbusds.openid.connect.sdk.claims.UserInfo; - -/** - * Encapsulates both the user's identifier ({@link UserRecordIdentifier}) and the user's claims information - * ({@link UserInfo}) retrieved from an OIDC (OpenID Connect) bearer token. - *

- * This class serves as a container for both the {@link UserRecordIdentifier}, which uniquely identifies - * the user within the system, and the {@link UserInfo}, which holds the user's claims data provided by - * an OIDC provider. It simplifies the management of these related pieces of user data when handling - * OIDC token validation and authorization processes. - * - * @see UserRecordIdentifier - * @see UserInfo - */ -public class OIDCUserInfo { - private final UserRecordIdentifier userRecordIdentifier; - private final UserInfo userClaimsInfo; - - public OIDCUserInfo(UserRecordIdentifier userRecordIdentifier, UserInfo userClaimsInfo) { - this.userRecordIdentifier = userRecordIdentifier; - this.userClaimsInfo = userClaimsInfo; - } - - public UserRecordIdentifier getUserRecordIdentifier() { - return userRecordIdentifier; - } - - public UserInfo getUserClaimsInfo() { - return userClaimsInfo; - } -} From 7d88c8efb3db5ba22200d77a9a7620b4e5082967 Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 20 Nov 2024 14:53:39 +0000 Subject: [PATCH 062/101] Added: release notes for #10959 --- doc/release-notes/10959-oidc-api-auth-ext.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 doc/release-notes/10959-oidc-api-auth-ext.md diff --git a/doc/release-notes/10959-oidc-api-auth-ext.md b/doc/release-notes/10959-oidc-api-auth-ext.md new file mode 100644 index 00000000000..37c5003e960 --- /dev/null +++ b/doc/release-notes/10959-oidc-api-auth-ext.md @@ -0,0 +1,9 @@ +Extends the OIDC API auth mechanism (available through feature flag ``api-bearer-auth``) to properly handle cases +where ``BearerTokenAuthMechanism`` successfully validates the token but cannot identify any Dataverse user because there +is no account associated with the token. + +To register a new user who has authenticated via an OIDC provider, a new endpoint has been +implemented (``/users/register``). A feature flag called ``api-bearer-auth-json-claims`` has been implemented to allow +sending missing user claims in the request JSON. This is useful when the identity provider does not supply the necessary +claims. However, this flag will only be considered if the ``api-bearer-auth`` feature flag is enabled. If the latter is +not enabled, the ``api-bearer-auth-json-claims`` flag will be ignored. From ae58595361b24bb8dbb3372e25561e3999a7296b Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 20 Nov 2024 15:40:54 +0000 Subject: [PATCH 063/101] Removed: duplicated release notes doc --- doc/release-notes/10959-bearer-token-user-registration.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 doc/release-notes/10959-bearer-token-user-registration.md diff --git a/doc/release-notes/10959-bearer-token-user-registration.md b/doc/release-notes/10959-bearer-token-user-registration.md deleted file mode 100644 index 329db550cc9..00000000000 --- a/doc/release-notes/10959-bearer-token-user-registration.md +++ /dev/null @@ -1,5 +0,0 @@ -The OIDC Bearer token API authentication feature (available through a feature flag) has been extended to allow the registration of new users in Dataverse when there is no user account associated with the bearer token. - -Specifically, a new endpoint (users/register) has been implemented, to which the bearer token and new user account information are sent, allowing the identity provider user to be linked to a Dataverse account. - -In this way, the user will be recognized in future requests using the bearer token in the BearerTokenAuthMechanism. From f360b91096fae07fabece89059598d42ffd2a58f Mon Sep 17 00:00:00 2001 From: GPortas Date: Thu, 21 Nov 2024 09:54:48 +0000 Subject: [PATCH 064/101] Changed: checking when claim is blank in the provider in RegisterOIDCUserCommand --- .../command/impl/RegisterOIDCUserCommand.java | 4 +-- .../impl/RegisterOIDCUserCommandTest.java | 31 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java index 2c94a08b088..e3d861c2dbf 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java @@ -89,7 +89,7 @@ private Map validateConflictingClaims(OAuth2UserRecord oAuth2Use } private void addFieldErrorIfConflict(String fieldName, String claimValue, String existingValue, Map fieldErrors) { - if (claimValue != null && existingValue != null && !claimValue.equals(existingValue)) { + if (claimValue != null && !claimValue.trim().isEmpty() && existingValue != null && !claimValue.equals(existingValue)) { String errorMessage = BundleUtil.getStringFromBundle( "registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider", List.of(fieldName) @@ -123,7 +123,7 @@ private void throwInvalidFieldsCommandExceptionIfErrorsExist(Map } private String getValueOrDefault(String oidcValue, String dtoValue) { - return (oidcValue == null || oidcValue.isEmpty()) ? dtoValue : oidcValue; + return (oidcValue == null || oidcValue.trim().isEmpty()) ? dtoValue : oidcValue; } private void validateUserFields(CommandContext ctxt, boolean provideMissingClaimsEnabled) throws InvalidFieldsCommandException { diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java index a626e155336..990b11066e2 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java @@ -294,4 +294,35 @@ void execute_happyPath_withoutAffiliationAndPosition_claimsInProvider_provideMis eq(true) ); } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-provide-missing-claims") + void execute_happyPath_withoutAffiliationAndPosition_blankClaimInProviderProvidedInJson_provideMissingClaimsFeatureFlagEnabled() throws AuthorizationException, CommandException { + String testUsername = "usernameNotBlank"; + testUserDTO.setUsername(testUsername); + testUserDTO.setTermsAccepted(true); + testUserDTO.setEmailAddress(null); + testUserDTO.setFirstName(null); + testUserDTO.setLastName(null); + + when(authServiceStub.verifyOIDCBearerTokenAndGetOAuth2UserRecord(TEST_BEARER_TOKEN)).thenReturn(oAuth2UserRecordStub); + + when(oAuth2UserRecordStub.getUsername()).thenReturn(" "); + when(oAuth2UserRecordStub.getDisplayInfo()).thenReturn(TEST_VALID_DISPLAY_INFO); + + sut.execute(contextStub); + + verify(authServiceStub, times(1)).createAuthenticatedUser( + eq(userRecordIdentifierMock), + eq(testUsername), + eq(new AuthenticatedUserDisplayInfo( + TEST_VALID_DISPLAY_INFO.getFirstName(), + TEST_VALID_DISPLAY_INFO.getLastName(), + TEST_VALID_DISPLAY_INFO.getEmailAddress(), + "", + "") + ), + eq(true) + ); + } } From 16f8e04c2c2813df88b4f08a8caa74adac15bb26 Mon Sep 17 00:00:00 2001 From: GPortas Date: Thu, 21 Nov 2024 10:23:45 +0000 Subject: [PATCH 065/101] Added: validate user DTO has no claims when feature flag is disabled --- .../command/impl/RegisterOIDCUserCommand.java | 35 +++++++++++++++++++ src/main/java/propertyFiles/Bundle.properties | 1 + .../impl/RegisterOIDCUserCommandTest.java | 22 ++++++++++++ 3 files changed, 58 insertions(+) diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java index e3d861c2dbf..ad0bf4470d3 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java @@ -73,6 +73,8 @@ private void updateUserDTO(OAuth2UserRecord oAuth2UserRecord, boolean provideMis throwInvalidFieldsCommandExceptionIfErrorsExist(fieldErrors); updateUserDTOWithClaims(oAuth2UserRecord); } else { + Map fieldErrors = validateUserDTOHasNoClaims(); + throwInvalidFieldsCommandExceptionIfErrorsExist(fieldErrors); overwriteUserDTOWithClaims(oAuth2UserRecord); } } @@ -98,6 +100,39 @@ private void addFieldErrorIfConflict(String fieldName, String claimValue, String } } + private Map validateUserDTOHasNoClaims() { + Map fieldErrors = new HashMap<>(); + if (userDTO.getUsername() != null) { + String errorMessage = BundleUtil.getStringFromBundle( + "registerOidcUserCommand.errors.provideMissingClaimsDisabled.unableToSetFieldViaJSON", + List.of(FIELD_USERNAME) + ); + fieldErrors.put(FIELD_USERNAME, errorMessage); + } + if (userDTO.getEmailAddress() != null) { + String errorMessage = BundleUtil.getStringFromBundle( + "registerOidcUserCommand.errors.provideMissingClaimsDisabled.unableToSetFieldViaJSON", + List.of(FIELD_EMAIL_ADDRESS) + ); + fieldErrors.put(FIELD_EMAIL_ADDRESS, errorMessage); + } + if (userDTO.getFirstName() != null) { + String errorMessage = BundleUtil.getStringFromBundle( + "registerOidcUserCommand.errors.provideMissingClaimsDisabled.unableToSetFieldViaJSON", + List.of(FIELD_FIRST_NAME) + ); + fieldErrors.put(FIELD_FIRST_NAME, errorMessage); + } + if (userDTO.getLastName() != null) { + String errorMessage = BundleUtil.getStringFromBundle( + "registerOidcUserCommand.errors.provideMissingClaimsDisabled.unableToSetFieldViaJSON", + List.of(FIELD_LAST_NAME) + ); + fieldErrors.put(FIELD_LAST_NAME, errorMessage); + } + return fieldErrors; + } + private void updateUserDTOWithClaims(OAuth2UserRecord oAuth2UserRecord) { userDTO.setUsername(getValueOrDefault(oAuth2UserRecord.getUsername(), userDTO.getUsername())); userDTO.setFirstName(getValueOrDefault(oAuth2UserRecord.getDisplayInfo().getFirstName(), userDTO.getFirstName())); diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index 62b1c3ed3cd..16dd8e69f4a 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -3074,6 +3074,7 @@ registerOidcUserCommand.errors.invalidFields=The provided fields are invalid for registerOidcUserCommand.errors.userShouldAcceptTerms=Terms should be accepted. registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider=Unable to set {0} because it conflicts with an existing claim from the OIDC identity provider. registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldRequired=It is required to include the field {0} in the request JSON for registering the user. +registerOidcUserCommand.errors.provideMissingClaimsDisabled.unableToSetFieldViaJSON=Unable to set field {0} via JSON because the api-bearer-auth-json-claims feature flag is disabled. registerOidcUserCommand.errors.provideMissingClaimsDisabled.fieldRequired=The OIDC identity provider does not provide the user claim {0}, which is required for user registration. Please contact an administrator. registerOidcUserCommand.errors.emailAddressInUse=Email already in use. registerOidcUserCommand.errors.usernameInUse=Username already in use. diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java index 990b11066e2..c6b6e77d23e 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java @@ -91,6 +91,10 @@ private void setUpDefaultUserDTO() { @Test public void execute_completedUserDTOWithUnacceptedTerms_missingClaimsInProvider_provideMissingClaimsFeatureFlagDisabled() throws AuthorizationException { testUserDTO.setTermsAccepted(false); + testUserDTO.setEmailAddress(null); + testUserDTO.setUsername(null); + testUserDTO.setFirstName(null); + testUserDTO.setLastName(null); when(authServiceStub.getAuthenticatedUserByEmail(testUserDTO.getEmailAddress())).thenReturn(null); when(authServiceStub.getAuthenticatedUser(testUserDTO.getUsername())).thenReturn(null); @@ -188,6 +192,24 @@ void execute_throwsIllegalCommandException_ifUserAlreadyRegisteredWithToken() th verify(contextStub.authentication(), times(1)).lookupUser(userRecordIdentifierMock); } + @Test + void execute_throwsInvalidFieldsCommandException_ifUserDTOHasClaimsAndProvideMissingClaimsFeatureFlagIsDisabled() throws AuthorizationException { + when(contextStub.authentication().verifyOIDCBearerTokenAndGetOAuth2UserRecord(TEST_BEARER_TOKEN)) + .thenReturn(oAuth2UserRecordStub); + + assertThatThrownBy(() -> sut.execute(contextStub)) + .isInstanceOf(InvalidFieldsCommandException.class) + .satisfies(exception -> { + InvalidFieldsCommandException ex = (InvalidFieldsCommandException) exception; + assertThat(ex.getFieldErrors()) + .containsEntry("username", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.unableToSetFieldViaJSON", List.of("username"))) + .containsEntry("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.unableToSetFieldViaJSON", List.of("emailAddress"))) + .containsEntry("firstName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.unableToSetFieldViaJSON", List.of("firstName"))) + .containsEntry("lastName", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.unableToSetFieldViaJSON", List.of("lastName"))) + .containsEntry("emailAddress", BundleUtil.getStringFromBundle("registerOidcUserCommand.errors.provideMissingClaimsDisabled.unableToSetFieldViaJSON", List.of("emailAddress"))); + }); + } + @Test @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-provide-missing-claims") void execute_happyPath_withoutAffiliationAndPosition_missingClaimsInProvider_provideMissingClaimsFeatureFlagEnabled() throws AuthorizationException, CommandException { From cc99a8b338c09b40ba938bbd03239a311c1ca54b Mon Sep 17 00:00:00 2001 From: GPortas Date: Thu, 21 Nov 2024 10:31:28 +0000 Subject: [PATCH 066/101] Added: test case to RegisterOIDCUserCommandTest for blank claim values --- .../command/impl/RegisterOIDCUserCommandTest.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java index c6b6e77d23e..934d4296f09 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java @@ -18,6 +18,8 @@ import edu.harvard.iq.dataverse.util.testing.LocalJvmSettings; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -317,11 +319,12 @@ void execute_happyPath_withoutAffiliationAndPosition_claimsInProvider_provideMis ); } - @Test + @ParameterizedTest + @ValueSource(strings = {" ", ""}) @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-provide-missing-claims") - void execute_happyPath_withoutAffiliationAndPosition_blankClaimInProviderProvidedInJson_provideMissingClaimsFeatureFlagEnabled() throws AuthorizationException, CommandException { - String testUsername = "usernameNotBlank"; - testUserDTO.setUsername(testUsername); + void execute_happyPath_withoutAffiliationAndPosition_blankClaimInProviderProvidedInJson_provideMissingClaimsFeatureFlagEnabled(String testBlankUsername) throws AuthorizationException, CommandException { + String testUsernameNotBlank = "usernameNotBlank"; + testUserDTO.setUsername(testUsernameNotBlank); testUserDTO.setTermsAccepted(true); testUserDTO.setEmailAddress(null); testUserDTO.setFirstName(null); @@ -329,14 +332,14 @@ void execute_happyPath_withoutAffiliationAndPosition_blankClaimInProviderProvide when(authServiceStub.verifyOIDCBearerTokenAndGetOAuth2UserRecord(TEST_BEARER_TOKEN)).thenReturn(oAuth2UserRecordStub); - when(oAuth2UserRecordStub.getUsername()).thenReturn(" "); + when(oAuth2UserRecordStub.getUsername()).thenReturn(testBlankUsername); when(oAuth2UserRecordStub.getDisplayInfo()).thenReturn(TEST_VALID_DISPLAY_INFO); sut.execute(contextStub); verify(authServiceStub, times(1)).createAuthenticatedUser( eq(userRecordIdentifierMock), - eq(testUsername), + eq(testUsernameNotBlank), eq(new AuthenticatedUserDisplayInfo( TEST_VALID_DISPLAY_INFO.getFirstName(), TEST_VALID_DISPLAY_INFO.getLastName(), From 4853f415eaceac487256cc089b4e22b837653210 Mon Sep 17 00:00:00 2001 From: Jim Myers Date: Fri, 22 Nov 2024 18:17:54 -0500 Subject: [PATCH 067/101] Text changes to address #11046 --- src/main/java/propertyFiles/Bundle.properties | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index ece675fce0d..1d33ad9e934 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -1734,15 +1734,13 @@ dataset.privateurl.general.title=General Preview dataset.privateurl.anonymous.title=Anonymous Preview dataset.privateurl.anonymous.button.label=Create Anonymous Preview URL dataset.privateurl.anonymous.description=Create a URL that others can use to access an anonymized view of this unpublished dataset version. Metadata that could identify the dataset author will not be displayed. Non-identifying metadata will be visible. -dataset.privateurl.anonymous.description.paragraph.two=The dataset's files are not changed and will be accessible if they're not restricted. Users of the Anonymous Preview URL will not be able to see the name of the Dataverse that this dataset is in but will be able to see the name of the repository, which might expose the dataset authors' identities. +dataset.privateurl.anonymous.description.paragraph.two=The dataset's files are not changed and users of the Anonymous Preview URL will be able to access them. Users of the Anonymous Preview URL will not be able to see the name of the Dataverse that this dataset is in but will be able to see the name of the repository, which might expose the dataset authors' identities. dataset.privateurl.createPrivateUrl=Create Preview URL dataset.privateurl.introduction=You can create a Preview URL to copy and share with others who will not need a repository account to review this unpublished dataset version. Once the dataset is published or if the URL is disabled, the URL will no longer work and will point to a "Page not found" page. dataset.privateurl.createPrivateUrl.anonymized=Create URL for Anonymized Access dataset.privateurl.createPrivateUrl.anonymized.unavailable=Anonymized Access is not available once a version of the dataset has been published -dataset.privateurl.disablePrivateUrl=Disable Preview URL dataset.privateurl.disableGeneralPreviewUrl=Disable General Preview URL dataset.privateurl.disableAnonPreviewUrl=Disable Anonymous Preview URL -dataset.privateurl.disablePrivateUrlConfirm=Yes, Disable Preview URL dataset.privateurl.disableGeneralPreviewUrlConfirm=Yes, Disable General Preview URL dataset.privateurl.disableAnonPreviewUrlConfirm=Yes, Disable Anonymous Preview URL dataset.privateurl.disableConfirmationText=Are you sure you want to disable the Preview URL? If you have shared the Preview URL with others they will no longer be able to use it to access your unpublished dataset. From d7cb845b8d549c0f51a23404a839525e90cfc254 Mon Sep 17 00:00:00 2001 From: Jim Myers Date: Wed, 20 Nov 2024 15:11:39 -0500 Subject: [PATCH 068/101] actions/checkout 2->3 --- .github/workflows/guides_build_sphinx.yml | 2 +- .github/workflows/reviewdog_checkstyle.yml | 2 +- .github/workflows/shellspec.yml | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/guides_build_sphinx.yml b/.github/workflows/guides_build_sphinx.yml index 86b59b11d35..50ca14d3f1b 100644 --- a/.github/workflows/guides_build_sphinx.yml +++ b/.github/workflows/guides_build_sphinx.yml @@ -10,7 +10,7 @@ jobs: docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - uses: uncch-rdmc/sphinx-action@master with: docs-folder: "doc/sphinx-guides/" diff --git a/.github/workflows/reviewdog_checkstyle.yml b/.github/workflows/reviewdog_checkstyle.yml index 90a0dd7d06b..637691f8b16 100644 --- a/.github/workflows/reviewdog_checkstyle.yml +++ b/.github/workflows/reviewdog_checkstyle.yml @@ -10,7 +10,7 @@ jobs: name: Checkstyle job steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Run check style uses: nikitasavinov/checkstyle-action@master with: diff --git a/.github/workflows/shellspec.yml b/.github/workflows/shellspec.yml index 3320d9d08a4..2c73259b978 100644 --- a/.github/workflows/shellspec.yml +++ b/.github/workflows/shellspec.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Install shellspec run: curl -fsSL https://git.io/shellspec | sh -s ${{ env.SHELLSPEC_VERSION }} --yes - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Run Shellspec run: | cd tests/shell @@ -30,7 +30,7 @@ jobs: container: image: rockylinux/rockylinux:9 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Install shellspec run: | curl -fsSL https://github.com/shellspec/shellspec/releases/download/${{ env.SHELLSPEC_VERSION }}/shellspec-dist.tar.gz | tar -xz -C /usr/share @@ -47,7 +47,7 @@ jobs: steps: - name: Install shellspec run: curl -fsSL https://git.io/shellspec | sh -s 0.28.1 --yes - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Run Shellspec run: | cd tests/shell From 60698157abb2a1307e77f172f773eede54ebceef Mon Sep 17 00:00:00 2001 From: Jim Myers Date: Mon, 25 Nov 2024 10:28:38 -0500 Subject: [PATCH 069/101] actions @v3 -> @v4 --- .github/workflows/container_app_pr.yml | 2 +- .github/workflows/container_app_push.yml | 2 +- .github/workflows/guides_build_sphinx.yml | 2 +- .github/workflows/reviewdog_checkstyle.yml | 2 +- .github/workflows/shellcheck.yml | 2 +- .github/workflows/shellspec.yml | 6 +++--- .github/workflows/spi_release.yml | 10 +++++----- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/container_app_pr.yml b/.github/workflows/container_app_pr.yml index c86d284e74b..4130506ba36 100644 --- a/.github/workflows/container_app_pr.yml +++ b/.github/workflows/container_app_pr.yml @@ -20,7 +20,7 @@ jobs: if: ${{ github.repository_owner == 'IQSS' }} steps: # Checkout the pull request code as when merged - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: ref: 'refs/pull/${{ github.event.client_payload.pull_request.number }}/merge' - uses: actions/setup-java@v3 diff --git a/.github/workflows/container_app_push.yml b/.github/workflows/container_app_push.yml index 3b7ce066d73..184b69583a5 100644 --- a/.github/workflows/container_app_push.yml +++ b/.github/workflows/container_app_push.yml @@ -68,7 +68,7 @@ jobs: if: ${{ github.event_name != 'pull_request' && github.ref_name == 'develop' && github.repository_owner == 'IQSS' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: peter-evans/dockerhub-description@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} diff --git a/.github/workflows/guides_build_sphinx.yml b/.github/workflows/guides_build_sphinx.yml index 50ca14d3f1b..fa3a876c418 100644 --- a/.github/workflows/guides_build_sphinx.yml +++ b/.github/workflows/guides_build_sphinx.yml @@ -10,7 +10,7 @@ jobs: docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: uncch-rdmc/sphinx-action@master with: docs-folder: "doc/sphinx-guides/" diff --git a/.github/workflows/reviewdog_checkstyle.yml b/.github/workflows/reviewdog_checkstyle.yml index 637691f8b16..804b04f696a 100644 --- a/.github/workflows/reviewdog_checkstyle.yml +++ b/.github/workflows/reviewdog_checkstyle.yml @@ -10,7 +10,7 @@ jobs: name: Checkstyle job steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Run check style uses: nikitasavinov/checkstyle-action@master with: diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index 56f7d648dc4..fb9cf5a0a1f 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -21,7 +21,7 @@ jobs: permissions: pull-requests: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: shellcheck uses: reviewdog/action-shellcheck@v1 with: diff --git a/.github/workflows/shellspec.yml b/.github/workflows/shellspec.yml index 2c73259b978..cc09992edac 100644 --- a/.github/workflows/shellspec.yml +++ b/.github/workflows/shellspec.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Install shellspec run: curl -fsSL https://git.io/shellspec | sh -s ${{ env.SHELLSPEC_VERSION }} --yes - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Run Shellspec run: | cd tests/shell @@ -30,7 +30,7 @@ jobs: container: image: rockylinux/rockylinux:9 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Install shellspec run: | curl -fsSL https://github.com/shellspec/shellspec/releases/download/${{ env.SHELLSPEC_VERSION }}/shellspec-dist.tar.gz | tar -xz -C /usr/share @@ -47,7 +47,7 @@ jobs: steps: - name: Install shellspec run: curl -fsSL https://git.io/shellspec | sh -s 0.28.1 --yes - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Run Shellspec run: | cd tests/shell diff --git a/.github/workflows/spi_release.yml b/.github/workflows/spi_release.yml index 8ad74b3e4bb..54718320d1e 100644 --- a/.github/workflows/spi_release.yml +++ b/.github/workflows/spi_release.yml @@ -37,8 +37,8 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'pull_request' && needs.check-secrets.outputs.available == 'true' steps: - - uses: actions/checkout@v3 - - uses: actions/setup-java@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 with: java-version: '17' distribution: 'adopt' @@ -63,8 +63,8 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'push' && needs.check-secrets.outputs.available == 'true' steps: - - uses: actions/checkout@v3 - - uses: actions/setup-java@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 with: java-version: '17' distribution: 'adopt' @@ -76,7 +76,7 @@ jobs: # Running setup-java again overwrites the settings.xml - IT'S MANDATORY TO DO THIS SECOND SETUP!!! - name: Set up Maven Central Repository - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: '17' distribution: 'adopt' From 514264394e0e56e7252477ed8c538d0a56978d9e Mon Sep 17 00:00:00 2001 From: Jim Myers Date: Mon, 25 Nov 2024 10:45:58 -0500 Subject: [PATCH 070/101] more @3->@4 --- .github/workflows/container_app_pr.yml | 6 +++--- .github/workflows/container_app_push.yml | 4 ++-- .github/workflows/pr_comment_commands.yml | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/container_app_pr.yml b/.github/workflows/container_app_pr.yml index 4130506ba36..c3f9e7bdc0d 100644 --- a/.github/workflows/container_app_pr.yml +++ b/.github/workflows/container_app_pr.yml @@ -23,11 +23,11 @@ jobs: - uses: actions/checkout@v4 with: ref: 'refs/pull/${{ github.event.client_payload.pull_request.number }}/merge' - - uses: actions/setup-java@v3 + - uses: actions/setup-java@v4 with: java-version: "17" distribution: 'adopt' - - uses: actions/cache@v3 + - uses: actions/cache@v4 with: path: ~/.m2 key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} @@ -87,7 +87,7 @@ jobs: :ship: [See on GHCR](https://github.com/orgs/gdcc/packages/container). Use by referencing with full name as printed above, mind the registry name. # Leave a note when things have gone sideways - - uses: peter-evans/create-or-update-comment@v3 + - uses: peter-evans/create-or-update-comment@v4 if: ${{ failure() }} with: issue-number: ${{ github.event.client_payload.pull_request.number }} diff --git a/.github/workflows/container_app_push.yml b/.github/workflows/container_app_push.yml index 184b69583a5..afb4f6f874b 100644 --- a/.github/workflows/container_app_push.yml +++ b/.github/workflows/container_app_push.yml @@ -69,14 +69,14 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: peter-evans/dockerhub-description@v3 + - uses: peter-evans/dockerhub-description@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} repository: gdcc/dataverse short-description: "Dataverse Application Container Image providing the executable" readme-filepath: ./src/main/docker/README.md - - uses: peter-evans/dockerhub-description@v3 + - uses: peter-evans/dockerhub-description@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/pr_comment_commands.yml b/.github/workflows/pr_comment_commands.yml index 5ff75def623..06b11b1ac5b 100644 --- a/.github/workflows/pr_comment_commands.yml +++ b/.github/workflows/pr_comment_commands.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Dispatch - uses: peter-evans/slash-command-dispatch@v3 + uses: peter-evans/slash-command-dispatch@v4 with: # This token belongs to @dataversebot and has sufficient scope. token: ${{ secrets.GHCR_TOKEN }} From 1066b1e8252120c9150e020abf7d6c9be2feef56 Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Tue, 26 Nov 2024 13:37:24 -0500 Subject: [PATCH 071/101] fix search files to return latest published citation --- ...set-name-and-dataset-citation-different.md | 4 ++ .../iq/dataverse/search/IndexServiceBean.java | 3 +- .../harvard/iq/dataverse/api/SearchIT.java | 48 +++++++++++++++---- 3 files changed, 45 insertions(+), 10 deletions(-) create mode 100644 doc/release-notes/10735-search-dataset-name-and-dataset-citation-different.md diff --git a/doc/release-notes/10735-search-dataset-name-and-dataset-citation-different.md b/doc/release-notes/10735-search-dataset-name-and-dataset-citation-different.md new file mode 100644 index 00000000000..6a6b2008772 --- /dev/null +++ b/doc/release-notes/10735-search-dataset-name-and-dataset-citation-different.md @@ -0,0 +1,4 @@ + +### Search files Bug fix + +dataset-citation was displaying DRAFT version instead of latest released version diff --git a/src/main/java/edu/harvard/iq/dataverse/search/IndexServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/search/IndexServiceBean.java index f72973076ec..ea9e8ba0506 100644 --- a/src/main/java/edu/harvard/iq/dataverse/search/IndexServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/search/IndexServiceBean.java @@ -1328,7 +1328,8 @@ public SolrInputDocuments toSolrDocs(IndexableDataset indexableDataset, Set Date: Tue, 3 Dec 2024 13:17:54 -0300 Subject: [PATCH 072/101] chore: update docs --- doc/release-notes/10959-oidc-api-auth-ext.md | 4 ++-- doc/sphinx-guides/source/api/auth.rst | 2 +- src/main/java/propertyFiles/Bundle.properties | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/release-notes/10959-oidc-api-auth-ext.md b/doc/release-notes/10959-oidc-api-auth-ext.md index 37c5003e960..e135fcccfd1 100644 --- a/doc/release-notes/10959-oidc-api-auth-ext.md +++ b/doc/release-notes/10959-oidc-api-auth-ext.md @@ -3,7 +3,7 @@ where ``BearerTokenAuthMechanism`` successfully validates the token but cannot i is no account associated with the token. To register a new user who has authenticated via an OIDC provider, a new endpoint has been -implemented (``/users/register``). A feature flag called ``api-bearer-auth-json-claims`` has been implemented to allow +implemented (``/users/register``). A feature flag called ``api-bearer-auth-provide-missing-claims`` has been implemented to allow sending missing user claims in the request JSON. This is useful when the identity provider does not supply the necessary claims. However, this flag will only be considered if the ``api-bearer-auth`` feature flag is enabled. If the latter is -not enabled, the ``api-bearer-auth-json-claims`` flag will be ignored. +not enabled, the ``api-bearer-auth-provide-missing-claims`` flag will be ignored. diff --git a/doc/sphinx-guides/source/api/auth.rst b/doc/sphinx-guides/source/api/auth.rst index 51234ad08bc..2784703ddae 100644 --- a/doc/sphinx-guides/source/api/auth.rst +++ b/doc/sphinx-guides/source/api/auth.rst @@ -91,7 +91,7 @@ It is essential to send a JSON that includes the property ``termsAccepted`` set In this JSON, we can also include the fields ``position`` or ``affiliation``, in the same way as when we register a user through the Dataverse UI. These fields are optional, and if not provided, they will be persisted as empty in Dataverse. -Beyond the ``api-bearer-auth`` feature flag, there is another flag called ``api-bearer-auth-json-claims`` that can be enabled to allow sending missing user claims in the registration JSON. This is useful when the identity provider does not supply the necessary claims. However, this flag will only be considered if the ``api-bearer-auth`` feature flag is enabled. If the latter is not enabled, the ``api-bearer-auth-json-claims`` flag will be ignored. +Beyond the ``api-bearer-auth`` feature flag, there is another flag called ``api-bearer-auth-provide-missing-claims`` that can be enabled to allow sending missing user claims in the registration JSON. This is useful when the identity provider does not supply the necessary claims. However, this flag will only be considered if the ``api-bearer-auth`` feature flag is enabled. If the latter is not enabled, the ``api-bearer-auth-provide-missing-claims`` flag will be ignored. With the ``api-bearer-auth`` feature flag enabled, you can include the following properties in the request JSON: diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index 16dd8e69f4a..13364904ab0 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -3074,7 +3074,7 @@ registerOidcUserCommand.errors.invalidFields=The provided fields are invalid for registerOidcUserCommand.errors.userShouldAcceptTerms=Terms should be accepted. registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldAlreadyPresentInProvider=Unable to set {0} because it conflicts with an existing claim from the OIDC identity provider. registerOidcUserCommand.errors.provideMissingClaimsEnabled.fieldRequired=It is required to include the field {0} in the request JSON for registering the user. -registerOidcUserCommand.errors.provideMissingClaimsDisabled.unableToSetFieldViaJSON=Unable to set field {0} via JSON because the api-bearer-auth-json-claims feature flag is disabled. +registerOidcUserCommand.errors.provideMissingClaimsDisabled.unableToSetFieldViaJSON=Unable to set field {0} via JSON because the api-bearer-auth-provide-missing-claims feature flag is disabled. registerOidcUserCommand.errors.provideMissingClaimsDisabled.fieldRequired=The OIDC identity provider does not provide the user claim {0}, which is required for user registration. Please contact an administrator. registerOidcUserCommand.errors.emailAddressInUse=Email already in use. registerOidcUserCommand.errors.usernameInUse=Username already in use. From f9fd4c83b661bf3b156de09086cfc5aa613991c8 Mon Sep 17 00:00:00 2001 From: Victoria Lubitch Date: Wed, 4 Dec 2024 17:19:21 -0500 Subject: [PATCH 073/101] Terms of use --- .../dataverse/export/ddi/DdiExportUtil.java | 29 +++++++++++++++---- .../dataverse/export/ddi/dataset-finch1.xml | 1 + .../iq/dataverse/export/ddi/exportfull.xml | 1 + 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/export/ddi/DdiExportUtil.java b/src/main/java/edu/harvard/iq/dataverse/export/ddi/DdiExportUtil.java index 05ddbe83e78..c48c6e5114b 100644 --- a/src/main/java/edu/harvard/iq/dataverse/export/ddi/DdiExportUtil.java +++ b/src/main/java/edu/harvard/iq/dataverse/export/ddi/DdiExportUtil.java @@ -5,11 +5,7 @@ import edu.harvard.iq.dataverse.ControlledVocabularyValue; import edu.harvard.iq.dataverse.DatasetFieldConstant; import edu.harvard.iq.dataverse.DvObjectContainer; -import edu.harvard.iq.dataverse.api.dto.DatasetDTO; -import edu.harvard.iq.dataverse.api.dto.DatasetVersionDTO; -import edu.harvard.iq.dataverse.api.dto.FieldDTO; -import edu.harvard.iq.dataverse.api.dto.FileDTO; -import edu.harvard.iq.dataverse.api.dto.MetadataBlockDTO; +import edu.harvard.iq.dataverse.api.dto.*; import static edu.harvard.iq.dataverse.export.DDIExportServiceBean.LEVEL_FILE; import static edu.harvard.iq.dataverse.export.DDIExportServiceBean.NOTE_SUBJECT_TAG; @@ -313,8 +309,16 @@ private static void writeDataAccess(XMLStreamWriter xmlw , DatasetVersionDTO ver XmlWriterUtil.writeFullElement(xmlw, "conditions", version.getConditions()); XmlWriterUtil.writeFullElement(xmlw, "disclaimer", version.getDisclaimer()); xmlw.writeEndElement(); //useStmt - + /* any s: */ + if (version.getTermsOfUse() != null && !version.getTermsOfUse().trim().equals("")) { + xmlw.writeStartElement("notes"); + xmlw.writeAttribute("type", NOTE_TYPE_TERMS_OF_USE); + xmlw.writeAttribute("level", LEVEL_DV); + xmlw.writeCharacters(version.getTermsOfUse()); + xmlw.writeEndElement(); //notes + } + if (version.getTermsOfAccess() != null && !version.getTermsOfAccess().trim().equals("")) { xmlw.writeStartElement("notes"); xmlw.writeAttribute("type", NOTE_TYPE_TERMS_OF_ACCESS); @@ -322,6 +326,19 @@ private static void writeDataAccess(XMLStreamWriter xmlw , DatasetVersionDTO ver xmlw.writeCharacters(version.getTermsOfAccess()); xmlw.writeEndElement(); //notes } + + LicenseDTO license = version.getLicense(); + if (license != null) { + String name = license.getName(); + String uri = license.getUri(); + if ((name != null && !name.trim().equals("")) && (uri != null && !uri.trim().equals(""))) { + xmlw.writeStartElement("notes"); + xmlw.writeAttribute("type", NOTE_TYPE_TERMS_OF_USE); + xmlw.writeAttribute("level", LEVEL_DV); + xmlw.writeCharacters("" + name + ""); + xmlw.writeEndElement(); //notes + } + } xmlw.writeEndElement(); //dataAccs } diff --git a/src/test/java/edu/harvard/iq/dataverse/export/ddi/dataset-finch1.xml b/src/test/java/edu/harvard/iq/dataverse/export/ddi/dataset-finch1.xml index 6730c44603a..010a5db4f2b 100644 --- a/src/test/java/edu/harvard/iq/dataverse/export/ddi/dataset-finch1.xml +++ b/src/test/java/edu/harvard/iq/dataverse/export/ddi/dataset-finch1.xml @@ -69,6 +69,7 @@ + <a href="http://creativecommons.org/publicdomain/zero/1.0">CC0 1.0</a> diff --git a/src/test/java/edu/harvard/iq/dataverse/export/ddi/exportfull.xml b/src/test/java/edu/harvard/iq/dataverse/export/ddi/exportfull.xml index 507d752192d..e865dc0ffe4 100644 --- a/src/test/java/edu/harvard/iq/dataverse/export/ddi/exportfull.xml +++ b/src/test/java/edu/harvard/iq/dataverse/export/ddi/exportfull.xml @@ -161,6 +161,7 @@ Disclaimer Terms of Access + <a href="http://creativecommons.org/publicdomain/zero/1.0">CC0 1.0</a> RelatedMaterial1 From 7b2caaebef377f8d6a84f9ca0ffd38efa9af96d1 Mon Sep 17 00:00:00 2001 From: Victoria Lubitch Date: Fri, 6 Dec 2024 15:13:44 -0500 Subject: [PATCH 074/101] Import DDI with license --- .../api/imports/ImportDDIServiceBean.java | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/imports/ImportDDIServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/api/imports/ImportDDIServiceBean.java index 35d35316f73..41df3b09500 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/imports/ImportDDIServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/imports/ImportDDIServiceBean.java @@ -5,13 +5,21 @@ import edu.harvard.iq.dataverse.DatasetFieldType; import edu.harvard.iq.dataverse.DatasetVersion; import edu.harvard.iq.dataverse.DatasetVersion.VersionState; -import edu.harvard.iq.dataverse.api.dto.*; +import edu.harvard.iq.dataverse.api.dto.LicenseDTO; import edu.harvard.iq.dataverse.api.dto.FieldDTO; import edu.harvard.iq.dataverse.api.dto.MetadataBlockDTO; +import edu.harvard.iq.dataverse.api.dto.DatasetDTO; +import edu.harvard.iq.dataverse.api.dto.DatasetVersionDTO; +import edu.harvard.iq.dataverse.api.dto.FileMetadataDTO; +import edu.harvard.iq.dataverse.api.dto.DataFileDTO; +import edu.harvard.iq.dataverse.api.dto.DataTableDTO; + import edu.harvard.iq.dataverse.api.imports.ImportUtil.ImportType; import static edu.harvard.iq.dataverse.export.ddi.DdiExportUtil.NOTE_TYPE_CONTENTTYPE; import static edu.harvard.iq.dataverse.export.ddi.DdiExportUtil.NOTE_TYPE_TERMS_OF_ACCESS; +import edu.harvard.iq.dataverse.license.License; +import edu.harvard.iq.dataverse.license.LicenseServiceBean; import edu.harvard.iq.dataverse.util.StringUtil; import java.io.File; import java.io.FileInputStream; @@ -32,6 +40,9 @@ import org.apache.commons.lang3.StringUtils; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + /** * * @author ellenk @@ -103,6 +114,8 @@ public class ImportDDIServiceBean { @EJB DatasetFieldServiceBean datasetFieldService; @EJB ImportGenericServiceBean importGenericService; + + @EJB LicenseServiceBean licenseService; // TODO: stop passing the xml source as a string; (it could be huge!) -- L.A. 4.5 @@ -1180,7 +1193,24 @@ private void processDataAccs(XMLStreamReader xmlr, DatasetVersionDTO dvDTO) thro String noteType = xmlr.getAttributeValue(null, "type"); if (NOTE_TYPE_TERMS_OF_USE.equalsIgnoreCase(noteType) ) { if ( LEVEL_DV.equalsIgnoreCase(xmlr.getAttributeValue(null, "level"))) { - dvDTO.setTermsOfUse(parseText(xmlr, "notes")); + String termsOfUseStr = parseText(xmlr, "notes").trim(); + Pattern pattern = Pattern.compile("(.*)", Pattern.CASE_INSENSITIVE); + Matcher matcher = pattern.matcher(termsOfUseStr); + boolean matchFound = matcher.find(); + if (matchFound) { + String uri = matcher.group(1); + String license = matcher.group(2); + License lic = licenseService.getByNameOrUri(license); + if (lic != null) { + LicenseDTO licenseDTO = new LicenseDTO(); + licenseDTO.setName(license); + licenseDTO.setName(uri); + dvDTO.setLicense(licenseDTO); + } + + } else { + dvDTO.setTermsOfUse(termsOfUseStr); + } } } else if (NOTE_TYPE_TERMS_OF_ACCESS.equalsIgnoreCase(noteType) ) { if (LEVEL_DV.equalsIgnoreCase(xmlr.getAttributeValue(null, "level"))) { From 4c28092822ade6554b910f32f580b2be61447ad8 Mon Sep 17 00:00:00 2001 From: Victoria Lubitch Date: Fri, 6 Dec 2024 15:21:07 -0500 Subject: [PATCH 075/101] import DTO --- .../harvard/iq/dataverse/export/ddi/DdiExportUtil.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/export/ddi/DdiExportUtil.java b/src/main/java/edu/harvard/iq/dataverse/export/ddi/DdiExportUtil.java index c48c6e5114b..8fab6a6704d 100644 --- a/src/main/java/edu/harvard/iq/dataverse/export/ddi/DdiExportUtil.java +++ b/src/main/java/edu/harvard/iq/dataverse/export/ddi/DdiExportUtil.java @@ -5,7 +5,13 @@ import edu.harvard.iq.dataverse.ControlledVocabularyValue; import edu.harvard.iq.dataverse.DatasetFieldConstant; import edu.harvard.iq.dataverse.DvObjectContainer; -import edu.harvard.iq.dataverse.api.dto.*; +import edu.harvard.iq.dataverse.api.dto.MetadataBlockDTO; +import edu.harvard.iq.dataverse.api.dto.DatasetDTO; +import edu.harvard.iq.dataverse.api.dto.DatasetVersionDTO; +import edu.harvard.iq.dataverse.api.dto.FileDTO; +import edu.harvard.iq.dataverse.api.dto.FieldDTO; +import edu.harvard.iq.dataverse.api.dto.LicenseDTO; + import static edu.harvard.iq.dataverse.export.DDIExportServiceBean.LEVEL_FILE; import static edu.harvard.iq.dataverse.export.DDIExportServiceBean.NOTE_SUBJECT_TAG; From 1859aeb6260be4cf68492a4043208aa3ba19aa9e Mon Sep 17 00:00:00 2001 From: Victoria Lubitch Date: Fri, 6 Dec 2024 15:44:07 -0500 Subject: [PATCH 076/101] uri --- .../harvard/iq/dataverse/api/imports/ImportDDIServiceBean.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/imports/ImportDDIServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/api/imports/ImportDDIServiceBean.java index 41df3b09500..31941d3c8c0 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/imports/ImportDDIServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/imports/ImportDDIServiceBean.java @@ -1204,7 +1204,7 @@ private void processDataAccs(XMLStreamReader xmlr, DatasetVersionDTO dvDTO) thro if (lic != null) { LicenseDTO licenseDTO = new LicenseDTO(); licenseDTO.setName(license); - licenseDTO.setName(uri); + licenseDTO.setUri(uri); dvDTO.setLicense(licenseDTO); } From 2dd997c73cd7ebeae0a5f745958ea7fa681458e8 Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:11:00 -0500 Subject: [PATCH 077/101] include total_count_per_object_type in search response --- ...xtend-search-api-to-include-type-counts.md | 1 + .../edu/harvard/iq/dataverse/api/Search.java | 11 ++++ .../harvard/iq/dataverse/api/SearchIT.java | 61 +++++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 doc/release-notes/ 11065-extend-search-api-to-include-type-counts.md diff --git a/doc/release-notes/ 11065-extend-search-api-to-include-type-counts.md b/doc/release-notes/ 11065-extend-search-api-to-include-type-counts.md new file mode 100644 index 00000000000..0ba188c8637 --- /dev/null +++ b/doc/release-notes/ 11065-extend-search-api-to-include-type-counts.md @@ -0,0 +1 @@ +The JSON payload of the search endpoint has been extended to include total_count_per_object_type for types: dataverse, dataset, and files when the search parameter "&show_type_counts=true" is passed in. diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Search.java b/src/main/java/edu/harvard/iq/dataverse/api/Search.java index f86f9f446fa..94a41cdb042 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Search.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Search.java @@ -19,6 +19,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.logging.Logger; @@ -73,6 +74,7 @@ public Response search( @QueryParam("metadata_fields") List metadataFields, @QueryParam("geo_point") String geoPointRequested, @QueryParam("geo_radius") String geoRadiusRequested, + @QueryParam("show_type_counts") boolean showTypeCounts, @Context HttpServletResponse response ) { @@ -172,9 +174,13 @@ public Response search( return error(Response.Status.INTERNAL_SERVER_ERROR, message); } + Map itemCountByType = new HashMap<>(); JsonArrayBuilder itemsArrayBuilder = Json.createArrayBuilder(); List solrSearchResults = solrQueryResponse.getSolrSearchResults(); for (SolrSearchResult solrSearchResult : solrSearchResults) { + if (showTypeCounts) { + itemCountByType.merge(solrSearchResult.getType(), 1, Integer::sum); + } itemsArrayBuilder.add(solrSearchResult.json(showRelevance, showEntityIds, showApiUrls, metadataFields)); } @@ -210,6 +216,11 @@ public Response search( } value.add("count_in_response", solrSearchResults.size()); + if (showTypeCounts && !itemCountByType.isEmpty()) { + JsonObjectBuilder objectTypeCounts = Json.createObjectBuilder(); + itemCountByType.forEach((k,v) -> objectTypeCounts.add(k,v)); + value.add("total_count_per_object_type", objectTypeCounts); + } /** * @todo Returning the fq might be useful as a troubleshooting aid * but we don't want to expose the raw dataverse database ids in diff --git a/src/test/java/edu/harvard/iq/dataverse/api/SearchIT.java b/src/test/java/edu/harvard/iq/dataverse/api/SearchIT.java index b03c23cd1e2..88d93ef262b 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/SearchIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/SearchIT.java @@ -1347,4 +1347,65 @@ public void testSearchFilesAndUrlImages() { .body("data.items[0].url", CoreMatchers.containsString("/datafile/")) .body("data.items[0]", CoreMatchers.not(CoreMatchers.hasItem("image_url"))); } + + @Test + public void testShowTypeCounts() { + //Create 1 user and 1 Dataverse/Collection + Response createUser = UtilIT.createRandomUser(); + String username = UtilIT.getUsernameFromResponse(createUser); + String apiToken = UtilIT.getApiTokenFromResponse(createUser); + String affiliation = "testAffiliation"; + + // test total_count_per_object_type is not included because the results are empty + Response searchResp = UtilIT.search(username, apiToken, "&show_type_counts=true"); + searchResp.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.total_count_per_object_type", CoreMatchers.equalTo(null)); + + Response createDataverseResponse = UtilIT.createRandomDataverse(apiToken, affiliation); + assertEquals(201, createDataverseResponse.getStatusCode()); + String dataverseAlias = UtilIT.getAliasFromResponse(createDataverseResponse); + + // create 3 Datasets, each with 2 Datafiles + for (int i = 0; i < 3; i++) { + Response createDatasetResponse = UtilIT.createRandomDatasetViaNativeApi(dataverseAlias, apiToken); + createDatasetResponse.then().assertThat() + .statusCode(CREATED.getStatusCode()); + String datasetId = UtilIT.getDatasetIdFromResponse(createDatasetResponse).toString(); + + // putting the dataverseAlias in the description of each file so the search q={dataverseAlias} will return dataverse, dataset, and files for this test only + String jsonAsString = "{\"description\":\"" + dataverseAlias + "\",\"directoryLabel\":\"data/subdir1\",\"categories\":[\"Data\"], \"restrict\":\"false\" }"; + + String pathToFile = "src/main/webapp/resources/images/dataverseproject.png"; + Response uploadImage = UtilIT.uploadFileViaNative(datasetId, pathToFile, jsonAsString, apiToken); + uploadImage.then().assertThat() + .statusCode(200); + pathToFile = "src/main/webapp/resources/js/mydata.js"; + Response uploadFile = UtilIT.uploadFileViaNative(datasetId, pathToFile, jsonAsString, apiToken); + uploadFile.then().assertThat() + .statusCode(200); + + // This call forces a wait for dataset indexing to finish and gives time for file uploads to complete + UtilIT.search("id:dataset_" + datasetId, apiToken); + } + + // Test Search without show_type_counts + searchResp = UtilIT.search(dataverseAlias, apiToken); + searchResp.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.total_count_per_object_type", CoreMatchers.equalTo(null)); + // Test Search with show_type_counts = FALSE + searchResp = UtilIT.search(dataverseAlias, apiToken, "&show_type_counts=false"); + searchResp.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.total_count_per_object_type", CoreMatchers.equalTo(null)); + // Test Search with show_type_counts = TRUE + searchResp = UtilIT.search(dataverseAlias, apiToken, "&show_type_counts=true"); + searchResp.prettyPrint(); + searchResp.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.total_count_per_object_type.dataverses", CoreMatchers.is(1)) + .body("data.total_count_per_object_type.datasets", CoreMatchers.is(3)) + .body("data.total_count_per_object_type.files", CoreMatchers.is(6)); + } } From f58149918378941b6d9353ecec98fc6ee8bcf4eb Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:26:39 -0500 Subject: [PATCH 078/101] update serch api doc --- doc/sphinx-guides/source/api/search.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/sphinx-guides/source/api/search.rst b/doc/sphinx-guides/source/api/search.rst index 7ca9a5abca6..73e8a514fc2 100755 --- a/doc/sphinx-guides/source/api/search.rst +++ b/doc/sphinx-guides/source/api/search.rst @@ -38,6 +38,7 @@ show_entity_ids boolean Whether or not to show the database IDs of the search geo_point string Latitude and longitude in the form ``geo_point=42.3,-71.1``. You must supply ``geo_radius`` as well. See also :ref:`geospatial-search`. geo_radius string Radial distance in kilometers from ``geo_point`` (which must be supplied as well) such as ``geo_radius=1.5``. metadata_fields string Includes the requested fields for each dataset in the response. Multiple "metadata_fields" parameters can be used to include several fields. The value must be in the form "{metadata_block_name}:{field_name}" to include a specific field from a metadata block (see :ref:`example `) or "{metadata_field_set_name}:\*" to include all the fields for a metadata block (see :ref:`example `). "{field_name}" cannot be a subfield of a compound field. If "{field_name}" is a compound field, all subfields are included. +show_type_counts boolean Whether or not to include total_count_per_object_type for types: dataverse, dataset, and files =============== ======= =========== Basic Search Example @@ -702,6 +703,10 @@ The above example ``metadata_fields=citation:dsDescription&metadata_fields=citat } ], "count_in_response": 4 + "total_count_per_object_type": { + "datasets": 2, + "dataverses": 2 + } } } From 5f1ea6bd2bcfc1814af50363a33cc0d2a4a176aa Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:30:43 -0500 Subject: [PATCH 079/101] update serch api doc --- doc/sphinx-guides/source/api/search.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/sphinx-guides/source/api/search.rst b/doc/sphinx-guides/source/api/search.rst index 73e8a514fc2..07545632424 100755 --- a/doc/sphinx-guides/source/api/search.rst +++ b/doc/sphinx-guides/source/api/search.rst @@ -38,7 +38,7 @@ show_entity_ids boolean Whether or not to show the database IDs of the search geo_point string Latitude and longitude in the form ``geo_point=42.3,-71.1``. You must supply ``geo_radius`` as well. See also :ref:`geospatial-search`. geo_radius string Radial distance in kilometers from ``geo_point`` (which must be supplied as well) such as ``geo_radius=1.5``. metadata_fields string Includes the requested fields for each dataset in the response. Multiple "metadata_fields" parameters can be used to include several fields. The value must be in the form "{metadata_block_name}:{field_name}" to include a specific field from a metadata block (see :ref:`example `) or "{metadata_field_set_name}:\*" to include all the fields for a metadata block (see :ref:`example `). "{field_name}" cannot be a subfield of a compound field. If "{field_name}" is a compound field, all subfields are included. -show_type_counts boolean Whether or not to include total_count_per_object_type for types: dataverse, dataset, and files +show_type_counts boolean Whether or not to include total_count_per_object_type for types: dataverse, dataset, and files. =============== ======= =========== Basic Search Example @@ -702,7 +702,7 @@ The above example ``metadata_fields=citation:dsDescription&metadata_fields=citat "published_at": "2021-03-16T08:11:54Z" } ], - "count_in_response": 4 + "count_in_response": 4, "total_count_per_object_type": { "datasets": 2, "dataverses": 2 From 3be636908fc0f1303400c2280404318a3f1130a7 Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:34:41 -0500 Subject: [PATCH 080/101] update search api doc --- doc/sphinx-guides/source/api/search.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/sphinx-guides/source/api/search.rst b/doc/sphinx-guides/source/api/search.rst index 07545632424..8e4e54f3767 100755 --- a/doc/sphinx-guides/source/api/search.rst +++ b/doc/sphinx-guides/source/api/search.rst @@ -39,7 +39,7 @@ geo_point string Latitude and longitude in the form ``geo_point=42.3,-7 geo_radius string Radial distance in kilometers from ``geo_point`` (which must be supplied as well) such as ``geo_radius=1.5``. metadata_fields string Includes the requested fields for each dataset in the response. Multiple "metadata_fields" parameters can be used to include several fields. The value must be in the form "{metadata_block_name}:{field_name}" to include a specific field from a metadata block (see :ref:`example `) or "{metadata_field_set_name}:\*" to include all the fields for a metadata block (see :ref:`example `). "{field_name}" cannot be a subfield of a compound field. If "{field_name}" is a compound field, all subfields are included. show_type_counts boolean Whether or not to include total_count_per_object_type for types: dataverse, dataset, and files. -=============== ======= =========== +================ ======= =========== Basic Search Example -------------------- From 9cc3f7fb5b9265760d48c1a1525c5f7231ad2fd1 Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:35:30 -0500 Subject: [PATCH 081/101] update search api doc --- doc/sphinx-guides/source/api/search.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/sphinx-guides/source/api/search.rst b/doc/sphinx-guides/source/api/search.rst index 8e4e54f3767..1d805b513f7 100755 --- a/doc/sphinx-guides/source/api/search.rst +++ b/doc/sphinx-guides/source/api/search.rst @@ -21,9 +21,9 @@ Please note that in Dataverse Software 4.3 and older the "citation" field wrappe Parameters ---------- -=============== ======= =========== +================ ======= =========== Name Type Description -=============== ======= =========== +================ ======= =========== q string The search term or terms. Using "title:data" will search only the "title" field. "*" can be used as a wildcard either alone or adjacent to a term (i.e. "bird*"). For example, https://demo.dataverse.org/api/search?q=title:data . For a list of fields to search, please see https://github.com/IQSS/dataverse/issues/2558 (for now). type string Can be either "dataverse", "dataset", or "file". Multiple "type" parameters can be used to include multiple types (i.e. ``type=dataset&type=file``). If omitted, all types will be returned. For example, https://demo.dataverse.org/api/search?q=*&type=dataset subtree string The identifier of the Dataverse collection to which the search should be narrowed. The subtree of this Dataverse collection and all its children will be searched. Multiple "subtree" parameters can be used to include multiple Dataverse collections. For example, https://demo.dataverse.org/api/search?q=data&subtree=birds&subtree=cats . From 600fe58d6456593a85926d733ec0d64449c005de Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 11 Dec 2024 15:07:31 +0000 Subject: [PATCH 082/101] Added: feature flag API_BEARER_AUTH_HANDLE_TOS_ACCEPTANCE_IN_IDP to FeatureFlags.java --- .../harvard/iq/dataverse/settings/FeatureFlags.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java index b3774c3fe06..2242b0f51c6 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java @@ -48,6 +48,17 @@ public enum FeatureFlags { * @since Dataverse @TODO: */ API_BEARER_AUTH_PROVIDE_MISSING_CLAIMS("api-bearer-auth-provide-missing-claims"), + /** + * Specifies that Terms of Service acceptance is handled by the IdP, eliminating the need to include + * ToS acceptance boolean parameter (termsAccepted) in the OIDC user registration request body. + * + *

The value of this feature flag is only considered when the feature flag + * {@link #API_BEARER_AUTH} is enabled.

+ * + * @apiNote Raise flag by setting "dataverse.feature.api-bearer-auth-handle-tos-acceptance-in-idp" + * @since Dataverse @TODO: + */ + API_BEARER_AUTH_HANDLE_TOS_ACCEPTANCE_IN_IDP("api-bearer-auth-handle-tos-acceptance-in-idp"), /** * For published (public) objects, don't use a join when searching Solr. * Experimental! Requires a reindex with the following feature flag enabled, From fac0dc373317c892702b84c4d86e1add454f54e6 Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 11 Dec 2024 15:25:20 +0000 Subject: [PATCH 083/101] Added: managing API_BEARER_AUTH_HANDLE_TOS_ACCEPTANCE_IN_IDP feature flag in RegisterOIDCUserCommand --- .../command/impl/RegisterOIDCUserCommand.java | 5 ++++- .../iq/dataverse/util/json/JsonParser.java | 8 +++++++- .../impl/RegisterOIDCUserCommandTest.java | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java index ad0bf4470d3..c7745c75aa9 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommand.java @@ -164,7 +164,10 @@ private String getValueOrDefault(String oidcValue, String dtoValue) { private void validateUserFields(CommandContext ctxt, boolean provideMissingClaimsEnabled) throws InvalidFieldsCommandException { Map fieldErrors = new HashMap<>(); - validateTermsAccepted(fieldErrors); + if (!FeatureFlags.API_BEARER_AUTH_HANDLE_TOS_ACCEPTANCE_IN_IDP.enabled()) { + validateTermsAccepted(fieldErrors); + } + validateField(fieldErrors, FIELD_EMAIL_ADDRESS, userDTO.getEmailAddress(), ctxt, provideMissingClaimsEnabled); validateField(fieldErrors, FIELD_USERNAME, userDTO.getUsername(), ctxt, provideMissingClaimsEnabled); validateField(fieldErrors, FIELD_FIRST_NAME, userDTO.getFirstName(), ctxt, provideMissingClaimsEnabled); diff --git a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java index af69807247d..ce6a5920a39 100644 --- a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java +++ b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java @@ -32,6 +32,7 @@ import edu.harvard.iq.dataverse.harvest.client.HarvestingClient; import edu.harvard.iq.dataverse.license.License; import edu.harvard.iq.dataverse.license.LicenseServiceBean; +import edu.harvard.iq.dataverse.settings.FeatureFlags; import edu.harvard.iq.dataverse.settings.SettingsServiceBean; import edu.harvard.iq.dataverse.util.BundleUtil; import edu.harvard.iq.dataverse.workflow.Workflow; @@ -1107,13 +1108,18 @@ private void validate(String objectName, JsonObject jobject, String fieldName, V public UserDTO parseUserDTO(JsonObject jobj) throws JsonParseException { UserDTO userDTO = new UserDTO(); + userDTO.setUsername(jobj.getString("username", null)); userDTO.setEmailAddress(jobj.getString("emailAddress", null)); userDTO.setFirstName(jobj.getString("firstName", null)); userDTO.setLastName(jobj.getString("lastName", null)); - userDTO.setTermsAccepted(getMandatoryBoolean(jobj, "termsAccepted")); userDTO.setAffiliation(jobj.getString("affiliation", null)); userDTO.setPosition(jobj.getString("position", null)); + + if (!FeatureFlags.API_BEARER_AUTH_HANDLE_TOS_ACCEPTANCE_IN_IDP.enabled()) { + userDTO.setTermsAccepted(getMandatoryBoolean(jobj, "termsAccepted")); + } + return userDTO; } } diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java index 934d4296f09..3f6b3b0f393 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterOIDCUserCommandTest.java @@ -29,6 +29,7 @@ import static edu.harvard.iq.dataverse.mocks.MocksFactory.makeRequest; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.mockito.Mockito.*; @LocalJvmSettings @@ -350,4 +351,21 @@ void execute_happyPath_withoutAffiliationAndPosition_blankClaimInProviderProvide eq(true) ); } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-handle-tos-acceptance-in-idp") + void execute_doNotThrowUnacceptedTermsError_unacceptedTermsInUserDTOAndAllClaimsInProvider_handleTosAcceptanceInIdpFeatureFlagEnabled() throws AuthorizationException { + testUserDTO.setTermsAccepted(false); + testUserDTO.setEmailAddress(null); + testUserDTO.setUsername(null); + testUserDTO.setFirstName(null); + testUserDTO.setLastName(null); + + when(authServiceStub.verifyOIDCBearerTokenAndGetOAuth2UserRecord(TEST_BEARER_TOKEN)).thenReturn(oAuth2UserRecordStub); + + when(oAuth2UserRecordStub.getUsername()).thenReturn(TEST_USERNAME); + when(oAuth2UserRecordStub.getDisplayInfo()).thenReturn(TEST_VALID_DISPLAY_INFO); + + assertDoesNotThrow(() -> sut.execute(contextStub)); + } } From 628746c9c621e4342adc3655746902ed723d2a67 Mon Sep 17 00:00:00 2001 From: GPortas Date: Thu, 12 Dec 2024 12:20:50 +0000 Subject: [PATCH 084/101] Changed: updated auth.rst docs with api-bearer-auth-handle-tos-acceptance-in-idp feature flag usage --- doc/sphinx-guides/source/api/auth.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/sphinx-guides/source/api/auth.rst b/doc/sphinx-guides/source/api/auth.rst index 2784703ddae..210c1bcd184 100644 --- a/doc/sphinx-guides/source/api/auth.rst +++ b/doc/sphinx-guides/source/api/auth.rst @@ -87,13 +87,13 @@ To register a new user who has authenticated via an OIDC provider, the following curl -H "Authorization: Bearer $TOKEN" -X POST http://localhost:8080/api/users/register --data '{"termsAccepted":true}' -It is essential to send a JSON that includes the property ``termsAccepted`` set to true, which indicates that you accept the Terms of Use of the installation. Otherwise, you will not be able to create an account. +If the feature flag ``api-bearer-auth-handle-tos-acceptance-in-idp``` is disabled, it is essential to send a JSON that includes the property ``termsAccepted``` set to true, indicating that you accept the Terms of Use of the installation. Otherwise, you will not be able to create an account. However, if the feature flag is enabled, Terms of Service acceptance is handled by the identity provider, and it is no longer necessary to include the ``termsAccepted``` parameter in the JSON. In this JSON, we can also include the fields ``position`` or ``affiliation``, in the same way as when we register a user through the Dataverse UI. These fields are optional, and if not provided, they will be persisted as empty in Dataverse. -Beyond the ``api-bearer-auth`` feature flag, there is another flag called ``api-bearer-auth-provide-missing-claims`` that can be enabled to allow sending missing user claims in the registration JSON. This is useful when the identity provider does not supply the necessary claims. However, this flag will only be considered if the ``api-bearer-auth`` feature flag is enabled. If the latter is not enabled, the ``api-bearer-auth-provide-missing-claims`` flag will be ignored. +There is another flag called ``api-bearer-auth-provide-missing-claims`` that can be enabled to allow sending missing user claims in the registration JSON. This is useful when the identity provider does not supply the necessary claims. However, this flag will only be considered if the ``api-bearer-auth`` feature flag is enabled. If the latter is not enabled, the ``api-bearer-auth-provide-missing-claims`` flag will be ignored. -With the ``api-bearer-auth`` feature flag enabled, you can include the following properties in the request JSON: +With the ``api-bearer-auth-provide-missing-claims`` feature flag enabled, you can include the following properties in the request JSON: - ``username`` - ``firstName`` From 4227eff7578f7ccb170bc076dcbcc658a2ba4ef6 Mon Sep 17 00:00:00 2001 From: GPortas Date: Thu, 12 Dec 2024 12:21:14 +0000 Subject: [PATCH 085/101] Changed: updated config.rst docs with api-bearer-auth-handle-tos-acceptance-in-idp feature flag usage --- doc/sphinx-guides/source/installation/config.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst index 2d1b942b41b..6fd40b8015b 100644 --- a/doc/sphinx-guides/source/installation/config.rst +++ b/doc/sphinx-guides/source/installation/config.rst @@ -3349,6 +3349,9 @@ please find all known feature flags below. Any of these flags can be activated u * - api-bearer-auth-provide-missing-claims - Enables sending missing user claims in the request JSON provided during OIDC user registration, when these claims are not returned by the identity provider and are required for registration. This feature only works when the feature flag ``api-bearer-auth`` is also enabled. **Caution: Enabling this feature flag exposes the installation to potential user impersonation issues.** - ``Off`` + * - api-bearer-auth-handle-tos-acceptance-in-idp + - Specifies that Terms of Service acceptance is handled by the IdP, eliminating the need to include ToS acceptance boolean parameter (termsAccepted) in the OIDC user registration request body. This feature only works when the feature flag ``api-bearer-auth`` is also enabled. + - ``Off`` * - avoid-expensive-solr-join - Changes the way Solr queries are constructed for public content (published Collections, Datasets and Files). It removes a very expensive Solr join on all such documents, improving overall performance, especially for large instances under heavy load. Before this feature flag is enabled, the corresponding indexing feature (see next feature flag) must be turned on and a full reindex performed (otherwise public objects are not going to be shown in search results). See :doc:`/admin/solr-search-index`. - ``Off`` From abf6994f7d8e67cdfea6cb859d2385d4bed17471 Mon Sep 17 00:00:00 2001 From: GPortas Date: Thu, 12 Dec 2024 12:24:26 +0000 Subject: [PATCH 086/101] Changed: updated release notes --- doc/release-notes/10959-oidc-api-auth-ext.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/doc/release-notes/10959-oidc-api-auth-ext.md b/doc/release-notes/10959-oidc-api-auth-ext.md index e135fcccfd1..04ee2099f68 100644 --- a/doc/release-notes/10959-oidc-api-auth-ext.md +++ b/doc/release-notes/10959-oidc-api-auth-ext.md @@ -3,7 +3,12 @@ where ``BearerTokenAuthMechanism`` successfully validates the token but cannot i is no account associated with the token. To register a new user who has authenticated via an OIDC provider, a new endpoint has been -implemented (``/users/register``). A feature flag called ``api-bearer-auth-provide-missing-claims`` has been implemented to allow +implemented (``/users/register``). A feature flag named ``api-bearer-auth-provide-missing-claims`` has been implemented +to allow sending missing user claims in the request JSON. This is useful when the identity provider does not supply the necessary claims. However, this flag will only be considered if the ``api-bearer-auth`` feature flag is enabled. If the latter is not enabled, the ``api-bearer-auth-provide-missing-claims`` flag will be ignored. + +A feature flag named ``api-bearer-auth-handle-tos-acceptance-in-idp`` has been implemented. When enabled, it specifies +that Terms of Service acceptance is managed by the identity provider, eliminating the need to explicitly include the +acceptance in the user registration request JSON. From af10ae9d716ac579919e464633e979c4776e032d Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Fri, 13 Dec 2024 11:12:53 -0500 Subject: [PATCH 087/101] fix type counts to include all results and not just the pages worth --- doc/sphinx-guides/source/api/search.rst | 6 +++--- .../java/edu/harvard/iq/dataverse/api/Search.java | 12 ++++++------ .../java/edu/harvard/iq/dataverse/api/SearchIT.java | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/doc/sphinx-guides/source/api/search.rst b/doc/sphinx-guides/source/api/search.rst index 1d805b513f7..9a211988979 100755 --- a/doc/sphinx-guides/source/api/search.rst +++ b/doc/sphinx-guides/source/api/search.rst @@ -38,7 +38,7 @@ show_entity_ids boolean Whether or not to show the database IDs of the search geo_point string Latitude and longitude in the form ``geo_point=42.3,-71.1``. You must supply ``geo_radius`` as well. See also :ref:`geospatial-search`. geo_radius string Radial distance in kilometers from ``geo_point`` (which must be supplied as well) such as ``geo_radius=1.5``. metadata_fields string Includes the requested fields for each dataset in the response. Multiple "metadata_fields" parameters can be used to include several fields. The value must be in the form "{metadata_block_name}:{field_name}" to include a specific field from a metadata block (see :ref:`example `) or "{metadata_field_set_name}:\*" to include all the fields for a metadata block (see :ref:`example `). "{field_name}" cannot be a subfield of a compound field. If "{field_name}" is a compound field, all subfields are included. -show_type_counts boolean Whether or not to include total_count_per_object_type for types: dataverse, dataset, and files. +show_type_counts boolean Whether or not to include total_count_per_object_type for types: Dataverse, Dataset, and Files. ================ ======= =========== Basic Search Example @@ -704,8 +704,8 @@ The above example ``metadata_fields=citation:dsDescription&metadata_fields=citat ], "count_in_response": 4, "total_count_per_object_type": { - "datasets": 2, - "dataverses": 2 + "Datasets": 2, + "Dataverses": 2 } } } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Search.java b/src/main/java/edu/harvard/iq/dataverse/api/Search.java index 94a41cdb042..222357765cd 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Search.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Search.java @@ -174,13 +174,9 @@ public Response search( return error(Response.Status.INTERNAL_SERVER_ERROR, message); } - Map itemCountByType = new HashMap<>(); JsonArrayBuilder itemsArrayBuilder = Json.createArrayBuilder(); List solrSearchResults = solrQueryResponse.getSolrSearchResults(); for (SolrSearchResult solrSearchResult : solrSearchResults) { - if (showTypeCounts) { - itemCountByType.merge(solrSearchResult.getType(), 1, Integer::sum); - } itemsArrayBuilder.add(solrSearchResult.json(showRelevance, showEntityIds, showApiUrls, metadataFields)); } @@ -216,9 +212,13 @@ public Response search( } value.add("count_in_response", solrSearchResults.size()); - if (showTypeCounts && !itemCountByType.isEmpty()) { + if (showTypeCounts && !solrQueryResponse.getTypeFacetCategories().isEmpty()) { JsonObjectBuilder objectTypeCounts = Json.createObjectBuilder(); - itemCountByType.forEach((k,v) -> objectTypeCounts.add(k,v)); + for (FacetCategory facetCategory : solrQueryResponse.getTypeFacetCategories()) { + for (FacetLabel facetLabel : facetCategory.getFacetLabel()) { + objectTypeCounts.add(facetLabel.getName(), facetLabel.getCount()); + } + } value.add("total_count_per_object_type", objectTypeCounts); } /** diff --git a/src/test/java/edu/harvard/iq/dataverse/api/SearchIT.java b/src/test/java/edu/harvard/iq/dataverse/api/SearchIT.java index 88d93ef262b..cee63d3c92f 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/SearchIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/SearchIT.java @@ -1404,8 +1404,8 @@ public void testShowTypeCounts() { searchResp.prettyPrint(); searchResp.then().assertThat() .statusCode(OK.getStatusCode()) - .body("data.total_count_per_object_type.dataverses", CoreMatchers.is(1)) - .body("data.total_count_per_object_type.datasets", CoreMatchers.is(3)) - .body("data.total_count_per_object_type.files", CoreMatchers.is(6)); + .body("data.total_count_per_object_type.Dataverses", CoreMatchers.is(1)) + .body("data.total_count_per_object_type.Datasets", CoreMatchers.is(3)) + .body("data.total_count_per_object_type.Files", CoreMatchers.is(6)); } } From 02ef448f4efb88917dedf380a3f3c832dd5c65d0 Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Fri, 13 Dec 2024 11:14:08 -0500 Subject: [PATCH 088/101] remove unused include --- src/main/java/edu/harvard/iq/dataverse/api/Search.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Search.java b/src/main/java/edu/harvard/iq/dataverse/api/Search.java index 222357765cd..ba82f8f758b 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Search.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Search.java @@ -19,7 +19,6 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.logging.Logger; From 32de2443bc32a468a258dd0bc60470cfe7299b8b Mon Sep 17 00:00:00 2001 From: Victoria Lubitch Date: Fri, 13 Dec 2024 16:51:50 -0500 Subject: [PATCH 089/101] test --- .../export/ddi/DdiExportUtilTest.java | 17 + .../ddi/dataset-finch-terms-of-use.json | 404 ++++++++++++++++++ .../export/ddi/dataset-finch-terms-of-use.xml | 78 ++++ 3 files changed, 499 insertions(+) create mode 100644 src/test/java/edu/harvard/iq/dataverse/export/ddi/dataset-finch-terms-of-use.json create mode 100644 src/test/java/edu/harvard/iq/dataverse/export/ddi/dataset-finch-terms-of-use.xml diff --git a/src/test/java/edu/harvard/iq/dataverse/export/ddi/DdiExportUtilTest.java b/src/test/java/edu/harvard/iq/dataverse/export/ddi/DdiExportUtilTest.java index 41e6be61bb8..f594de4757d 100644 --- a/src/test/java/edu/harvard/iq/dataverse/export/ddi/DdiExportUtilTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/export/ddi/DdiExportUtilTest.java @@ -64,6 +64,23 @@ public void testJson2DdiNoFiles() throws Exception { XmlAssert.assertThat(result).and(datasetAsDdi).ignoreWhitespace().areSimilar(); } + @Test + public void testJson2DdiNoFilesTermsOfUse() throws Exception { + // given + Path datasetVersionJson = Path.of("src/test/java/edu/harvard/iq/dataverse/export/ddi/dataset-finch-terms-of-use.json"); + String datasetVersionAsJson = Files.readString(datasetVersionJson, StandardCharsets.UTF_8); + Path ddiFile = Path.of("src/test/java/edu/harvard/iq/dataverse/export/ddi/dataset-finch-terms-of-use.xml"); + String datasetAsDdi = XmlPrinter.prettyPrintXml(Files.readString(ddiFile, StandardCharsets.UTF_8)); + logger.fine(datasetAsDdi); + + // when + String result = DdiExportUtil.datasetDtoAsJson2ddi(datasetVersionAsJson); + logger.fine(result); + + // then + XmlAssert.assertThat(result).and(datasetAsDdi).ignoreWhitespace().areSimilar(); + } + @Test public void testExportDDI() throws Exception { // given diff --git a/src/test/java/edu/harvard/iq/dataverse/export/ddi/dataset-finch-terms-of-use.json b/src/test/java/edu/harvard/iq/dataverse/export/ddi/dataset-finch-terms-of-use.json new file mode 100644 index 00000000000..b3d6caff2e9 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/export/ddi/dataset-finch-terms-of-use.json @@ -0,0 +1,404 @@ +{ + "id": 11, + "identifier": "PCA2E3", + "persistentUrl": "https://doi.org/10.5072/FK2/PCA2E3", + "protocol": "doi", + "authority": "10.5072/FK2", + "metadataLanguage": "en", + "datasetVersion": { + "id": 2, + "versionNumber": 1, + "versionMinorNumber": 0, + "versionState": "RELEASED", + "productionDate": "Production Date", + "lastUpdateTime": "2015-09-24T17:07:57Z", + "releaseTime": "2015-09-24T17:07:57Z", + "createTime": "2015-09-24T16:47:51Z", + "termsOfUse":"This dataset is made available without information on how it can be used. You should communicate with the Contact(s) specified before use.", + "metadataBlocks": { + "citation": { + "displayName": "Citation Metadata", + "name":"citation", + "fields": [ + { + "typeName": "title", + "multiple": false, + "typeClass": "primitive", + "value": "Darwin's Finches" + }, + { + "typeName": "alternativeTitle", + "multiple": true, + "typeClass": "primitive", + "value": ["Darwin's Finches Alternative Title1", "Darwin's Finches Alternative Title2"] + }, + { + "typeName": "author", + "multiple": true, + "typeClass": "compound", + "value": [ + { + "authorName": { + "typeName": "authorName", + "multiple": false, + "typeClass": "primitive", + "value": "Finch, Fiona" + }, + "authorAffiliation": { + "typeName": "authorAffiliation", + "multiple": false, + "typeClass": "primitive", + "value": "Birds Inc." + } + } + ] + }, + { + "typeName": "timePeriodCovered", + "multiple": true, + "typeClass": "compound", + "value": [ + { + "timePeriodStart": { + "typeName": "timePeriodCoveredStart", + "multiple": false, + "typeClass": "primitive", + "value": "20020816" + }, + "timePeriodEnd": { + "typeName": "timePeriodCoveredEnd", + "multiple": false, + "typeClass": "primitive", + "value": "20160630" + } + } + ] + }, + { + "typeName": "dateOfCollection", + "multiple": true, + "typeClass": "compound", + "value": [ + { + "timePeriodStart": { + "typeName": "dateOfCollectionStart", + "multiple": false, + "typeClass": "primitive", + "value": "20070831" + }, + "timePeriodEnd": { + "typeName": "dateOfCollectionEnd", + "multiple": false, + "typeClass": "primitive", + "value": "20130630" + } + } + ] + }, + { + "typeName": "datasetContact", + "multiple": true, + "typeClass": "compound", + "value": [ + { + "datasetContactEmail": { + "typeName": "datasetContactEmail", + "multiple": false, + "typeClass": "primitive", + "value": "finch@mailinator.com" + }, + "datasetContactName": { + "typeName": "datasetContactName", + "multiple": false, + "typeClass": "primitive", + "value": "Jimmy Finch" + }, + "datasetContactAffiliation": { + "typeName": "datasetContactAffiliation", + "multiple": false, + "typeClass": "primitive", + "value": "Finch Academy" + } + } + ] + }, + { + "typeName": "producer", + "multiple": true, + "typeClass": "compound", + "value": [ + { + "producerAbbreviation": { + "typeName": "producerAbbreviation", + "multiple": false, + "typeClass": "primitive", + "value": "ProdAbb" + }, + "producerName": { + "typeName": "producerName", + "multiple": false, + "typeClass": "primitive", + "value": "Johnny Hawk" + }, + "producerAffiliation": { + "typeName": "producerAffiliation", + "multiple": false, + "typeClass": "primitive", + "value": "Hawk Institute" + }, + "producerURL": { + "typeName": "producerURL", + "multiple": false, + "typeClass": "primitive", + "value": "http://www.hawk.edu/url" + }, + "producerLogoURL": { + "typeName": "producerLogoURL", + "multiple": false, + "typeClass": "primitive", + "value": "http://www.hawk.edu/logo" + } + } + ] + }, + { + "typeName": "distributor", + "multiple": true, + "typeClass": "compound", + "value": [ + { + "distributorAbbreviation": { + "typeName": "distributorAbbreviation", + "multiple": false, + "typeClass": "primitive", + "value": "Dist-Abb" + }, + "producerName": { + "typeName": "distributorName", + "multiple": false, + "typeClass": "primitive", + "value": "Odin Raven" + }, + "distributorAffiliation": { + "typeName": "distributorAffiliation", + "multiple": false, + "typeClass": "primitive", + "value": "Valhalla Polytechnic" + }, + "distributorURL": { + "typeName": "distributorURL", + "multiple": false, + "typeClass": "primitive", + "value": "http://www.valhalla.edu/url" + }, + "distributorLogoURL": { + "typeName": "distributorLogoURL", + "multiple": false, + "typeClass": "primitive", + "value": "http://www.valhalla.edu/logo" + } + } + ] + }, + { + "typeName": "dsDescription", + "multiple": true, + "typeClass": "compound", + "value": [ + { + "dsDescriptionValue": { + "typeName": "dsDescriptionValue", + "multiple": false, + "typeClass": "primitive", + "value": "Darwin's finches (also known as the Galápagos finches) are a group of about fifteen species of passerine birds." + } + } + ] + }, + { + "typeName": "subject", + "multiple": true, + "typeClass": "controlledVocabulary", + "value": [ + "Medicine, Health and Life Sciences" + ] + }, + { + "typeName": "keyword", + "multiple": true, + "typeClass": "compound", + "value": [ + { + "keywordValue": { + "typeName": "keywordValue", + "multiple": false, + "typeClass": "primitive", + "value": "Keyword Value 1" + }, + "keywordTermURI": { + "typeName": "keywordTermURI", + "multiple": false, + "typeClass": "primitive", + "value": "http://keywordTermURI1.org" + }, + "keywordVocabulary": { + "typeName": "keywordVocabulary", + "multiple": false, + "typeClass": "primitive", + "value": "Keyword Vocabulary" + }, + "keywordVocabularyURI": { + "typeName": "keywordVocabularyURI", + "multiple": false, + "typeClass": "primitive", + "value": "http://www.keyword.com/one" + } + }, + { + "keywordValue": { + "typeName": "keywordValue", + "multiple": false, + "typeClass": "primitive", + "value": "Keyword Value Two" + }, + "keywordTermURI": { + "typeName": "keywordTermURI", + "multiple": false, + "typeClass": "primitive", + "value": "http://keywordTermURI1.org" + }, + "keywordVocabulary": { + "typeName": "keywordVocabulary", + "multiple": false, + "typeClass": "primitive", + "value": "Keyword Vocabulary" + }, + "keywordVocabularyURI": { + "typeName": "keywordVocabularyURI", + "multiple": false, + "typeClass": "primitive", + "value": "http://www.keyword.com/one" + } + } + ] + }, + { + "typeName": "topicClassification", + "multiple": true, + "typeClass": "compound", + "value": [ + { + "topicClassValue": { + "typeName": "topicClassValue", + "multiple": false, + "typeClass": "primitive", + "value": "TC Value 1" + }, + "topicClassVocab": { + "typeName": "topicClassVocab", + "multiple": false, + "typeClass": "primitive", + "value": "TC Vocabulary" + }, + "topicClassVocabURI": { + "typeName": "topicClassVocabURI", + "multiple": false, + "typeClass": "primitive", + "value": "http://www.topicClass.com/one" + } + } + ] + }, + { + "typeName": "kindOfData", + "multiple": true, + "typeClass": "primitive", + "value": [ + "Kind of Data" + ] + }, + { + "typeName": "depositor", + "multiple": false, + "typeClass": "primitive", + "value": "Added, Depositor" + } + ] + }, + "geospatial": { + "displayName": "Geospatial", + "name":"geospatial", + "fields": [ + { + "typeName": "geographicCoverage", + "multiple": true, + "typeClass": "compound", + "value": [ + { + "country": { + "typeName": "country", + "multiple": false, + "typeClass": "primitive", + "value": "USA" + }, + "state": { + "typeName": "state", + "multiple": false, + "typeClass": "primitive", + "value": "MA" + }, + "city": { + "typeName": "city", + "multiple": false, + "typeClass": "primitive", + "value": "Cambridge" + }, + "otherGeographicCoverage": { + "typeName": "otherGeographicCoverage", + "multiple": false, + "typeClass": "primitive", + "value": "Other Geographic Coverage" + } + } + ] + }, + { + "typeName": "geographicBoundingBox", + "multiple": true, + "typeClass": "compound", + "value": [ + { + "westLongitude": { + "typeName": "westLongitude", + "multiple": false, + "typeClass": "primitive", + "value": "60.3" + }, + "eastLongitude": { + "typeName": "eastLongitude", + "multiple": false, + "typeClass": "primitive", + "value": "59.8" + }, + "southLatitude": { + "typeName": "southLatitude", + "multiple": false, + "typeClass": "primitive", + "value": "41.6" + }, + "northLatitude": { + "typeName": "northLatitude", + "multiple": false, + "typeClass": "primitive", + "value": "43.8" + } + } + ] + } + ] + } + }, + "files": [], + "citation": "Finch, Fiona, 2015, \"Darwin's Finches\", https://doi.org/10.5072/FK2/PCA2E3, Root Dataverse, V1" + } +} \ No newline at end of file diff --git a/src/test/java/edu/harvard/iq/dataverse/export/ddi/dataset-finch-terms-of-use.xml b/src/test/java/edu/harvard/iq/dataverse/export/ddi/dataset-finch-terms-of-use.xml new file mode 100644 index 00000000000..d813d155a90 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/export/ddi/dataset-finch-terms-of-use.xml @@ -0,0 +1,78 @@ + + + + + + Darwin's Finches + doi:10.5072/FK2/PCA2E3 + + + + 1 + + Finch, Fiona, 2015, "Darwin's Finches", https://doi.org/10.5072/FK2/PCA2E3, Root Dataverse, V1 + + + + + + Darwin's Finches + Darwin's Finches Alternative Title1 + Darwin's Finches Alternative Title2 + doi:10.5072/FK2/PCA2E3 + + + Finch, Fiona + + + Johnny Hawk + + + Odin Raven + Jimmy Finch + Added, Depositor + + + + + + Medicine, Health and Life Sciences + Keyword Value 1 + Keyword Value Two + TC Value 1 + + Darwin's finches (also known as the Galápagos finches) are a group of about fifteen species of passerine birds. + + 20020816 + 20160630 + 20070831 + 20130630 + USA + Cambridge + MA + Other Geographic Coverage + + 60.3 + 59.8 + 41.6 + 43.8 + + Kind of Data + + + + + + + + + + + + + This dataset is made available without information on how it can be used. You should communicate with the Contact(s) specified before use. + + + + + From b2fb0cc6603572d586ee3c94e1d28bffdfe83f8e Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Mon, 16 Dec 2024 08:11:39 -0500 Subject: [PATCH 090/101] extened search api --- conf/solr/schema.xml | 4 +++ .../iq/dataverse/search/IndexServiceBean.java | 4 +++ .../iq/dataverse/search/SearchFields.java | 3 ++ .../dataverse/search/SearchServiceBean.java | 17 +++++++++ .../iq/dataverse/search/SolrSearchResult.java | 35 +++++++++++++++++- .../util/json/NullSafeJsonBuilder.java | 5 ++- .../harvard/iq/dataverse/api/SearchIT.java | 36 +++++++++++++++++-- 7 files changed, 100 insertions(+), 4 deletions(-) diff --git a/conf/solr/schema.xml b/conf/solr/schema.xml index d5c789c7189..380e4fc4da5 100644 --- a/conf/solr/schema.xml +++ b/conf/solr/schema.xml @@ -167,6 +167,8 @@ + + @@ -201,6 +203,8 @@ + + diff --git a/src/main/java/edu/harvard/iq/dataverse/search/IndexServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/search/IndexServiceBean.java index 4efd339ee46..4290a58bd00 100644 --- a/src/main/java/edu/harvard/iq/dataverse/search/IndexServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/search/IndexServiceBean.java @@ -1580,6 +1580,7 @@ public SolrInputDocuments toSolrDocs(IndexableDataset indexableDataset, Set variables = fileMetadata.getDataFile().getDataTable().getDataVariables(); + Long observations = fileMetadata.getDataFile().getDataTable().getCaseQuantity(); + datafileSolrInputDocument.addField(SearchFields.OBSERVATIONS, observations); + datafileSolrInputDocument.addField(SearchFields.VARIABLE_COUNT, variables.size()); Map variableMap = null; List variablesByMetadata = variableService.findVarMetByFileMetaId(fileMetadata.getId()); diff --git a/src/main/java/edu/harvard/iq/dataverse/search/SearchFields.java b/src/main/java/edu/harvard/iq/dataverse/search/SearchFields.java index 1f1137016f2..712f90186f5 100644 --- a/src/main/java/edu/harvard/iq/dataverse/search/SearchFields.java +++ b/src/main/java/edu/harvard/iq/dataverse/search/SearchFields.java @@ -171,6 +171,7 @@ public class SearchFields { public static final String FILE_CHECKSUM_TYPE = "fileChecksumType"; public static final String FILE_CHECKSUM_VALUE = "fileChecksumValue"; public static final String FILENAME_WITHOUT_EXTENSION = "fileNameWithoutExtension"; + public static final String FILE_RESTRICTED = "fileRestricted"; /** * Indexed as a string so we can facet on it. */ @@ -270,6 +271,8 @@ more targeted results for just datasets. The format is YYYY (i.e. */ public static final String DATASET_TYPE = "datasetType"; + public static final String OBSERVATIONS = "observations"; + public static final String VARIABLE_COUNT = "variableCount"; public static final String VARIABLE_NAME = "variableName"; public static final String VARIABLE_LABEL = "variableLabel"; public static final String LITERAL_QUESTION = "literalQuestion"; diff --git a/src/main/java/edu/harvard/iq/dataverse/search/SearchServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/search/SearchServiceBean.java index 3fd97d418c0..60bcc9f846e 100644 --- a/src/main/java/edu/harvard/iq/dataverse/search/SearchServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/search/SearchServiceBean.java @@ -1,6 +1,7 @@ package edu.harvard.iq.dataverse.search; import edu.harvard.iq.dataverse.*; +import edu.harvard.iq.dataverse.authorization.Permission; import edu.harvard.iq.dataverse.authorization.groups.Group; import edu.harvard.iq.dataverse.authorization.groups.GroupServiceBean; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; @@ -18,6 +19,7 @@ import java.util.Calendar; import java.util.Collections; import java.util.Date; +import java.util.EnumSet; import java.util.HashMap; import java.util.LinkedList; import java.util.List; @@ -75,6 +77,8 @@ public class SearchServiceBean { SystemConfig systemConfig; @EJB SolrClientService solrClientService; + @EJB + PermissionServiceBean permissionService; @Inject ThumbnailServiceWrapper thumbnailServiceWrapper; @@ -677,6 +681,15 @@ public SolrQueryResponse search( logger.info("Exception setting setFileChecksumType: " + ex); } solrSearchResult.setFileChecksumValue((String) solrDocument.getFieldValue(SearchFields.FILE_CHECKSUM_VALUE)); + + if (solrDocument.getFieldValue(SearchFields.FILE_RESTRICTED) != null) { + solrSearchResult.setFileRestricted((Boolean) solrDocument.getFieldValue(SearchFields.FILE_RESTRICTED)); + } + + if (solrSearchResult.getEntity() != null) { + solrSearchResult.setCanDownloadFile(permissionService.hasPermissionsFor(dataverseRequest, solrSearchResult.getEntity(), EnumSet.of(Permission.DownloadFile))); + } + solrSearchResult.setUnf((String) solrDocument.getFieldValue(SearchFields.UNF)); solrSearchResult.setDatasetVersionId(datasetVersionId); List fileCategories = (List) solrDocument.getFieldValues(SearchFields.FILE_TAG); @@ -688,6 +701,10 @@ public SolrQueryResponse search( Collections.sort(tabularDataTags); solrSearchResult.setTabularDataTags(tabularDataTags); } + Long observations = (Long) solrDocument.getFieldValue(SearchFields.OBSERVATIONS); + solrSearchResult.setObservations(observations); + Long tabCount = (Long) solrDocument.getFieldValue(SearchFields.VARIABLE_COUNT); + solrSearchResult.setTabularDataCount(tabCount); String filePID = (String) solrDocument.getFieldValue(SearchFields.FILE_PERSISTENT_ID); if(null != filePID && !"".equals(filePID) && !"".equals("null")) { solrSearchResult.setFilePersistentId(filePID); diff --git a/src/main/java/edu/harvard/iq/dataverse/search/SolrSearchResult.java b/src/main/java/edu/harvard/iq/dataverse/search/SolrSearchResult.java index 8802555affd..70e9a549554 100644 --- a/src/main/java/edu/harvard/iq/dataverse/search/SolrSearchResult.java +++ b/src/main/java/edu/harvard/iq/dataverse/search/SolrSearchResult.java @@ -97,6 +97,8 @@ public class SolrSearchResult { private String fileMd5; private DataFile.ChecksumType fileChecksumType; private String fileChecksumValue; + private Boolean fileRestricted; + private Boolean canDownloadFile; private String dataverseAlias; private String dataverseParentAlias; private String dataverseParentName; @@ -122,6 +124,8 @@ public class SolrSearchResult { private String harvestingDescription = null; private List fileCategories = null; private List tabularDataTags = null; + private Long tabularDataCount; + private Long observations; private String identifierOfDataverse = null; private String nameOfDataverse = null; @@ -565,7 +569,12 @@ public JsonObjectBuilder json(boolean showRelevance, boolean showEntityIds, bool .add("citationHtml", this.citationHtml) .add("identifier_of_dataverse", this.identifierOfDataverse) .add("name_of_dataverse", this.nameOfDataverse) - .add("citation", this.citation); + .add("citation", this.citation) + .add("restricted", this.fileRestricted) + .add("variables", this.tabularDataCount) + .add("observations", this.observations) + .add("canDownloadFile", this.canDownloadFile); + // Now that nullSafeJsonBuilder has been instatiated, check for null before adding to it! if (showRelevance) { nullSafeJsonBuilder.add("matches", getRelevance()); @@ -579,6 +588,9 @@ public JsonObjectBuilder json(boolean showRelevance, boolean showEntityIds, bool if (!getPublicationStatuses().isEmpty()) { nullSafeJsonBuilder.add("publicationStatuses", getPublicationStatusesAsJSON()); } + if (this.fileCategories != null && !this.fileCategories.isEmpty()) { + nullSafeJsonBuilder.add("categories", JsonPrinter.asJsonArray(this.fileCategories)); + } if (this.entity == null) { @@ -956,6 +968,18 @@ public List getTabularDataTags() { public void setTabularDataTags(List tabularDataTags) { this.tabularDataTags = tabularDataTags; } + public void setTabularDataCount(Long tabularDataCount) { + this.tabularDataCount = tabularDataCount; + } + public Long getTabularDataCount() { + return tabularDataCount; + } + public Long getObservations() { + return observations; + } + public void setObservations(Long observations) { + this.observations = observations; + } public Map getParent() { return parent; @@ -1078,6 +1102,15 @@ public void setFileChecksumValue(String fileChecksumValue) { this.fileChecksumValue = fileChecksumValue; } + public Boolean getFileRestricted() { return fileRestricted; } + public void setFileRestricted(Boolean fileRestricted) { + this.fileRestricted = fileRestricted; + } + public Boolean getCanDownloadFile() { return canDownloadFile; } + public void setCanDownloadFile(Boolean canDownloadFile) { + this.canDownloadFile = canDownloadFile; + } + public String getNameSort() { return nameSort; } diff --git a/src/main/java/edu/harvard/iq/dataverse/util/json/NullSafeJsonBuilder.java b/src/main/java/edu/harvard/iq/dataverse/util/json/NullSafeJsonBuilder.java index ef8ab39122f..21360fcd708 100644 --- a/src/main/java/edu/harvard/iq/dataverse/util/json/NullSafeJsonBuilder.java +++ b/src/main/java/edu/harvard/iq/dataverse/util/json/NullSafeJsonBuilder.java @@ -85,7 +85,10 @@ public NullSafeJsonBuilder add(String name, boolean value) { delegate.add(name, value); return this; } - + public NullSafeJsonBuilder add(String name, Boolean value) { + return (value != null) ? add(name, value.booleanValue()) : this; + } + @Override public NullSafeJsonBuilder addNull(String name) { delegate.addNull(name); diff --git a/src/test/java/edu/harvard/iq/dataverse/api/SearchIT.java b/src/test/java/edu/harvard/iq/dataverse/api/SearchIT.java index b03c23cd1e2..8338caff9ef 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/SearchIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/SearchIT.java @@ -4,6 +4,7 @@ import io.restassured.path.json.JsonPath; import io.restassured.response.Response; import edu.harvard.iq.dataverse.settings.SettingsServiceBean; +import java.util.UUID; import java.util.logging.Level; import java.util.logging.Logger; import jakarta.json.Json; @@ -1300,8 +1301,12 @@ public void testSearchFilesAndUrlImages() { System.out.println("id: " + datasetId); String datasetPid = JsonPath.from(createDatasetResponse.getBody().asString()).getString("data.persistentId"); System.out.println("datasetPid: " + datasetPid); - String pathToFile = "src/main/webapp/resources/images/dataverseproject.png"; + Response logoResponse = UtilIT.uploadDatasetLogo(datasetPid, pathToFile, apiToken); + logoResponse.prettyPrint(); + logoResponse.then().assertThat() + .statusCode(200); + Response uploadImage = UtilIT.uploadFileViaNative(datasetId.toString(), pathToFile, apiToken); uploadImage.prettyPrint(); uploadImage.then().assertThat() @@ -1311,7 +1316,16 @@ public void testSearchFilesAndUrlImages() { uploadFile.prettyPrint(); uploadFile.then().assertThat() .statusCode(200); - + pathToFile = "src/test/resources/tab/test.tab"; + String searchableUniqueId = "testtab"+ UUID.randomUUID().toString().substring(0, 8); // so the search only returns 1 file + JsonObjectBuilder json = Json.createObjectBuilder() + .add("description", searchableUniqueId) + .add("restrict", "true") + .add("categories", Json.createArrayBuilder().add("Data")); + Response uploadTabFile = UtilIT.uploadFileViaNative(datasetId.toString(), pathToFile, json.build(), apiToken); + uploadTabFile.prettyPrint(); + uploadTabFile.then().assertThat() + .statusCode(200); Response publishDataverse = UtilIT.publishDataverseViaSword(dataverseAlias, apiToken); publishDataverse.prettyPrint(); publishDataverse.then().assertThat() @@ -1339,6 +1353,13 @@ public void testSearchFilesAndUrlImages() { .body("data.items[0].url", CoreMatchers.containsString("/dataverse/")) .body("data.items[0]", CoreMatchers.not(CoreMatchers.hasItem("image_url"))); + searchResp = UtilIT.search(datasetPid, apiToken); + searchResp.prettyPrint(); + searchResp.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.items[0].type", CoreMatchers.is("dataset")) + .body("data.items[0].image_url", CoreMatchers.containsString("/logo")); + searchResp = UtilIT.search("mydata", apiToken); searchResp.prettyPrint(); searchResp.then().assertThat() @@ -1346,5 +1367,16 @@ public void testSearchFilesAndUrlImages() { .body("data.items[0].type", CoreMatchers.is("file")) .body("data.items[0].url", CoreMatchers.containsString("/datafile/")) .body("data.items[0]", CoreMatchers.not(CoreMatchers.hasItem("image_url"))); + searchResp = UtilIT.search(searchableUniqueId, apiToken); + searchResp.prettyPrint(); + searchResp.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.items[0].type", CoreMatchers.is("file")) + .body("data.items[0].url", CoreMatchers.containsString("/datafile/")) + .body("data.items[0].variables", CoreMatchers.is(3)) + .body("data.items[0].observations", CoreMatchers.is(10)) + .body("data.items[0].restricted", CoreMatchers.is(true)) + .body("data.items[0].canDownloadFile", CoreMatchers.is(true)) + .body("data.items[0]", CoreMatchers.not(CoreMatchers.hasItem("image_url"))); } } From ea79a8fb2d92783d43bbe6e8b6d198d02d2b04fa Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Mon, 16 Dec 2024 08:17:50 -0500 Subject: [PATCH 091/101] fix style --- .../java/edu/harvard/iq/dataverse/search/SolrSearchResult.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/edu/harvard/iq/dataverse/search/SolrSearchResult.java b/src/main/java/edu/harvard/iq/dataverse/search/SolrSearchResult.java index 70e9a549554..31ead80e98a 100644 --- a/src/main/java/edu/harvard/iq/dataverse/search/SolrSearchResult.java +++ b/src/main/java/edu/harvard/iq/dataverse/search/SolrSearchResult.java @@ -1103,10 +1103,12 @@ public void setFileChecksumValue(String fileChecksumValue) { } public Boolean getFileRestricted() { return fileRestricted; } + public void setFileRestricted(Boolean fileRestricted) { this.fileRestricted = fileRestricted; } public Boolean getCanDownloadFile() { return canDownloadFile; } + public void setCanDownloadFile(Boolean canDownloadFile) { this.canDownloadFile = canDownloadFile; } From 4d28d0dec83e8f8c90d7ce1563f775b99f99dc45 Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Mon, 16 Dec 2024 08:25:03 -0500 Subject: [PATCH 092/101] fix style --- .../edu/harvard/iq/dataverse/search/SolrSearchResult.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/search/SolrSearchResult.java b/src/main/java/edu/harvard/iq/dataverse/search/SolrSearchResult.java index 31ead80e98a..4b394a7bc5e 100644 --- a/src/main/java/edu/harvard/iq/dataverse/search/SolrSearchResult.java +++ b/src/main/java/edu/harvard/iq/dataverse/search/SolrSearchResult.java @@ -1102,12 +1102,16 @@ public void setFileChecksumValue(String fileChecksumValue) { this.fileChecksumValue = fileChecksumValue; } - public Boolean getFileRestricted() { return fileRestricted; } + public Boolean getFileRestricted() { + return fileRestricted; + } public void setFileRestricted(Boolean fileRestricted) { this.fileRestricted = fileRestricted; } - public Boolean getCanDownloadFile() { return canDownloadFile; } + public Boolean getCanDownloadFile() { + return canDownloadFile; + } public void setCanDownloadFile(Boolean canDownloadFile) { this.canDownloadFile = canDownloadFile; From 3fd62f6224fb5e8bf1f11554559495f3e83582b0 Mon Sep 17 00:00:00 2001 From: Philip Durbin Date: Mon, 16 Dec 2024 09:16:27 -0500 Subject: [PATCH 093/101] make changelog for 6.6, reword #10764 --- doc/sphinx-guides/source/api/changelog.rst | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/doc/sphinx-guides/source/api/changelog.rst b/doc/sphinx-guides/source/api/changelog.rst index f94124765d3..162574e7799 100644 --- a/doc/sphinx-guides/source/api/changelog.rst +++ b/doc/sphinx-guides/source/api/changelog.rst @@ -7,6 +7,11 @@ This API changelog is experimental and we would love feedback on its usefulness. :local: :depth: 1 +v6.6 +---- + +- **/api/metadatablocks** is no longer returning duplicated metadata properties and does not omit metadata properties when called. + v6.5 ---- @@ -15,7 +20,6 @@ v6.5 v6.4 ---- -- /api/metadatablocks is now returning no duplicated metadata properties and does not ommit metadata properties when called. The JsonPrinter class output is fixed. - **/api/datasets/$dataset-id/modifyRegistration**: Changed from GET to POST - **/api/datasets/modifyRegistrationPIDMetadataAll**: Changed from GET to POST From aad328e49400d174a64166c7dd7c089a9dfdb778 Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Mon, 16 Dec 2024 11:40:44 -0500 Subject: [PATCH 094/101] adding release notes --- ...7-extend-datasets-files-from-search-api.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 doc/release-notes/11027-extend-datasets-files-from-search-api.md diff --git a/doc/release-notes/11027-extend-datasets-files-from-search-api.md b/doc/release-notes/11027-extend-datasets-files-from-search-api.md new file mode 100644 index 00000000000..92924eee3ac --- /dev/null +++ b/doc/release-notes/11027-extend-datasets-files-from-search-api.md @@ -0,0 +1,19 @@ +### Feature to extend Search API for SPA + +Added new fields to search results type=files + +For Files: +- restricted: boolean +- canDownloadFile: boolean ( from file user permission) +- categories: array of string "categories" would be similar to what it is in metadata api. +For tabular files: +- variables: number/int shows how many variables we have for the tabular file +- observations: number/int shows how many observations for the tabular file + + + +New fields added to solr schema.xml: + + + +See https://github.com/IQSS/dataverse/issues/11027 From 3d9da8050c07cb62f10f71cf9a380a4821f5a28f Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Mon, 16 Dec 2024 13:33:21 -0500 Subject: [PATCH 095/101] change schema.xml --- conf/solr/schema.xml | 2 +- .../11027-extend-datasets-files-from-search-api.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/conf/solr/schema.xml b/conf/solr/schema.xml index 380e4fc4da5..f4121de97c1 100644 --- a/conf/solr/schema.xml +++ b/conf/solr/schema.xml @@ -204,7 +204,7 @@ - + diff --git a/doc/release-notes/11027-extend-datasets-files-from-search-api.md b/doc/release-notes/11027-extend-datasets-files-from-search-api.md index 92924eee3ac..68f5c340298 100644 --- a/doc/release-notes/11027-extend-datasets-files-from-search-api.md +++ b/doc/release-notes/11027-extend-datasets-files-from-search-api.md @@ -14,6 +14,6 @@ For tabular files: New fields added to solr schema.xml: - + See https://github.com/IQSS/dataverse/issues/11027 From 135d9cb689cbc496c779f8d42a34096e256189da Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Mon, 16 Dec 2024 14:56:10 -0500 Subject: [PATCH 096/101] adding tabularTags --- .../11027-extend-datasets-files-from-search-api.md | 1 + .../iq/dataverse/search/SolrSearchResult.java | 3 +++ .../edu/harvard/iq/dataverse/api/SearchIT.java | 14 +++++++++++++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/doc/release-notes/11027-extend-datasets-files-from-search-api.md b/doc/release-notes/11027-extend-datasets-files-from-search-api.md index 68f5c340298..3a4c41e64fc 100644 --- a/doc/release-notes/11027-extend-datasets-files-from-search-api.md +++ b/doc/release-notes/11027-extend-datasets-files-from-search-api.md @@ -7,6 +7,7 @@ For Files: - canDownloadFile: boolean ( from file user permission) - categories: array of string "categories" would be similar to what it is in metadata api. For tabular files: +- tabularTags: array of string for example,{"tabularTags" : ["Event", "Genomics", "Geospatial"]} - variables: number/int shows how many variables we have for the tabular file - observations: number/int shows how many observations for the tabular file diff --git a/src/main/java/edu/harvard/iq/dataverse/search/SolrSearchResult.java b/src/main/java/edu/harvard/iq/dataverse/search/SolrSearchResult.java index 4b394a7bc5e..2250a245dab 100644 --- a/src/main/java/edu/harvard/iq/dataverse/search/SolrSearchResult.java +++ b/src/main/java/edu/harvard/iq/dataverse/search/SolrSearchResult.java @@ -591,6 +591,9 @@ public JsonObjectBuilder json(boolean showRelevance, boolean showEntityIds, bool if (this.fileCategories != null && !this.fileCategories.isEmpty()) { nullSafeJsonBuilder.add("categories", JsonPrinter.asJsonArray(this.fileCategories)); } + if (this.tabularDataTags != null && !this.tabularDataTags.isEmpty()) { + nullSafeJsonBuilder.add("tabularTags", JsonPrinter.asJsonArray(this.tabularDataTags)); + } if (this.entity == null) { diff --git a/src/test/java/edu/harvard/iq/dataverse/api/SearchIT.java b/src/test/java/edu/harvard/iq/dataverse/api/SearchIT.java index 8338caff9ef..f40d6a2e62d 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/SearchIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/SearchIT.java @@ -4,6 +4,8 @@ import io.restassured.path.json.JsonPath; import io.restassured.response.Response; import edu.harvard.iq.dataverse.settings.SettingsServiceBean; + +import java.util.List; import java.util.UUID; import java.util.logging.Level; import java.util.logging.Logger; @@ -30,6 +32,7 @@ import jakarta.json.JsonObjectBuilder; import static jakarta.ws.rs.core.Response.Status.*; +import static java.lang.Thread.sleep; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -1285,7 +1288,7 @@ public static void cleanup() { } @Test - public void testSearchFilesAndUrlImages() { + public void testSearchFilesAndUrlImages() throws InterruptedException { Response createUser = UtilIT.createRandomUser(); createUser.prettyPrint(); String username = UtilIT.getUsernameFromResponse(createUser); @@ -1326,6 +1329,14 @@ public void testSearchFilesAndUrlImages() { uploadTabFile.prettyPrint(); uploadTabFile.then().assertThat() .statusCode(200); + // Ensure tabular file is ingested + sleep(2000); + // Set tabular tags + String tabularFileId = uploadTabFile.getBody().jsonPath().getString("data.files[0].dataFile.id"); + List testTabularTags = List.of("Survey", "Genomics"); + Response setFileTabularTagsResponse = UtilIT.setFileTabularTags(tabularFileId, apiToken, testTabularTags); + setFileTabularTagsResponse.then().assertThat().statusCode(OK.getStatusCode()); + Response publishDataverse = UtilIT.publishDataverseViaSword(dataverseAlias, apiToken); publishDataverse.prettyPrint(); publishDataverse.then().assertThat() @@ -1377,6 +1388,7 @@ public void testSearchFilesAndUrlImages() { .body("data.items[0].observations", CoreMatchers.is(10)) .body("data.items[0].restricted", CoreMatchers.is(true)) .body("data.items[0].canDownloadFile", CoreMatchers.is(true)) + .body("data.items[0].tabularTags", CoreMatchers.hasItem("Genomics")) .body("data.items[0]", CoreMatchers.not(CoreMatchers.hasItem("image_url"))); } } From b2e271a403ce6a02a1b60183fcb5698374975f57 Mon Sep 17 00:00:00 2001 From: Florian Fritze Date: Wed, 18 Dec 2024 09:43:36 +0100 Subject: [PATCH 097/101] fix the tests: DataversesIT and MetadataBlocksIT --- .../harvard/iq/dataverse/api/DataversesIT.java | 3 ++- .../iq/dataverse/api/MetadataBlocksIT.java | 15 +++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/test/java/edu/harvard/iq/dataverse/api/DataversesIT.java b/src/test/java/edu/harvard/iq/dataverse/api/DataversesIT.java index 0c5ac8f4260..2fc66ae2955 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/DataversesIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/DataversesIT.java @@ -927,7 +927,8 @@ public void testListMetadataBlocks() { .body("data.size()", equalTo(1)) .body("data[0].name", is("citation")) .body("data[0].fields.title.displayOnCreate", equalTo(true)) - .body("data[0].fields.size()", is(28)); + .body("data[0].fields.size()", is(10)) + .body("data[0].fields.author.childFields.size()", is(4)); Response setMetadataBlocksResponse = UtilIT.setMetadataBlocks(dataverseAlias, Json.createArrayBuilder().add("citation").add("astrophysics"), apiToken); setMetadataBlocksResponse.then().assertThat().statusCode(OK.getStatusCode()); diff --git a/src/test/java/edu/harvard/iq/dataverse/api/MetadataBlocksIT.java b/src/test/java/edu/harvard/iq/dataverse/api/MetadataBlocksIT.java index 6e7061961f0..242d8f82db4 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/MetadataBlocksIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/MetadataBlocksIT.java @@ -1,6 +1,7 @@ package edu.harvard.iq.dataverse.api; import io.restassured.RestAssured; + import io.restassured.response.Response; import org.hamcrest.CoreMatchers; import org.junit.jupiter.api.BeforeAll; @@ -9,6 +10,7 @@ import static jakarta.ws.rs.core.Response.Status.CREATED; import static jakarta.ws.rs.core.Response.Status.OK; import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assumptions.assumeFalse; @@ -42,22 +44,27 @@ void testListMetadataBlocks() { // returnDatasetFieldTypes=true listMetadataBlocksResponse = UtilIT.listMetadataBlocks(false, true); - int expectedNumberOfMetadataFields = 80; + int expectedNumberOfMetadataFields = 35; + listMetadataBlocksResponse.prettyPrint(); listMetadataBlocksResponse.then().assertThat() .statusCode(OK.getStatusCode()) .body("data[0].fields", not(equalTo(null))) .body("data[0].fields.size()", equalTo(expectedNumberOfMetadataFields)) - .body("data.size()", equalTo(expectedDefaultNumberOfMetadataBlocks)); + .body("data.size()", equalTo(expectedDefaultNumberOfMetadataBlocks)) + .body("data[1].fields.geographicCoverage.childFields.size()", is(4)) + .body("data[0].fields.publication.childFields.size()", is(5)); // onlyDisplayedOnCreate=true and returnDatasetFieldTypes=true listMetadataBlocksResponse = UtilIT.listMetadataBlocks(true, true); - expectedNumberOfMetadataFields = 28; + listMetadataBlocksResponse.prettyPrint(); + expectedNumberOfMetadataFields = 10; listMetadataBlocksResponse.then().assertThat() .statusCode(OK.getStatusCode()) .body("data[0].fields", not(equalTo(null))) .body("data[0].fields.size()", equalTo(expectedNumberOfMetadataFields)) .body("data[0].displayName", equalTo("Citation Metadata")) - .body("data.size()", equalTo(expectedOnlyDisplayedOnCreateNumberOfMetadataBlocks)); + .body("data.size()", equalTo(expectedOnlyDisplayedOnCreateNumberOfMetadataBlocks)) + .body("data[0].fields.author.childFields.size()", is(4)); } @Test From 957adab17f2b1b11ba8eab02a12e54931c034a09 Mon Sep 17 00:00:00 2001 From: Florian Fritze Date: Wed, 18 Dec 2024 09:53:28 +0100 Subject: [PATCH 098/101] removed the tab --- src/test/java/edu/harvard/iq/dataverse/api/DataversesIT.java | 2 +- .../java/edu/harvard/iq/dataverse/api/MetadataBlocksIT.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/edu/harvard/iq/dataverse/api/DataversesIT.java b/src/test/java/edu/harvard/iq/dataverse/api/DataversesIT.java index 2fc66ae2955..13c4c30190b 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/DataversesIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/DataversesIT.java @@ -928,7 +928,7 @@ public void testListMetadataBlocks() { .body("data[0].name", is("citation")) .body("data[0].fields.title.displayOnCreate", equalTo(true)) .body("data[0].fields.size()", is(10)) - .body("data[0].fields.author.childFields.size()", is(4)); + .body("data[0].fields.author.childFields.size()", is(4)); Response setMetadataBlocksResponse = UtilIT.setMetadataBlocks(dataverseAlias, Json.createArrayBuilder().add("citation").add("astrophysics"), apiToken); setMetadataBlocksResponse.then().assertThat().statusCode(OK.getStatusCode()); diff --git a/src/test/java/edu/harvard/iq/dataverse/api/MetadataBlocksIT.java b/src/test/java/edu/harvard/iq/dataverse/api/MetadataBlocksIT.java index 242d8f82db4..3b0b56740eb 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/MetadataBlocksIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/MetadataBlocksIT.java @@ -64,7 +64,7 @@ void testListMetadataBlocks() { .body("data[0].fields.size()", equalTo(expectedNumberOfMetadataFields)) .body("data[0].displayName", equalTo("Citation Metadata")) .body("data.size()", equalTo(expectedOnlyDisplayedOnCreateNumberOfMetadataBlocks)) - .body("data[0].fields.author.childFields.size()", is(4)); + .body("data[0].fields.author.childFields.size()", is(4)); } @Test From 1e4715322372fcb58bf5ac9cb750d0041993b039 Mon Sep 17 00:00:00 2001 From: Stephen Kraffmiller Date: Wed, 18 Dec 2024 09:59:23 -0500 Subject: [PATCH 099/101] #11027 update release notes for schema.xml --- .../11027-extend-datasets-files-from-search-api.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/doc/release-notes/11027-extend-datasets-files-from-search-api.md b/doc/release-notes/11027-extend-datasets-files-from-search-api.md index 3a4c41e64fc..7b20daeeb0f 100644 --- a/doc/release-notes/11027-extend-datasets-files-from-search-api.md +++ b/doc/release-notes/11027-extend-datasets-files-from-search-api.md @@ -13,8 +13,10 @@ For tabular files: -New fields added to solr schema.xml: +New fields added to solr schema.xml (Note: upgrade instructions will need to include instructions for schema.xml): + + See https://github.com/IQSS/dataverse/issues/11027 From ec77873293d555838177b398e6d143751bd37101 Mon Sep 17 00:00:00 2001 From: Jim Myers Date: Wed, 18 Dec 2024 11:51:11 -0500 Subject: [PATCH 100/101] Change per request https://github.com/IQSS/dataverse/pull/11048#issuecomment-2551721927 --- src/main/java/propertyFiles/Bundle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index 04f81cb336d..34e74791ee8 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -1745,7 +1745,7 @@ dataset.privateurl.anonymous.description.paragraph.two=The dataset's files are n dataset.privateurl.createPrivateUrl=Create Preview URL dataset.privateurl.introduction=You can create a Preview URL to copy and share with others who will not need a repository account to review this unpublished dataset version. Once the dataset is published or if the URL is disabled, the URL will no longer work and will point to a "Page not found" page. dataset.privateurl.createPrivateUrl.anonymized=Create URL for Anonymized Access -dataset.privateurl.createPrivateUrl.anonymized.unavailable=Anonymized Access is not available once a version of the dataset has been published +dataset.privateurl.createPrivateUrl.anonymized.unavailable=You won't be able to create an Anonymous Preview URL once a version of this dataset has been published. dataset.privateurl.disableGeneralPreviewUrl=Disable General Preview URL dataset.privateurl.disableAnonPreviewUrl=Disable Anonymous Preview URL dataset.privateurl.disableGeneralPreviewUrlConfirm=Yes, Disable General Preview URL From b9f99643dc370707b35d36b8df8b430ccc6fd7ed Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 20 Dec 2024 17:06:36 +0000 Subject: [PATCH 101/101] Bump actions/cache from 2 to 4 Bumps [actions/cache](https://github.com/actions/cache) from 2 to 4. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v2...v4) --- updated-dependencies: - dependency-name: actions/cache dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/spi_release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/spi_release.yml b/.github/workflows/spi_release.yml index 54718320d1e..6398edca412 100644 --- a/.github/workflows/spi_release.yml +++ b/.github/workflows/spi_release.yml @@ -45,7 +45,7 @@ jobs: server-id: ossrh server-username: MAVEN_USERNAME server-password: MAVEN_PASSWORD - - uses: actions/cache@v2 + - uses: actions/cache@v4 with: path: ~/.m2 key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} @@ -68,7 +68,7 @@ jobs: with: java-version: '17' distribution: 'adopt' - - uses: actions/cache@v2 + - uses: actions/cache@v4 with: path: ~/.m2 key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}