From 1d5377a50ab0630621c0b5e33352cff27ca21d38 Mon Sep 17 00:00:00 2001 From: qqmyers Date: Wed, 24 Feb 2021 17:06:18 -0500 Subject: [PATCH 001/137] 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/137] 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/137] 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/137] 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/137] 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/137] 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/137] 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/137] 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/137] 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 fa804197bbae4d6da4f32167eff4a298546cf05b Mon Sep 17 00:00:00 2001 From: Vera Clemens Date: Fri, 27 Sep 2024 13:54:53 +0200 Subject: [PATCH 010/137] feat: index numerical and date fields in Solr with appropriate types --- conf/solr/schema.xml | 2 ++ .../java/edu/harvard/iq/dataverse/DatasetFieldType.java | 9 ++++----- .../harvard/iq/dataverse/search/IndexServiceBean.java | 4 +++- .../harvard/iq/dataverse/search/SearchServiceBean.java | 2 +- .../java/edu/harvard/iq/dataverse/search/SolrField.java | 2 +- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/conf/solr/schema.xml b/conf/solr/schema.xml index 2aed50e9998..02e699722f7 100644 --- a/conf/solr/schema.xml +++ b/conf/solr/schema.xml @@ -814,6 +814,8 @@ + + diff --git a/src/main/java/edu/harvard/iq/dataverse/DatasetFieldType.java b/src/main/java/edu/harvard/iq/dataverse/DatasetFieldType.java index 01785359e0e..2c385268fa5 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DatasetFieldType.java +++ b/src/main/java/edu/harvard/iq/dataverse/DatasetFieldType.java @@ -531,15 +531,14 @@ public String getDisplayName() { public SolrField getSolrField() { SolrField.SolrType solrType = SolrField.SolrType.TEXT_EN; if (fieldType != null) { - - /** - * @todo made more decisions based on fieldType: index as dates, - * integers, and floats so we can do range queries etc. - */ if (fieldType.equals(FieldType.DATE)) { solrType = SolrField.SolrType.DATE; } else if (fieldType.equals(FieldType.EMAIL)) { solrType = SolrField.SolrType.EMAIL; + } else if (fieldType.equals(FieldType.INT)) { + solrType = SolrField.SolrType.INTEGER; + } else if (fieldType.equals(FieldType.FLOAT)) { + solrType = SolrField.SolrType.FLOAT; } Boolean parentAllowsMultiplesBoolean = false; 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 a8cf9ed519b..e73b8d2f679 100644 --- a/src/main/java/edu/harvard/iq/dataverse/search/IndexServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/search/IndexServiceBean.java @@ -1061,6 +1061,8 @@ public SolrInputDocuments toSolrDocs(IndexableDataset indexableDataset, Set datasetFields = datasetFieldService.findAllOrderedById(); Map solrFieldsToHightlightOnMap = new HashMap<>(); if (addHighlights) { - solrQuery.setHighlight(true).setHighlightSnippets(1); + solrQuery.setHighlight(true).setHighlightSnippets(1).setHighlightRequireFieldMatch(true); Integer fragSize = systemConfig.getSearchHighlightFragmentSize(); if (fragSize != null) { solrQuery.setHighlightFragsize(fragSize); diff --git a/src/main/java/edu/harvard/iq/dataverse/search/SolrField.java b/src/main/java/edu/harvard/iq/dataverse/search/SolrField.java index ca9805b6c57..7092a01beb1 100644 --- a/src/main/java/edu/harvard/iq/dataverse/search/SolrField.java +++ b/src/main/java/edu/harvard/iq/dataverse/search/SolrField.java @@ -63,7 +63,7 @@ public enum SolrType { * support range queries) in * https://github.com/IQSS/dataverse/issues/370 */ - STRING("string"), TEXT_EN("text_en"), INTEGER("int"), LONG("long"), DATE("text_en"), EMAIL("text_en"); + STRING("string"), TEXT_EN("text_en"), INTEGER("plong"), FLOAT("pdouble"), DATE("date_range"), EMAIL("text_en"); private String type; From 3f5919b6ac5eced7b7bfa25eb6ce1a2f3b448326 Mon Sep 17 00:00:00 2001 From: Vera Clemens Date: Fri, 27 Sep 2024 15:43:05 +0200 Subject: [PATCH 011/137] docs: add release note snippet for #10887 --- doc/release-notes/10887-solr-field-types.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 doc/release-notes/10887-solr-field-types.md diff --git a/doc/release-notes/10887-solr-field-types.md b/doc/release-notes/10887-solr-field-types.md new file mode 100644 index 00000000000..ca5b210cb21 --- /dev/null +++ b/doc/release-notes/10887-solr-field-types.md @@ -0,0 +1,11 @@ +This release enhances how numerical and date fields are indexed in Solr. Previously, all fields were indexed as English text (text_en), but with this update: + +* Integer fields are indexed as `plong` +* Float fields are indexed as `pdouble` +* Date fields are indexed as `date_range` (`solr.DateRangeField`) + +This enables range queries via the search bar or API, such as `exampleIntegerField:[25 TO 50]` or `exampleDateField:[2000-11-01 TO 2014-12-01]`. + +To activate this feature, Dataverse administrators must update their Solr schema.xml (manually or by rerunning `update-fields.sh`) and reindex all datasets. + +Additionally, search result highlighting is now more accurate, ensuring that only fields relevant to the query are highlighted in search results. If the query is specifically limited to certain fields, the highlighting is now limited to those fields as well. \ No newline at end of file From 42f64cb2e11ff73f8d1668ffa58219e15f19a84d Mon Sep 17 00:00:00 2001 From: Vera Clemens Date: Thu, 17 Oct 2024 14:28:20 +0200 Subject: [PATCH 012/137] test: add test for range search queries for ints, floats and dates --- .../harvard/iq/dataverse/api/SearchIT.java | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) 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 3a2b684c421..8850b7ce7c2 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/SearchIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/SearchIT.java @@ -1269,6 +1269,199 @@ public void testGeospatialSearchInvalid() { } + @Test + public void testRangeQueries() { + + Response createUser = UtilIT.createRandomUser(); + createUser.prettyPrint(); + String username = UtilIT.getUsernameFromResponse(createUser); + String apiToken = UtilIT.getApiTokenFromResponse(createUser); + + Response createDataverseResponse = UtilIT.createRandomDataverse(apiToken); + createDataverseResponse.prettyPrint(); + String dataverseAlias = UtilIT.getAliasFromResponse(createDataverseResponse); + + // Using the "astrophysics" block because it contains all field types relevant for range queries + // (int, float and date) + Response setMetadataBlocks = UtilIT.setMetadataBlocks(dataverseAlias, Json.createArrayBuilder().add("citation").add("astrophysics"), apiToken); + setMetadataBlocks.prettyPrint(); + setMetadataBlocks.then().assertThat().statusCode(OK.getStatusCode()); + + JsonObjectBuilder datasetJson = Json.createObjectBuilder() + .add("datasetVersion", Json.createObjectBuilder() + .add("metadataBlocks", Json.createObjectBuilder() + .add("citation", Json.createObjectBuilder() + .add("fields", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("typeName", "title") + .add("value", "Test Astrophysics Dataset") + .add("typeClass", "primitive") + .add("multiple", false) + ) + .add(Json.createObjectBuilder() + .add("value", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("authorName", + Json.createObjectBuilder() + .add("value", "Simpson, Homer") + .add("typeClass", "primitive") + .add("multiple", false) + .add("typeName", "authorName")) + ) + ) + .add("typeClass", "compound") + .add("multiple", true) + .add("typeName", "author") + ) + .add(Json.createObjectBuilder() + .add("value", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("datasetContactEmail", + Json.createObjectBuilder() + .add("value", "hsimpson@mailinator.com") + .add("typeClass", "primitive") + .add("multiple", false) + .add("typeName", "datasetContactEmail")) + ) + ) + .add("typeClass", "compound") + .add("multiple", true) + .add("typeName", "datasetContact") + ) + .add(Json.createObjectBuilder() + .add("value", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("dsDescriptionValue", + Json.createObjectBuilder() + .add("value", "This is a test dataset.") + .add("typeClass", "primitive") + .add("multiple", false) + .add("typeName", "dsDescriptionValue")) + ) + ) + .add("typeClass", "compound") + .add("multiple", true) + .add("typeName", "dsDescription") + ) + .add(Json.createObjectBuilder() + .add("value", Json.createArrayBuilder() + .add("Other") + ) + .add("typeClass", "controlledVocabulary") + .add("multiple", true) + .add("typeName", "subject") + ) + ) + ) + .add("astrophysics", Json.createObjectBuilder() + .add("fields", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("typeName", "coverage.Temporal") + .add("typeClass", "compound") + .add("multiple", true) + .add("value", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("coverage.Temporal.StartTime", + Json.createObjectBuilder() + .add("value", "2015-01-01") + .add("typeClass", "primitive") + .add("multiple", false) + .add("typeName", "coverage.Temporal.StartTime") + ) + ) + ) + ) + .add(Json.createObjectBuilder() + .add("typeName", "coverage.ObjectCount") + .add("typeClass", "primitive") + .add("multiple", false) + .add("value", "9000") + ) + .add(Json.createObjectBuilder() + .add("typeName", "coverage.SkyFraction") + .add("typeClass", "primitive") + .add("multiple", false) + .add("value", "0.002") + ) + ) + ) + )); + + Response createDatasetResponse = UtilIT.createDataset(dataverseAlias, datasetJson, apiToken); + createDatasetResponse.prettyPrint(); + Integer datasetId = UtilIT.getDatasetIdFromResponse(createDatasetResponse); + String datasetPid = JsonPath.from(createDatasetResponse.getBody().asString()).getString("data.persistentId"); + + // Integer range query: Hit + Response search1 = UtilIT.search("id:dataset_" + datasetId + "_draft AND coverage.ObjectCount:[1000 TO 10000]", apiToken, "&show_entity_ids=true"); + search1.prettyPrint(); + search1.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.total_count", CoreMatchers.is(1)) + .body("data.count_in_response", CoreMatchers.is(1)) + .body("data.items[0].entity_id", CoreMatchers.is(datasetId)); + + // Integer range query: Miss + Response search2 = UtilIT.search("id:dataset_" + datasetId + "_draft AND coverage.ObjectCount:[* TO 1000]", apiToken); + search2.prettyPrint(); + search2.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.total_count", CoreMatchers.is(0)) + .body("data.count_in_response", CoreMatchers.is(0)); + + // Float range query: Hit + Response search3 = UtilIT.search("id:dataset_" + datasetId + "_draft AND coverage.SkyFraction:[0 TO 0.5]", apiToken, "&show_entity_ids=true"); + search3.prettyPrint(); + search3.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.total_count", CoreMatchers.is(1)) + .body("data.count_in_response", CoreMatchers.is(1)) + .body("data.items[0].entity_id", CoreMatchers.is(datasetId)); + + // Float range query: Miss + Response search4 = UtilIT.search("id:dataset_" + datasetId + "_draft AND coverage.SkyFraction:[0.5 TO 1]", apiToken); + search4.prettyPrint(); + search4.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.total_count", CoreMatchers.is(0)) + .body("data.count_in_response", CoreMatchers.is(0)); + + // Date range query: Hit + Response search5 = UtilIT.search("id:dataset_" + datasetId + "_draft AND coverage.Temporal.StartTime:2015", apiToken, "&show_entity_ids=true"); + search5.prettyPrint(); + search5.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.total_count", CoreMatchers.is(1)) + .body("data.count_in_response", CoreMatchers.is(1)) + .body("data.items[0].entity_id", CoreMatchers.is(datasetId)); + + // Date range query: Miss + Response search6 = UtilIT.search("id:dataset_" + datasetId + "_draft AND coverage.Temporal.StartTime:[2020 TO *]", apiToken); + search6.prettyPrint(); + search6.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.total_count", CoreMatchers.is(0)) + .body("data.count_in_response", CoreMatchers.is(0)); + + // Combining all three range queries: Hit + Response search7 = UtilIT.search("id:dataset_" + datasetId + "_draft AND coverage.ObjectCount:[1000 TO 10000] AND coverage.SkyFraction:[0 TO 0.5] AND coverage.Temporal.StartTime:2015", apiToken, "&show_entity_ids=true"); + search7.prettyPrint(); + search7.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.total_count", CoreMatchers.is(1)) + .body("data.count_in_response", CoreMatchers.is(1)) + .body("data.items[0].entity_id", CoreMatchers.is(datasetId)); + + // Combining all three range queries: Miss + Response search8 = UtilIT.search("id:dataset_" + datasetId + "_draft AND coverage.ObjectCount:[* TO 1000] AND coverage.SkyFraction:[0.5 TO 1] AND coverage.Temporal.StartTime:[2020 TO *]", apiToken); + search8.prettyPrint(); + search8.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.total_count", CoreMatchers.is(0)) + .body("data.count_in_response", CoreMatchers.is(0)); + + } + @AfterEach public void tearDownDataverse() { File treesThumb = new File("scripts/search/data/binary/trees.png.thumb48"); From ed7e38ec57e302f2a40f7491b3360878c6eb187b Mon Sep 17 00:00:00 2001 From: Vera Clemens Date: Fri, 18 Oct 2024 15:59:18 +0200 Subject: [PATCH 013/137] feat: skip indexing of field instead of entire dataset when encountering invalid ints, floats or dates --- .../iq/dataverse/search/IndexServiceBean.java | 102 ++++++++++---- .../harvard/iq/dataverse/api/SearchIT.java | 128 ++++++++++++++++++ 2 files changed, 207 insertions(+), 23 deletions(-) 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 e73b8d2f679..17dc6726a5a 100644 --- a/src/main/java/edu/harvard/iq/dataverse/search/IndexServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/search/IndexServiceBean.java @@ -27,6 +27,8 @@ import java.sql.Timestamp; import java.text.SimpleDateFormat; import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.Calendar; import java.util.Collection; @@ -44,6 +46,7 @@ import java.util.function.Function; import java.util.logging.Level; import java.util.logging.Logger; +import java.util.regex.Pattern; import java.util.stream.Collectors; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; @@ -1060,36 +1063,89 @@ public SolrInputDocuments toSolrDocs(IndexableDataset indexableDataset, Set indexableValues = dsf.getValuesWithoutNaValues().stream() + .filter(s -> intPattern.matcher(s).find()) + .collect(Collectors.toList()); + solrInputDocument.addField(solrFieldSearchable, indexableValues); + if (dsfType.getSolrField().isFacetable()) { + solrInputDocument.addField(solrFieldFacetable, indexableValues); + } + } else if (dsfType.getSolrField().getSolrType().equals(SolrField.SolrType.FLOAT)) { + // same as for integer values, we need to filter invalid float values + List indexableValues = dsf.getValuesWithoutNaValues().stream() + .filter(s -> { + try { + Double.parseDouble(s); + return true; + } catch (NumberFormatException e) { + return false; + } + }) + .collect(Collectors.toList()); + solrInputDocument.addField(solrFieldSearchable, indexableValues); + if (dsfType.getSolrField().isFacetable()) { + solrInputDocument.addField(solrFieldFacetable, indexableValues); + } } else if (dsfType.getSolrField().getSolrType().equals(SolrField.SolrType.DATE)) { - // we index dates as full strings (YYYY, YYYY-MM or YYYY-MM-DD) - // for use in facets, we index only the year (YYYY) + // Solr accepts dates in the ISO-8601 format, e.g. YYYY-MM-DDThh:mm:ssZ, YYYYY-MM-DD, YYYY-MM, YYYY + // See: https://solr.apache.org/guide/solr/latest/indexing-guide/date-formatting-math.html + // If dates have been entered in other formats, we need to skip or convert them + // TODO at the moment we are simply skipping, but converting them would offer more value for search + // For use in facets, we index only the year (YYYY) String dateAsString = ""; if (!dsf.getValues_nondisplay().isEmpty()) { - dateAsString = dsf.getValues_nondisplay().get(0); - } + dateAsString = dsf.getValues_nondisplay().get(0).trim(); + } + logger.fine("date as string: " + dateAsString); + if (dateAsString != null && !dateAsString.isEmpty()) { - SimpleDateFormat inputDateyyyy = new SimpleDateFormat("yyyy", Locale.ENGLISH); - try { - /** - * @todo when bean validation is working we - * won't have to convert strings into dates - */ - logger.fine("Trying to convert " + dateAsString + " to a YYYY date from dataset " + dataset.getId()); - Date dateAsDate = inputDateyyyy.parse(dateAsString); - SimpleDateFormat yearOnly = new SimpleDateFormat("yyyy"); - String datasetFieldFlaggedAsDate = yearOnly.format(dateAsDate); - logger.fine("YYYY only: " + datasetFieldFlaggedAsDate); - // solrInputDocument.addField(solrFieldSearchable, - // Integer.parseInt(datasetFieldFlaggedAsDate)); - solrInputDocument.addField(solrFieldSearchable, dateAsString); - if (dsfType.getSolrField().isFacetable()) { - // solrInputDocument.addField(solrFieldFacetable, + boolean dateValid = false; + + DateTimeFormatter[] possibleFormats = { + DateTimeFormatter.ISO_INSTANT, + DateTimeFormatter.ofPattern("yyyy-MM-dd"), + DateTimeFormatter.ofPattern("yyyy-MM"), + DateTimeFormatter.ofPattern("yyyy") + }; + for (DateTimeFormatter format : possibleFormats){ + try { + format.parse(dateAsString); + dateValid = true; + } catch (DateTimeParseException e) { + // no-op, date is invalid + } + } + + if (!dateValid) { + logger.fine("couldn't index " + dsf.getDatasetFieldType().getName() + ":" + dsf.getValues() + " because it's not a valid date format according to Solr"); + } else { + SimpleDateFormat inputDateyyyy = new SimpleDateFormat("yyyy", Locale.ENGLISH); + try { + /** + * @todo when bean validation is working we + * won't have to convert strings into dates + */ + logger.fine("Trying to convert " + dateAsString + " to a YYYY date from dataset " + dataset.getId()); + Date dateAsDate = inputDateyyyy.parse(dateAsString); + SimpleDateFormat yearOnly = new SimpleDateFormat("yyyy"); + String datasetFieldFlaggedAsDate = yearOnly.format(dateAsDate); + logger.fine("YYYY only: " + datasetFieldFlaggedAsDate); + // solrInputDocument.addField(solrFieldSearchable, // Integer.parseInt(datasetFieldFlaggedAsDate)); - solrInputDocument.addField(solrFieldFacetable, datasetFieldFlaggedAsDate); + solrInputDocument.addField(solrFieldSearchable, dateAsString); + if (dsfType.getSolrField().isFacetable()) { + // solrInputDocument.addField(solrFieldFacetable, + // Integer.parseInt(datasetFieldFlaggedAsDate)); + solrInputDocument.addField(solrFieldFacetable, datasetFieldFlaggedAsDate); + } + } catch (Exception ex) { + logger.info("unable to convert " + dateAsString + " into YYYY format and couldn't index it (" + dsfType.getName() + ")"); } - } catch (Exception ex) { - logger.info("unable to convert " + dateAsString + " into YYYY format and couldn't index it (" + dsfType.getName() + ")"); } } } else { 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 8850b7ce7c2..6058ab17d72 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/SearchIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/SearchIT.java @@ -1462,6 +1462,134 @@ public void testRangeQueries() { } + @Test + public void testSearchWithInvalidDateField() { + + Response createUser = UtilIT.createRandomUser(); + createUser.prettyPrint(); + String username = UtilIT.getUsernameFromResponse(createUser); + String apiToken = UtilIT.getApiTokenFromResponse(createUser); + + Response createDataverseResponse = UtilIT.createRandomDataverse(apiToken); + createDataverseResponse.prettyPrint(); + String dataverseAlias = UtilIT.getAliasFromResponse(createDataverseResponse); + + Response setMetadataBlocks = UtilIT.setMetadataBlocks(dataverseAlias, Json.createArrayBuilder().add("citation"), apiToken); + setMetadataBlocks.prettyPrint(); + setMetadataBlocks.then().assertThat().statusCode(OK.getStatusCode()); + + // Adding a dataset with a date in the "timePeriodCoveredStart" field that doesn't match Solr's date format + // (ISO-8601 format, e.g. YYYY-MM-DDThh:mm:ssZ, YYYYY-MM-DD, YYYY-MM, YYYY) + // (See: https://solr.apache.org/guide/solr/latest/indexing-guide/date-formatting-math.html) + // So the date currently cannot be indexed + JsonObjectBuilder datasetJson = Json.createObjectBuilder() + .add("datasetVersion", Json.createObjectBuilder() + .add("metadataBlocks", Json.createObjectBuilder() + .add("citation", Json.createObjectBuilder() + .add("fields", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("typeName", "title") + .add("value", "Test Dataset") + .add("typeClass", "primitive") + .add("multiple", false) + ) + .add(Json.createObjectBuilder() + .add("value", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("authorName", + Json.createObjectBuilder() + .add("value", "Simpson, Homer") + .add("typeClass", "primitive") + .add("multiple", false) + .add("typeName", "authorName")) + ) + ) + .add("typeClass", "compound") + .add("multiple", true) + .add("typeName", "author") + ) + .add(Json.createObjectBuilder() + .add("value", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("datasetContactEmail", + Json.createObjectBuilder() + .add("value", "hsimpson@mailinator.com") + .add("typeClass", "primitive") + .add("multiple", false) + .add("typeName", "datasetContactEmail")) + ) + ) + .add("typeClass", "compound") + .add("multiple", true) + .add("typeName", "datasetContact") + ) + .add(Json.createObjectBuilder() + .add("value", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("dsDescriptionValue", + Json.createObjectBuilder() + .add("value", "This is a test dataset.") + .add("typeClass", "primitive") + .add("multiple", false) + .add("typeName", "dsDescriptionValue")) + ) + ) + .add("typeClass", "compound") + .add("multiple", true) + .add("typeName", "dsDescription") + ) + .add(Json.createObjectBuilder() + .add("value", Json.createArrayBuilder() + .add("Other") + ) + .add("typeClass", "controlledVocabulary") + .add("multiple", true) + .add("typeName", "subject") + ) + .add(Json.createObjectBuilder() + .add("typeName", "timePeriodCovered") + .add("typeClass", "compound") + .add("multiple", true) + .add("value", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("timePeriodCoveredStart", + Json.createObjectBuilder() + .add("value", "15-01-01") + .add("typeClass", "primitive") + .add("multiple", false) + .add("typeName", "timePeriodCoveredStart") + ) + ) + ) + ) + ) + ) + )); + + Response createDatasetResponse = UtilIT.createDataset(dataverseAlias, datasetJson, apiToken); + createDatasetResponse.prettyPrint(); + Integer datasetId = UtilIT.getDatasetIdFromResponse(createDatasetResponse); + String datasetPid = JsonPath.from(createDatasetResponse.getBody().asString()).getString("data.persistentId"); + + // When querying on the date field: miss (because the date field was skipped during indexing) + Response search1 = UtilIT.search("id:dataset_" + datasetId + "_draft AND timePeriodCoveredStart:[2000 TO 2020]", apiToken); + search1.prettyPrint(); + search1.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.total_count", CoreMatchers.is(0)) + .body("data.count_in_response", CoreMatchers.is(0)); + + // When querying not on the date field: the dataset can be found (only the date field was skipped during indexing, not the entire dataset) + Response search2 = UtilIT.search("id:dataset_" + datasetId + "_draft", apiToken, "&show_entity_ids=true"); + search2.prettyPrint(); + search2.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.total_count", CoreMatchers.is(1)) + .body("data.count_in_response", CoreMatchers.is(1)) + .body("data.items[0].entity_id", CoreMatchers.is(datasetId)); + + } + @AfterEach public void tearDownDataverse() { File treesThumb = new File("scripts/search/data/binary/trees.png.thumb48"); From 5b9e67efcd10e23ae410d62c7202f0df69264315 Mon Sep 17 00:00:00 2001 From: GPortas Date: Mon, 28 Oct 2024 09:46:06 +0000 Subject: [PATCH 014/137] 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 015/137] 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 016/137] 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 017/137] 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 018/137] 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 a8f09e62667d5a6245cf35594b865db339df623e Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Thu, 31 Oct 2024 14:32:57 -0400 Subject: [PATCH 019/137] Published datasets should contain files --- ...published-datasets-should-contain-files.md | 9 +++ doc/sphinx-guides/source/api/native-api.rst | 1 + .../edu/harvard/iq/dataverse/Dataverse.java | 11 +++- .../harvard/iq/dataverse/api/Dataverses.java | 2 +- .../command/impl/PublishDatasetCommand.java | 20 +++++- .../impl/UpdateDataverseAttributeCommand.java | 33 ++++++---- .../iq/dataverse/util/json/JsonParser.java | 3 + .../iq/dataverse/util/json/JsonPrinter.java | 3 + src/main/java/propertyFiles/Bundle.properties | 1 + src/main/resources/db/migration/V6.5.0.1.sql | 2 + .../harvard/iq/dataverse/api/DatasetsIT.java | 66 +++++++++++++++++-- 11 files changed, 132 insertions(+), 19 deletions(-) create mode 100644 doc/release-notes/10981-published-datasets-should-contain-files.md create mode 100644 src/main/resources/db/migration/V6.5.0.1.sql diff --git a/doc/release-notes/10981-published-datasets-should-contain-files.md b/doc/release-notes/10981-published-datasets-should-contain-files.md new file mode 100644 index 00000000000..73c76744164 --- /dev/null +++ b/doc/release-notes/10981-published-datasets-should-contain-files.md @@ -0,0 +1,9 @@ +## Feature: Prevent publishing Datasets without files +A new attribute was added to Collections in order to control the publishing of Datasets without files. Once set, the publishing of a Dataset within a Collection or Collection's hierarchy, without files, will be blocked for all non Admin users. +In order to configure a Collection to block publishing an Admin must set the attribute "requireFilesToPublishDataset" to true. +Any Collection created under a Collection with this attribute will also be bound by this blocking. Setting this attribute on the Root Dataverse will essentially block the publishing of Datasets without files for the entire installation. +```shell +curl -X PUT -H "X-Dataverse-key:$API_TOKEN" "$SERVER_URL/api/dataverses/$ID/attribute/requireFilesToPublishDataset?value=true" +``` + +See also #10981. diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index f8b8620f121..8fab30a884b 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -1005,6 +1005,7 @@ The following attributes are supported: * ``description`` Description * ``affiliation`` Affiliation * ``filePIDsEnabled`` ("true" or "false") Restricted to use by superusers and only when the :ref:`:AllowEnablingFilePIDsPerCollection <:AllowEnablingFilePIDsPerCollection>` setting is true. Enables or disables registration of file-level PIDs in datasets within the collection (overriding the instance-wide setting). +* ``requireFilesToPublishDataset`` ("true" or "false") Dataset needs files in order to be published. Restricted to use by Administrators. If any TRUE found in the ownership tree publishing will be blocked. Publishing by an Administrator will not be blocked. .. _collection-storage-quotas: diff --git a/src/main/java/edu/harvard/iq/dataverse/Dataverse.java b/src/main/java/edu/harvard/iq/dataverse/Dataverse.java index 86e2e0207c1..3bbf02fd611 100644 --- a/src/main/java/edu/harvard/iq/dataverse/Dataverse.java +++ b/src/main/java/edu/harvard/iq/dataverse/Dataverse.java @@ -602,7 +602,16 @@ public List getCitationDatasetFieldTypes() { public void setCitationDatasetFieldTypes(List citationDatasetFieldTypes) { this.citationDatasetFieldTypes = citationDatasetFieldTypes; } - + + @Column(nullable = true) + private Boolean requireFilesToPublishDataset; + public Boolean getRequireFilesToPublishDataset() { + return requireFilesToPublishDataset; + } + public void setRequireFilesToPublishDataset(boolean requireFilesToPublishDataset) { + this.requireFilesToPublishDataset = requireFilesToPublishDataset; + } + /** * @Note: this setting is Nullable, with {@code null} indicating that the * desired behavior is not explicitly configured for this specific collection. diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Dataverses.java b/src/main/java/edu/harvard/iq/dataverse/api/Dataverses.java index 2be6b1e51c2..f549003f70b 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Dataverses.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Dataverses.java @@ -631,7 +631,7 @@ public Response updateAttribute(@Context ContainerRequestContext crc, @PathParam } private Object formatAttributeValue(String attribute, String value) throws WrappedResponse { - if (attribute.equals("filePIDsEnabled")) { + if (List.of("filePIDsEnabled","requireFilesToPublishDataset").contains(attribute)) { return parseBooleanOrDie(value); } return value; diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/PublishDatasetCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/PublishDatasetCommand.java index 1ac41105237..dfe2bb44b20 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/PublishDatasetCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/PublishDatasetCommand.java @@ -2,6 +2,7 @@ import edu.harvard.iq.dataverse.Dataset; import edu.harvard.iq.dataverse.DatasetLock; +import edu.harvard.iq.dataverse.Dataverse; import edu.harvard.iq.dataverse.authorization.Permission; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.engine.command.CommandContext; @@ -9,8 +10,11 @@ import edu.harvard.iq.dataverse.engine.command.RequiredPermissions; import edu.harvard.iq.dataverse.engine.command.exception.CommandException; import edu.harvard.iq.dataverse.engine.command.exception.IllegalCommandException; +import edu.harvard.iq.dataverse.util.BundleUtil; import edu.harvard.iq.dataverse.workflow.Workflow; import edu.harvard.iq.dataverse.workflow.WorkflowContext.TriggerType; + +import java.util.List; import java.util.Optional; import java.util.logging.Logger; import static java.util.stream.Collectors.joining; @@ -218,9 +222,23 @@ private void verifyCommandArguments(CommandContext ctxt) throws IllegalCommandEx if (minorRelease && !getDataset().getLatestVersion().isMinorUpdate()) { throw new IllegalCommandException("Cannot release as minor version. Re-try as major release.", this); } + + if (getDataset().getFiles().isEmpty() && requiresFilesToPublishDataset()) { + throw new IllegalCommandException(BundleUtil.getStringFromBundle("dataset.mayNotPublish.FilesRequired"), this); + } } } - + private boolean requiresFilesToPublishDataset() { + if (!getUser().isSuperuser()) { + List owners = getDataset().getOwner().getOwners(); + for(Dataverse owner : owners) { + if (owner.getRequireFilesToPublishDataset() != null && owner.getRequireFilesToPublishDataset()) { + return true; + } + } + } + return false; + } @Override public boolean onSuccess(CommandContext ctxt, Object r) { diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/UpdateDataverseAttributeCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/UpdateDataverseAttributeCommand.java index 57ac20fcee6..ab12d8eea26 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/UpdateDataverseAttributeCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/UpdateDataverseAttributeCommand.java @@ -24,7 +24,7 @@ public class UpdateDataverseAttributeCommand extends AbstractCommand private static final String ATTRIBUTE_DESCRIPTION = "description"; private static final String ATTRIBUTE_AFFILIATION = "affiliation"; private static final String ATTRIBUTE_FILE_PIDS_ENABLED = "filePIDsEnabled"; - + private static final String ATTRIBUTE_REQUIRE_FILES_TO_PUBLISH_DATASET = "requireFilesToPublishDataset"; private final Dataverse dataverse; private final String attributeName; private final Object attributeValue; @@ -45,8 +45,9 @@ public Dataverse execute(CommandContext ctxt) throws CommandException { case ATTRIBUTE_AFFILIATION: setStringAttribute(attributeName, attributeValue); break; + case ATTRIBUTE_REQUIRE_FILES_TO_PUBLISH_DATASET: case ATTRIBUTE_FILE_PIDS_ENABLED: - setBooleanAttributeForFilePIDs(ctxt); + setBooleanAttribute(ctxt, true); break; default: throw new IllegalCommandException("'" + attributeName + "' is not a supported attribute", this); @@ -86,25 +87,33 @@ private void setStringAttribute(String attributeName, Object attributeValue) thr } /** - * Helper method to handle the "filePIDsEnabled" boolean attribute. + * Helper method to handle boolean attributes. * * @param ctxt The command context. + * @param adminOnly True if this attribute can only be modified by an Administrator * @throws PermissionException if the user doesn't have permission to modify this attribute. */ - private void setBooleanAttributeForFilePIDs(CommandContext ctxt) throws CommandException { - if (!getRequest().getUser().isSuperuser()) { + private void setBooleanAttribute(CommandContext ctxt, boolean adminOnly) throws CommandException { + if (adminOnly && !getRequest().getUser().isSuperuser()) { throw new PermissionException("You must be a superuser to change this setting", this, Collections.singleton(Permission.EditDataset), dataverse); } - if (!ctxt.settings().isTrueForKey(SettingsServiceBean.Key.AllowEnablingFilePIDsPerCollection, false)) { - throw new PermissionException("Changing File PID policy per collection is not enabled on this server", - this, Collections.singleton(Permission.EditDataset), dataverse); - } if (!(attributeValue instanceof Boolean)) { - throw new IllegalCommandException("'" + ATTRIBUTE_FILE_PIDS_ENABLED + "' requires a boolean value", this); + throw new IllegalCommandException("'" + attributeName + "' requires a boolean value", this); + } + switch (attributeName) { + case ATTRIBUTE_FILE_PIDS_ENABLED: + if (!ctxt.settings().isTrueForKey(SettingsServiceBean.Key.AllowEnablingFilePIDsPerCollection, false)) { + throw new PermissionException("Changing File PID policy per collection is not enabled on this server", + this, Collections.singleton(Permission.EditDataset), dataverse); + } + dataverse.setFilePIDsEnabled((Boolean) attributeValue); + case ATTRIBUTE_REQUIRE_FILES_TO_PUBLISH_DATASET: + dataverse.setRequireFilesToPublishDataset((Boolean) attributeValue); + break; + default: + throw new IllegalCommandException("Unsupported boolean attribute: " + attributeName, this); } - - dataverse.setFilePIDsEnabled((Boolean) attributeValue); } } 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..50caf2c6732 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 @@ -160,6 +160,9 @@ public Dataverse parseDataverse(JsonObject jobj) throws JsonParseException { if (jobj.containsKey("filePIDsEnabled")) { dv.setFilePIDsEnabled(jobj.getBoolean("filePIDsEnabled")); } + if (jobj.containsKey("requireFilesToPublishDataset")) { + dv.setRequireFilesToPublishDataset(jobj.getBoolean("requireFilesToPublishDataset")); + } /* We decided that subject is not user set, but gotten from the subject of the dataverse's datasets - leavig this code in for now, in case we need to go back to it at some point 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 1bdee48b14d..8f5f97512aa 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 @@ -292,6 +292,9 @@ public static JsonObjectBuilder json(Dataverse dv, Boolean hideEmail, Boolean re if (dv.getFilePIDsEnabled() != null) { bld.add("filePIDsEnabled", dv.getFilePIDsEnabled()); } + if (dv.getRequireFilesToPublishDataset() != null) { + bld.add("requireFilesToPublishDataset", dv.getRequireFilesToPublishDataset()); + } bld.add("isReleased", dv.isReleased()); List inputLevels = dv.getDataverseFieldTypeInputLevels(); diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index 149e6a7e828..ebc09a1d731 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -1542,6 +1542,7 @@ dataset.mayNotPublish.administrator= This dataset cannot be published until {0} dataset.mayNotPublish.both= This dataset cannot be published until {0} is published. Would you like to publish both right now? dataset.mayNotPublish.twoGenerations= This dataset cannot be published until {0} and {1} are published. dataset.mayNotBePublished.both.button=Yes, Publish Both +dataset.mayNotPublish.FilesRequired=This dataset cannot be published without uploaded files. dataset.viewVersion.unpublished=View Unpublished Version dataset.viewVersion.published=View Published Version dataset.link.title=Link Dataset diff --git a/src/main/resources/db/migration/V6.5.0.1.sql b/src/main/resources/db/migration/V6.5.0.1.sql new file mode 100644 index 00000000000..661924b54af --- /dev/null +++ b/src/main/resources/db/migration/V6.5.0.1.sql @@ -0,0 +1,2 @@ +-- files are required to publish datasets +ALTER TABLE dataverse ADD COLUMN IF NOT EXISTS requirefilestopublishdataset bool; diff --git a/src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java b/src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java index 93f1024ae7a..0f24e6b73a9 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java @@ -16,16 +16,14 @@ import edu.harvard.iq.dataverse.util.SystemConfig; import edu.harvard.iq.dataverse.util.json.JSONLDUtil; import edu.harvard.iq.dataverse.util.json.JsonUtil; +import edu.harvard.iq.dataverse.util.json.NullSafeJsonBuilder; import io.restassured.RestAssured; import io.restassured.http.ContentType; import io.restassured.parsing.Parser; import io.restassured.path.json.JsonPath; import io.restassured.path.xml.XmlPath; import io.restassured.response.Response; -import jakarta.json.Json; -import jakarta.json.JsonArray; -import jakarta.json.JsonObject; -import jakarta.json.JsonObjectBuilder; +import jakarta.json.*; import jakarta.ws.rs.core.Response.Status; import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.StringUtils; @@ -5168,4 +5166,64 @@ public void testGetCanDownloadAtLeastOneFile() { Response getUserPermissionsOnDatasetInvalidIdResponse = UtilIT.getCanDownloadAtLeastOneFile("testInvalidId", DS_VERSION_LATEST, secondUserApiToken); getUserPermissionsOnDatasetInvalidIdResponse.then().assertThat().statusCode(BAD_REQUEST.getStatusCode()); } + + @Test + public void testRequireFilesToPublishDatasets() { + // Create superuser and regular user + Response createUserResponse = UtilIT.createRandomUser(); + createUserResponse.then().assertThat().statusCode(OK.getStatusCode()); + String usernameAdmin = UtilIT.getUsernameFromResponse(createUserResponse); + String apiTokenAdmin = UtilIT.getApiTokenFromResponse(createUserResponse); + Response makeSuperUser = UtilIT.makeSuperUser(usernameAdmin); + assertEquals(200, makeSuperUser.getStatusCode()); + + createUserResponse = UtilIT.createRandomUser(); + createUserResponse.then().assertThat().statusCode(OK.getStatusCode()); + String apiToken = UtilIT.getApiTokenFromResponse(createUserResponse); + + // Create and publish a top level Dataverse (under root) with a requireFilesToPublishDataset set to true + Response createDataverseResponse = UtilIT.createRandomDataverse(apiToken); + String ownerAlias = UtilIT.getAliasFromResponse(createDataverseResponse); + // Only admin can set this attribute + Response setDataverseAttributeResponse = UtilIT.setCollectionAttribute(ownerAlias, "requireFilesToPublishDataset", "true", apiToken); + setDataverseAttributeResponse.prettyPrint(); + setDataverseAttributeResponse.then().assertThat().statusCode(UNAUTHORIZED.getStatusCode()); + setDataverseAttributeResponse = UtilIT.setCollectionAttribute(ownerAlias, "requireFilesToPublishDataset", "true", apiTokenAdmin); + setDataverseAttributeResponse.prettyPrint(); + setDataverseAttributeResponse.then().assertThat().statusCode(OK.getStatusCode()); + setDataverseAttributeResponse.then().assertThat().body("data.requireFilesToPublishDataset",equalTo(true)); + Response publishDataverseResponse = UtilIT.publishDataverseViaNativeApi(ownerAlias, apiTokenAdmin); + publishDataverseResponse.prettyPrint(); + publishDataverseResponse.then().assertThat().statusCode(OK.getStatusCode()); + + // Create and publish a new Dataverse under the above Dataverse with requireFilesToPublishDataset not set (default null) + String alias = "dv2-" + UtilIT.getRandomIdentifier(); + createDataverseResponse = UtilIT.createSubDataverse(alias, null, apiToken, ownerAlias); + createDataverseResponse.prettyPrint(); + createDataverseResponse.then().assertThat().statusCode(CREATED.getStatusCode()); + publishDataverseResponse = UtilIT.publishDataverseViaNativeApi(alias, apiToken); + publishDataverseResponse.then().assertThat().statusCode(OK.getStatusCode()); + + // Create a Dataset under the 2nd level Dataverse + Response createDatasetResponse = UtilIT.createRandomDatasetViaNativeApi(alias, apiToken); + createDatasetResponse.then().assertThat().statusCode(CREATED.getStatusCode()); + Integer id = UtilIT.getDatasetIdFromResponse(createDatasetResponse); + + // Try to publish with no files (minimum is 1 file from the top level Dataverse) + Response publishDatasetResponse = UtilIT.publishDatasetViaNativeApi(String.valueOf(id), "major", apiToken); + publishDatasetResponse.prettyPrint(); + publishDatasetResponse.then().assertThat().statusCode(FORBIDDEN.getStatusCode()); + publishDatasetResponse.then().assertThat().body("message", containsString( + BundleUtil.getStringFromBundle("dataset.mayNotPublish.FilesRequired") + )); + + // Upload 1 file and try to publish again + String pathToFile = "src/main/webapp/resources/images/dataverseproject.png"; + Response uploadResponse = UtilIT.uploadFileViaNative(String.valueOf(id), pathToFile, apiToken); + uploadResponse.then().assertThat().statusCode(OK.getStatusCode()); + + publishDatasetResponse = UtilIT.publishDatasetViaNativeApi(String.valueOf(id), "major", apiToken); + publishDatasetResponse.prettyPrint(); + publishDatasetResponse.then().assertThat().statusCode(OK.getStatusCode()); + } } From 1159134aa8b3cbb987a5e9f3d566de9914f17ff7 Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Thu, 31 Oct 2024 14:42:51 -0400 Subject: [PATCH 020/137] cosmetic fixes --- src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java b/src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java index 0f24e6b73a9..3bc15bfc363 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java @@ -16,14 +16,16 @@ import edu.harvard.iq.dataverse.util.SystemConfig; import edu.harvard.iq.dataverse.util.json.JSONLDUtil; import edu.harvard.iq.dataverse.util.json.JsonUtil; -import edu.harvard.iq.dataverse.util.json.NullSafeJsonBuilder; import io.restassured.RestAssured; import io.restassured.http.ContentType; import io.restassured.parsing.Parser; import io.restassured.path.json.JsonPath; import io.restassured.path.xml.XmlPath; import io.restassured.response.Response; -import jakarta.json.*; +import jakarta.json.Json; +import jakarta.json.JsonArray; +import jakarta.json.JsonObject; +import jakarta.json.JsonObjectBuilder; import jakarta.ws.rs.core.Response.Status; import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.StringUtils; From c0c4c89aee8e9dd5acfb979dbc0c36f1803da57a Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Thu, 31 Oct 2024 15:56:01 -0400 Subject: [PATCH 021/137] change Admin to superuser in docs --- .../10981-published-datasets-should-contain-files.md | 4 ++-- doc/sphinx-guides/source/api/native-api.rst | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/release-notes/10981-published-datasets-should-contain-files.md b/doc/release-notes/10981-published-datasets-should-contain-files.md index 73c76744164..74c932f853a 100644 --- a/doc/release-notes/10981-published-datasets-should-contain-files.md +++ b/doc/release-notes/10981-published-datasets-should-contain-files.md @@ -1,6 +1,6 @@ ## Feature: Prevent publishing Datasets without files -A new attribute was added to Collections in order to control the publishing of Datasets without files. Once set, the publishing of a Dataset within a Collection or Collection's hierarchy, without files, will be blocked for all non Admin users. -In order to configure a Collection to block publishing an Admin must set the attribute "requireFilesToPublishDataset" to true. +A new attribute was added to Collections in order to control the publishing of Datasets without files. Once set, the publishing of a Dataset within a Collection or Collection's hierarchy, without files, will be blocked for all non superusers. +In order to configure a Collection to block publishing a superuser must set the attribute "requireFilesToPublishDataset" to true. Any Collection created under a Collection with this attribute will also be bound by this blocking. Setting this attribute on the Root Dataverse will essentially block the publishing of Datasets without files for the entire installation. ```shell curl -X PUT -H "X-Dataverse-key:$API_TOKEN" "$SERVER_URL/api/dataverses/$ID/attribute/requireFilesToPublishDataset?value=true" diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index 8fab30a884b..d174d4a87cd 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -1005,7 +1005,7 @@ The following attributes are supported: * ``description`` Description * ``affiliation`` Affiliation * ``filePIDsEnabled`` ("true" or "false") Restricted to use by superusers and only when the :ref:`:AllowEnablingFilePIDsPerCollection <:AllowEnablingFilePIDsPerCollection>` setting is true. Enables or disables registration of file-level PIDs in datasets within the collection (overriding the instance-wide setting). -* ``requireFilesToPublishDataset`` ("true" or "false") Dataset needs files in order to be published. Restricted to use by Administrators. If any TRUE found in the ownership tree publishing will be blocked. Publishing by an Administrator will not be blocked. +* ``requireFilesToPublishDataset`` ("true" or "false") Dataset needs files in order to be published. Restricted to use by superusers. If any TRUE found in the ownership tree publishing will be blocked. Publishing by a superusers will not be blocked. .. _collection-storage-quotas: From 85af55f8f1a6dfe0165a28d425cdcc6f51f6ea2d Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Fri, 1 Nov 2024 10:45:06 -0400 Subject: [PATCH 022/137] change how hierarchy works --- .../10981-published-datasets-should-contain-files.md | 8 +++++--- doc/sphinx-guides/source/api/native-api.rst | 2 +- src/main/java/edu/harvard/iq/dataverse/Dataverse.java | 9 +++++++++ .../engine/command/impl/PublishDatasetCommand.java | 9 +++++---- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/doc/release-notes/10981-published-datasets-should-contain-files.md b/doc/release-notes/10981-published-datasets-should-contain-files.md index 74c932f853a..964e7ff1937 100644 --- a/doc/release-notes/10981-published-datasets-should-contain-files.md +++ b/doc/release-notes/10981-published-datasets-should-contain-files.md @@ -1,7 +1,9 @@ ## Feature: Prevent publishing Datasets without files -A new attribute was added to Collections in order to control the publishing of Datasets without files. Once set, the publishing of a Dataset within a Collection or Collection's hierarchy, without files, will be blocked for all non superusers. -In order to configure a Collection to block publishing a superuser must set the attribute "requireFilesToPublishDataset" to true. -Any Collection created under a Collection with this attribute will also be bound by this blocking. Setting this attribute on the Root Dataverse will essentially block the publishing of Datasets without files for the entire installation. +A new attribute was added to Collections in order to control the publishing of Datasets without files. +Once set to "True", the publishing of a Dataset within a Collection, without files, will be blocked for all non superusers. +In order to configure a Collection to block publishing a superuser must set the attribute "requireFilesToPublishDataset" to "True". +The collection's hierarchy will be checked if the collection's "requireFilesToPublishDataset" attribute is not set explicitly to "True" or "False". + ```shell curl -X PUT -H "X-Dataverse-key:$API_TOKEN" "$SERVER_URL/api/dataverses/$ID/attribute/requireFilesToPublishDataset?value=true" ``` diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index d174d4a87cd..8d73dd9dd24 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -1005,7 +1005,7 @@ The following attributes are supported: * ``description`` Description * ``affiliation`` Affiliation * ``filePIDsEnabled`` ("true" or "false") Restricted to use by superusers and only when the :ref:`:AllowEnablingFilePIDsPerCollection <:AllowEnablingFilePIDsPerCollection>` setting is true. Enables or disables registration of file-level PIDs in datasets within the collection (overriding the instance-wide setting). -* ``requireFilesToPublishDataset`` ("true" or "false") Dataset needs files in order to be published. Restricted to use by superusers. If any TRUE found in the ownership tree publishing will be blocked. Publishing by a superusers will not be blocked. +* ``requireFilesToPublishDataset`` ("true" or "false") Restricted to use by superusers. Defines if Dataset needs files in order to be published. If not set the determination will be made through inheritance by checking the owners of this collection. Publishing by a superusers will not be blocked. .. _collection-storage-quotas: diff --git a/src/main/java/edu/harvard/iq/dataverse/Dataverse.java b/src/main/java/edu/harvard/iq/dataverse/Dataverse.java index 3bbf02fd611..5b6fbdee6ba 100644 --- a/src/main/java/edu/harvard/iq/dataverse/Dataverse.java +++ b/src/main/java/edu/harvard/iq/dataverse/Dataverse.java @@ -605,6 +605,15 @@ public void setCitationDatasetFieldTypes(List citationDatasetF @Column(nullable = true) private Boolean requireFilesToPublishDataset; + /** + * Specifies whether the existance of files in a dataset is required when publishing + * @return {@code Boolean.TRUE} if explicitly enabled, {@code Boolean.FALSE} if explicitly disabled. + * {@code null} indicates that the behavior is not explicitly defined, in which + * case the behavior should follow the explicit configuration of the first + * direct ancestor collection. + * @Note: If present, this configuration therefore by default applies to all + * the sub-collections, unless explicitly overwritten there. + */ public Boolean getRequireFilesToPublishDataset() { return requireFilesToPublishDataset; } diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/PublishDatasetCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/PublishDatasetCommand.java index dfe2bb44b20..50800f72271 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/PublishDatasetCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/PublishDatasetCommand.java @@ -230,11 +230,12 @@ private void verifyCommandArguments(CommandContext ctxt) throws IllegalCommandEx } private boolean requiresFilesToPublishDataset() { if (!getUser().isSuperuser()) { - List owners = getDataset().getOwner().getOwners(); - for(Dataverse owner : owners) { - if (owner.getRequireFilesToPublishDataset() != null && owner.getRequireFilesToPublishDataset()) { - return true; + Dataverse parent = getDataset().getOwner(); + while (parent != null) { + if (parent.getRequireFilesToPublishDataset() != null) { + return parent.getRequireFilesToPublishDataset(); } + parent = parent.getOwner(); } } return false; From 80ad5a4d5868a09c620eeb8f3d8a7b81bdb16bf1 Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 1 Nov 2024 19:26:16 +0000 Subject: [PATCH 023/137] 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 024/137] 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 025/137] 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 026/137] 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 027/137] 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 028/137] 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 029/137] 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 030/137] 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 031/137] 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 032/137] 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 033/137] 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 034/137] 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 035/137] 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 036/137] 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 037/137] 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 038/137] 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 039/137] 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 040/137] 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 041/137] 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 042/137] 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 043/137] 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 044/137] 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 045/137] 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 046/137] 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 047/137] 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 048/137] 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 049/137] 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 050/137] 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 051/137] 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 052/137] 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 053/137] 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 054/137] 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 055/137] 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 056/137] 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 057/137] 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 058/137] 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 059/137] 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 060/137] 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 061/137] 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 939d6803ca6e0aae7b6eebcc2f17b6f96e55454a Mon Sep 17 00:00:00 2001 From: Philip Durbin Date: Fri, 15 Nov 2024 16:21:04 -0500 Subject: [PATCH 062/137] remove date_range for now, revert to schema.xml from #11024 #10887 --- conf/solr/schema.xml | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/conf/solr/schema.xml b/conf/solr/schema.xml index 02e699722f7..e9a5364d6fd 100644 --- a/conf/solr/schema.xml +++ b/conf/solr/schema.xml @@ -291,12 +291,12 @@ - + @@ -327,8 +327,8 @@ - + @@ -352,9 +352,9 @@ - + @@ -384,13 +384,13 @@ - - + + - + @@ -402,10 +402,10 @@ + - @@ -533,12 +533,12 @@ - + @@ -569,8 +569,8 @@ - + @@ -594,9 +594,9 @@ - + @@ -626,13 +626,13 @@ - - + + - + @@ -644,10 +644,10 @@ + - @@ -814,8 +814,6 @@ - - From 55e3c605090045a4a2bd1a4d87b1c70f11141a20 Mon Sep 17 00:00:00 2001 From: Philip Durbin Date: Fri, 15 Nov 2024 16:25:11 -0500 Subject: [PATCH 063/137] update Solr schema.xml using update-fields.sh #10887 This reflects the following changes: * Integer fields are indexed as `plong` instead of `text_en` * Float fields are indexed as `pdouble` instead of `text_en` * Date fields are indexed as `date_range` (`solr.DateRangeField`) instead of `text_en` --- conf/solr/schema.xml | 46 ++++++++++++++++++++++---------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/conf/solr/schema.xml b/conf/solr/schema.xml index e9a5364d6fd..0b95cc482d2 100644 --- a/conf/solr/schema.xml +++ b/conf/solr/schema.xml @@ -272,23 +272,23 @@ - - - + + + - - - - + + + + - - - + + + - - + + @@ -298,12 +298,12 @@ - - - + + + - + @@ -311,7 +311,7 @@ - + @@ -323,7 +323,7 @@ - + @@ -348,7 +348,7 @@ - + @@ -360,7 +360,7 @@ - + @@ -394,13 +394,13 @@ - + - - + + From 3d2218376e5b30dbe12467cf370781a58eda438b Mon Sep 17 00:00:00 2001 From: Philip Durbin Date: Fri, 15 Nov 2024 16:32:34 -0500 Subject: [PATCH 064/137] add date_range type #10887 --- conf/solr/schema.xml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/conf/solr/schema.xml b/conf/solr/schema.xml index 0b95cc482d2..1dac5bc8c76 100644 --- a/conf/solr/schema.xml +++ b/conf/solr/schema.xml @@ -814,7 +814,9 @@ - + + + From 2837abf4d840be5b87c9993046d195f7acf79d8a Mon Sep 17 00:00:00 2001 From: Philip Durbin Date: Fri, 15 Nov 2024 16:50:49 -0500 Subject: [PATCH 065/137] updating schema.xml is required #10887 --- doc/release-notes/10887-solr-field-types.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/release-notes/10887-solr-field-types.md b/doc/release-notes/10887-solr-field-types.md index ca5b210cb21..13ab8dc9b4c 100644 --- a/doc/release-notes/10887-solr-field-types.md +++ b/doc/release-notes/10887-solr-field-types.md @@ -6,6 +6,6 @@ This release enhances how numerical and date fields are indexed in Solr. Previou This enables range queries via the search bar or API, such as `exampleIntegerField:[25 TO 50]` or `exampleDateField:[2000-11-01 TO 2014-12-01]`. -To activate this feature, Dataverse administrators must update their Solr schema.xml (manually or by rerunning `update-fields.sh`) and reindex all datasets. +Dataverse administrators must update their Solr schema.xml (manually or by rerunning `update-fields.sh`) and reindex all datasets. -Additionally, search result highlighting is now more accurate, ensuring that only fields relevant to the query are highlighted in search results. If the query is specifically limited to certain fields, the highlighting is now limited to those fields as well. \ No newline at end of file +Additionally, search result highlighting is now more accurate, ensuring that only fields relevant to the query are highlighted in search results. If the query is specifically limited to certain fields, the highlighting is now limited to those fields as well. From e011e537dcd562f35da1b1d49ca24729059811c1 Mon Sep 17 00:00:00 2001 From: Philip Durbin Date: Fri, 15 Nov 2024 17:00:06 -0500 Subject: [PATCH 066/137] enumerate fields changed, use examples from blocks we ship #10887 --- doc/release-notes/10887-solr-field-types.md | 28 ++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/doc/release-notes/10887-solr-field-types.md b/doc/release-notes/10887-solr-field-types.md index 13ab8dc9b4c..93ff897f4c3 100644 --- a/doc/release-notes/10887-solr-field-types.md +++ b/doc/release-notes/10887-solr-field-types.md @@ -4,7 +4,33 @@ This release enhances how numerical and date fields are indexed in Solr. Previou * Float fields are indexed as `pdouble` * Date fields are indexed as `date_range` (`solr.DateRangeField`) -This enables range queries via the search bar or API, such as `exampleIntegerField:[25 TO 50]` or `exampleDateField:[2000-11-01 TO 2014-12-01]`. +Specifically, the following fields were updated: + +- coverage.Depth +- coverage.ObjectCount +- coverage.ObjectDensity +- coverage.Redshift.MaximumValue +- coverage.Redshift.MinimumValue +- coverage.RedshiftValue +- coverage.SkyFraction +- coverage.Spectral.CentralWavelength +- coverage.Spectral.MaximumWavelength +- coverage.Spectral.MinimumWavelength +- coverage.Temporal.StartTime +- coverage.Temporal.StopTime +- dateOfCollectionEnd +- dateOfCollectionStart +- dateOfDeposit +- distributionDate +- dsDescriptionDate +- journalPubDate +- productionDate +- resolution.Redshift +- targetSampleActualSize +- timePeriodCoveredEnd +- timePeriodCoveredStart + +This change enables range queries when searching from both the UI and the API, such as `dateOfDeposit:[2000-01-01 TO 2014-12-31]` or `targetSampleActualSize:[25 TO 50]`. Dataverse administrators must update their Solr schema.xml (manually or by rerunning `update-fields.sh`) and reindex all datasets. From 73c407997b0a9972aa1e1989aef7bc4056f2128b Mon Sep 17 00:00:00 2001 From: GPortas Date: Tue, 19 Nov 2024 11:02:08 +0000 Subject: [PATCH 067/137] 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 068/137] 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 069/137] 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 070/137] 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 071/137] 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 072/137] 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 073/137] 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 074/137] 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 075/137] 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 076/137] 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 077/137] 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 078/137] 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 079/137] 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 3f599cfa1935a5b421597590463284ba4b1dacef Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Thu, 21 Nov 2024 10:29:17 -0500 Subject: [PATCH 080/137] address review comments --- .../edu/harvard/iq/dataverse/Dataverse.java | 11 +++++++++++ .../command/impl/PublishDatasetCommand.java | 19 +++++++------------ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/Dataverse.java b/src/main/java/edu/harvard/iq/dataverse/Dataverse.java index 5b6fbdee6ba..f5d935ad353 100644 --- a/src/main/java/edu/harvard/iq/dataverse/Dataverse.java +++ b/src/main/java/edu/harvard/iq/dataverse/Dataverse.java @@ -791,6 +791,17 @@ public List getOwners() { return owners; } + public boolean getEffectiveRequireFilesToPublishDataset() { + Dataverse dv = this; + while (dv != null) { + if (dv.getRequireFilesToPublishDataset() != null) { + return dv.getRequireFilesToPublishDataset(); + } + dv = dv.getOwner(); + } + return false; + } + @Override public boolean equals(Object object) { // TODO: Warning - this method won't work in the case the id fields are not set diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/PublishDatasetCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/PublishDatasetCommand.java index 50800f72271..54223ac63b6 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/PublishDatasetCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/PublishDatasetCommand.java @@ -14,7 +14,6 @@ import edu.harvard.iq.dataverse.workflow.Workflow; import edu.harvard.iq.dataverse.workflow.WorkflowContext.TriggerType; -import java.util.List; import java.util.Optional; import java.util.logging.Logger; import static java.util.stream.Collectors.joining; @@ -223,22 +222,18 @@ private void verifyCommandArguments(CommandContext ctxt) throws IllegalCommandEx throw new IllegalCommandException("Cannot release as minor version. Re-try as major release.", this); } - if (getDataset().getFiles().isEmpty() && requiresFilesToPublishDataset()) { + if (getDataset().getFiles().isEmpty() && getEffectiveRequireFilesToPublishDataset()) { throw new IllegalCommandException(BundleUtil.getStringFromBundle("dataset.mayNotPublish.FilesRequired"), this); } } } - private boolean requiresFilesToPublishDataset() { - if (!getUser().isSuperuser()) { - Dataverse parent = getDataset().getOwner(); - while (parent != null) { - if (parent.getRequireFilesToPublishDataset() != null) { - return parent.getRequireFilesToPublishDataset(); - } - parent = parent.getOwner(); - } + private boolean getEffectiveRequireFilesToPublishDataset() { + if (getUser().isSuperuser()) { + return false; + } else { + Dataverse dv = getDataset().getOwner(); + return dv != null && dv.getEffectiveRequireFilesToPublishDataset(); } - return false; } @Override From a244f18eabff62070adf25f5cfc06a09b1b09859 Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Thu, 21 Nov 2024 10:43:30 -0500 Subject: [PATCH 081/137] address review comments --- src/main/java/edu/harvard/iq/dataverse/Dataverse.java | 2 +- .../dataverse/engine/command/impl/PublishDatasetCommand.java | 2 +- .../java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java | 5 +---- .../resources/db/migration/{V6.5.0.1.sql => V6.4.0.3.sql} | 0 src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java | 2 +- 5 files changed, 4 insertions(+), 7 deletions(-) rename src/main/resources/db/migration/{V6.5.0.1.sql => V6.4.0.3.sql} (100%) diff --git a/src/main/java/edu/harvard/iq/dataverse/Dataverse.java b/src/main/java/edu/harvard/iq/dataverse/Dataverse.java index f5d935ad353..312f4aa13f1 100644 --- a/src/main/java/edu/harvard/iq/dataverse/Dataverse.java +++ b/src/main/java/edu/harvard/iq/dataverse/Dataverse.java @@ -791,7 +791,7 @@ public List getOwners() { return owners; } - public boolean getEffectiveRequireFilesToPublishDataset() { + public boolean getEffectiveRequiresFilesToPublishDataset() { Dataverse dv = this; while (dv != null) { if (dv.getRequireFilesToPublishDataset() != null) { diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/PublishDatasetCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/PublishDatasetCommand.java index 54223ac63b6..eccc69b95c6 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/PublishDatasetCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/PublishDatasetCommand.java @@ -232,7 +232,7 @@ private boolean getEffectiveRequireFilesToPublishDataset() { return false; } else { Dataverse dv = getDataset().getOwner(); - return dv != null && dv.getEffectiveRequireFilesToPublishDataset(); + return dv != null && dv.getEffectiveRequiresFilesToPublishDataset(); } } 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 fb4ef516f9b..dd8971d67a1 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 @@ -17,7 +17,6 @@ import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.branding.BrandingUtil; import edu.harvard.iq.dataverse.dataaccess.DataAccess; -import edu.harvard.iq.dataverse.dataset.DatasetType; import edu.harvard.iq.dataverse.dataset.DatasetUtil; import edu.harvard.iq.dataverse.datavariable.CategoryMetadata; import edu.harvard.iq.dataverse.datavariable.DataVariable; @@ -294,9 +293,7 @@ public static JsonObjectBuilder json(Dataverse dv, Boolean hideEmail, Boolean re if (dv.getFilePIDsEnabled() != null) { bld.add("filePIDsEnabled", dv.getFilePIDsEnabled()); } - if (dv.getRequireFilesToPublishDataset() != null) { - bld.add("requireFilesToPublishDataset", dv.getRequireFilesToPublishDataset()); - } + bld.add("effectiveRequiresFilesToPublishDataset", dv.getEffectiveRequiresFilesToPublishDataset()); bld.add("isReleased", dv.isReleased()); List inputLevels = dv.getDataverseFieldTypeInputLevels(); diff --git a/src/main/resources/db/migration/V6.5.0.1.sql b/src/main/resources/db/migration/V6.4.0.3.sql similarity index 100% rename from src/main/resources/db/migration/V6.5.0.1.sql rename to src/main/resources/db/migration/V6.4.0.3.sql diff --git a/src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java b/src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java index 1be1e498ccd..a33d077dc07 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java @@ -5193,7 +5193,7 @@ public void testRequireFilesToPublishDatasets() { setDataverseAttributeResponse = UtilIT.setCollectionAttribute(ownerAlias, "requireFilesToPublishDataset", "true", apiTokenAdmin); setDataverseAttributeResponse.prettyPrint(); setDataverseAttributeResponse.then().assertThat().statusCode(OK.getStatusCode()); - setDataverseAttributeResponse.then().assertThat().body("data.requireFilesToPublishDataset",equalTo(true)); + setDataverseAttributeResponse.then().assertThat().body("data.effectiveRequiresFilesToPublishDataset",equalTo(true)); Response publishDataverseResponse = UtilIT.publishDataverseViaNativeApi(ownerAlias, apiTokenAdmin); publishDataverseResponse.prettyPrint(); publishDataverseResponse.then().assertThat().statusCode(OK.getStatusCode()); From 5dce13b847be29ed0c32483cadba2b0ffe41523d Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Thu, 21 Nov 2024 10:50:31 -0500 Subject: [PATCH 082/137] address review comments --- .../dataverse/engine/command/impl/PublishDatasetCommand.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/PublishDatasetCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/PublishDatasetCommand.java index eccc69b95c6..2c32b1e8954 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/PublishDatasetCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/PublishDatasetCommand.java @@ -222,12 +222,12 @@ private void verifyCommandArguments(CommandContext ctxt) throws IllegalCommandEx throw new IllegalCommandException("Cannot release as minor version. Re-try as major release.", this); } - if (getDataset().getFiles().isEmpty() && getEffectiveRequireFilesToPublishDataset()) { + if (getDataset().getFiles().isEmpty() && getEffectiveRequiresFilesToPublishDataset()) { throw new IllegalCommandException(BundleUtil.getStringFromBundle("dataset.mayNotPublish.FilesRequired"), this); } } } - private boolean getEffectiveRequireFilesToPublishDataset() { + private boolean getEffectiveRequiresFilesToPublishDataset() { if (getUser().isSuperuser()) { return false; } else { From 6186a6e1147bb48324670927afb124024d29c23c Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Thu, 21 Nov 2024 13:41:02 -0500 Subject: [PATCH 083/137] address review comments --- 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 c82f9ab248d..30c1e96ee78 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -1542,7 +1542,7 @@ dataset.mayNotPublish.administrator= This dataset cannot be published until {0} dataset.mayNotPublish.both= This dataset cannot be published until {0} is published. Would you like to publish both right now? dataset.mayNotPublish.twoGenerations= This dataset cannot be published until {0} and {1} are published. dataset.mayNotBePublished.both.button=Yes, Publish Both -dataset.mayNotPublish.FilesRequired=This dataset cannot be published without uploaded files. +dataset.mayNotPublish.FilesRequired=Published datasets should contain at least one data file. dataset.viewVersion.unpublished=View Unpublished Version dataset.viewVersion.published=View Published Version dataset.link.title=Link Dataset From 4853f415eaceac487256cc089b4e22b837653210 Mon Sep 17 00:00:00 2001 From: Jim Myers Date: Fri, 22 Nov 2024 18:17:54 -0500 Subject: [PATCH 084/137] 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 d12d9f6bc11829b41330ba85aaeef5b3081b1140 Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Mon, 25 Nov 2024 10:16:18 -0500 Subject: [PATCH 085/137] fix bad merge --- src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java b/src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java index 65e984de919..1b2d7e9a431 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java @@ -5323,7 +5323,7 @@ public void testRequireFilesToPublishDatasets() { setDataverseAttributeResponse = UtilIT.setCollectionAttribute(ownerAlias, "requireFilesToPublishDataset", "true", apiTokenAdmin); setDataverseAttributeResponse.prettyPrint(); setDataverseAttributeResponse.then().assertThat().statusCode(OK.getStatusCode()); - setDataverseAttributeResponse.then().assertThat().body("data.requireFilesToPublishDataset", equalTo(true)); + setDataverseAttributeResponse.then().assertThat().body("data.effectiveRequiresFilesToPublishDataset", equalTo(true)); Response publishDataverseResponse = UtilIT.publishDataverseViaNativeApi(ownerAlias, apiTokenAdmin); publishDataverseResponse.prettyPrint(); publishDataverseResponse.then().assertThat().statusCode(OK.getStatusCode()); From d7cb845b8d549c0f51a23404a839525e90cfc254 Mon Sep 17 00:00:00 2001 From: Jim Myers Date: Wed, 20 Nov 2024 15:11:39 -0500 Subject: [PATCH 086/137] 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 087/137] 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 088/137] 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 089/137] 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 090/137] 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 091/137] 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 359335d24306b6a9e34360c61a5789e7f965d15f Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Thu, 5 Dec 2024 09:37:44 -0500 Subject: [PATCH 092/137] fix merge --- src/main/resources/db/migration/V6.4.0.4.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 src/main/resources/db/migration/V6.4.0.4.sql diff --git a/src/main/resources/db/migration/V6.4.0.4.sql b/src/main/resources/db/migration/V6.4.0.4.sql new file mode 100644 index 00000000000..661924b54af --- /dev/null +++ b/src/main/resources/db/migration/V6.4.0.4.sql @@ -0,0 +1,2 @@ +-- files are required to publish datasets +ALTER TABLE dataverse ADD COLUMN IF NOT EXISTS requirefilestopublishdataset bool; From 8fda98cd4fde8a949650ef3293c100c9af1d5dc5 Mon Sep 17 00:00:00 2001 From: Ludovic DANIEL Date: Thu, 5 Dec 2024 16:01:16 +0100 Subject: [PATCH 093/137] #7961 - Update the guide to mention boolean possibility in the Metadata Customization page --- doc/sphinx-guides/source/admin/metadatacustomization.rst | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/doc/sphinx-guides/source/admin/metadatacustomization.rst b/doc/sphinx-guides/source/admin/metadatacustomization.rst index e5326efebef..ffabb033c25 100644 --- a/doc/sphinx-guides/source/admin/metadatacustomization.rst +++ b/doc/sphinx-guides/source/admin/metadatacustomization.rst @@ -259,9 +259,9 @@ Each of the three main sections own sets of properties: | | | an existing #datasetField from | | | | another metadata block.) | +--------------+--------------------------------------------+-----------------------------------------+ -| Value | A short display string, representing | Free text | -| | an enumerated value for this field. If | | -| | the identifier property is empty, | | +| Value | A short display string, representing | Free text. As boolean, values "True" | +| | an enumerated value for this field. If | and "False" are recommended, "Unknown" | +| | the identifier property is empty, | value is an option. | | | this value is used as the identifier. | | +--------------+--------------------------------------------+-----------------------------------------+ | identifier | A string used to encode the selected | Free text | @@ -293,6 +293,9 @@ FieldType definitions +---------------+------------------------------------+ | text | Any text other than newlines may | | | be entered into this field. | +| | The text fieldtype can be used | +| | combined with | +| | #controlledVocabulary as boolean. | +---------------+------------------------------------+ | textbox | Any text may be entered. For | | | input, the Dataverse Software | From 57d1dd5781175a672b0f61011a1b148017ecbfaa Mon Sep 17 00:00:00 2001 From: Ludovic DANIEL Date: Thu, 5 Dec 2024 16:24:28 +0100 Subject: [PATCH 094/137] added release note --- doc/release-notes/11064-update-metadata-customization.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/release-notes/11064-update-metadata-customization.md diff --git a/doc/release-notes/11064-update-metadata-customization.md b/doc/release-notes/11064-update-metadata-customization.md new file mode 100644 index 00000000000..bcead3497f8 --- /dev/null +++ b/doc/release-notes/11064-update-metadata-customization.md @@ -0,0 +1 @@ +Metadata Customization guide has been updated to explain how to implement a kind of boolean fieldtype (see [Metadata Customization Guide](https://guides.dataverse.org/en/latest/admin/metadatacustomization.html#controlledvocabulary-enumerated-properties)) \ No newline at end of file From fa73fe048b0f9295204af4c88a98ace183b78db1 Mon Sep 17 00:00:00 2001 From: Philip Durbin Date: Thu, 5 Dec 2024 15:07:04 -0500 Subject: [PATCH 095/137] add detailed upgrade instruction steps for Solr #10887 --- doc/release-notes/10887-solr-field-types.md | 45 +++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/doc/release-notes/10887-solr-field-types.md b/doc/release-notes/10887-solr-field-types.md index 93ff897f4c3..2d8225172af 100644 --- a/doc/release-notes/10887-solr-field-types.md +++ b/doc/release-notes/10887-solr-field-types.md @@ -35,3 +35,48 @@ This change enables range queries when searching from both the UI and the API, s Dataverse administrators must update their Solr schema.xml (manually or by rerunning `update-fields.sh`) and reindex all datasets. Additionally, search result highlighting is now more accurate, ensuring that only fields relevant to the query are highlighted in search results. If the query is specifically limited to certain fields, the highlighting is now limited to those fields as well. + +## Upgrade Instructions + +7\. Update Solr schema.xml file. Start with the standard v6.5 schema.xml, then, if your installation uses any custom or experimental metadata blocks, update it to include the extra fields (step 7a). + +Stop Solr (usually `service solr stop`, depending on Solr installation/OS, see the [Installation Guide](https://guides.dataverse.org/en/6.5/installation/prerequisites.html#solr-init-script)). + +```shell +service solr stop +``` + +Replace schema.xml + +```shell +wget https://raw.githubusercontent.com/IQSS/dataverse/v6.5/conf/solr/schema.xml +cp schema.xml /usr/local/solr/solr-9.4.1/server/solr/collection1/conf +``` + +Start Solr (but if you use any custom metadata blocks, perform the next step, 7a first). + +```shell +service solr start +``` + +7a\. For installations with custom or experimental metadata blocks: + +Before starting Solr, update the schema to include all the extra metadata fields that your installation uses. We do this by collecting the output of the Dataverse schema API and feeding it to the `update-fields.sh` script that we supply, as in the example below (modify the command lines as needed to reflect the names of the directories, if different): + +```shell + wget https://raw.githubusercontent.com/IQSS/dataverse/v6.5/conf/solr/update-fields.sh + chmod +x update-fields.sh + curl "http://localhost:8080/api/admin/index/solr/schema" | ./update-fields.sh /usr/local/solr/solr-9.4.1/server/solr/collection1/conf/schema.xml +``` + +Now start Solr. + +8\. Reindex Solr + +Below is the simplest way to reindex Solr: + +```shell +curl http://localhost:8080/api/admin/index +``` + +The API above rebuilds the existing index "in place". If you want to be absolutely sure that your index is up-to-date and consistent, you may consider wiping it clean and reindexing everything from scratch (see [the guides](https://guides.dataverse.org/en/latest/admin/solr-search-index.html)). Just note that, depending on the size of your database, a full reindex may take a while and the users will be seeing incomplete search results during that window. From 7b2caaebef377f8d6a84f9ca0ffd38efa9af96d1 Mon Sep 17 00:00:00 2001 From: Victoria Lubitch Date: Fri, 6 Dec 2024 15:13:44 -0500 Subject: [PATCH 096/137] 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 097/137] 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 098/137] 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 6cae3ecb6ab14495690a6460382908f8848d42dc Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Mon, 9 Dec 2024 15:22:22 -0500 Subject: [PATCH 099/137] add new apis for marketplace external tool registration --- .../10930-marketplace-external-tools-apis.md | 14 +++ .../source/admin/external-tools.rst | 14 ++- .../iq/dataverse/api/ExternalToolsApi.java | 62 +++++++++++++ .../iq/dataverse/api/ExternalToolsIT.java | 89 ++++++++++++++++++- .../edu/harvard/iq/dataverse/api/UtilIT.java | 36 ++++++++ 5 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 doc/release-notes/10930-marketplace-external-tools-apis.md create mode 100644 src/main/java/edu/harvard/iq/dataverse/api/ExternalToolsApi.java diff --git a/doc/release-notes/10930-marketplace-external-tools-apis.md b/doc/release-notes/10930-marketplace-external-tools-apis.md new file mode 100644 index 00000000000..9e20c908823 --- /dev/null +++ b/doc/release-notes/10930-marketplace-external-tools-apis.md @@ -0,0 +1,14 @@ +## New APIs for External Tools Registration for Marketplace + +New API base path /api/externalTools created that mimics the admin APIs /api/admin/externalTools. These new apis require an authenticated superuser token. + +Example: +``` + API_TOKEN='xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' + export TOOL_ID=1 + + curl -s -H "X-Dataverse-key:$API_TOKEN" http://localhost:8080/api/externalTools + curl -s -H "X-Dataverse-key:$API_TOKEN" http://localhost:8080/api/externalTools/$TOOL_ID + curl -s -H "X-Dataverse-key:$API_TOKEN" -X POST -H 'Content-type: application/json' http://localhost:8080/api/externalTools --upload-file fabulousFileTool.json + curl -s -H "X-Dataverse-key:$API_TOKEN" -X DELETE http://localhost:8080/api/externalTools/$TOOL_ID +``` diff --git a/doc/sphinx-guides/source/admin/external-tools.rst b/doc/sphinx-guides/source/admin/external-tools.rst index 346ca0b15ee..50c2ec63c44 100644 --- a/doc/sphinx-guides/source/admin/external-tools.rst +++ b/doc/sphinx-guides/source/admin/external-tools.rst @@ -35,7 +35,10 @@ Configure the tool with the curl command below, making sure to replace the ``fab .. code-block:: bash - curl -X POST -H 'Content-type: application/json' http://localhost:8080/api/admin/externalTools --upload-file fabulousFileTool.json + curl -X POST -H 'Content-type: application/json' http://localhost:8080/api/admin/externalTools --upload-file fabulousFileTool.json + + This API is Superuser only. Note the endpoint difference (/api/externalTools instead of /api/admin/externalTools). + curl -s -H "X-Dataverse-key:$API_TOKEN" -X POST -H 'Content-type: application/json' http://localhost:8080/api/externalTools --upload-file fabulousFileTool.json Listing All External Tools in a Dataverse Installation ++++++++++++++++++++++++++++++++++++++++++++++++++++++ @@ -46,6 +49,9 @@ To list all the external tools that are available in a Dataverse installation: curl http://localhost:8080/api/admin/externalTools + This API is Superuser only. Note the endpoint difference (/api/externalTools instead of /api/admin/externalTools). + curl -s -H "X-Dataverse-key:$API_TOKEN" http://localhost:8080/api/externalTools + Showing an External Tool in a Dataverse Installation ++++++++++++++++++++++++++++++++++++++++++++++++++++ @@ -56,6 +62,9 @@ To show one of the external tools that are available in a Dataverse installation export TOOL_ID=1 curl http://localhost:8080/api/admin/externalTools/$TOOL_ID + This API is Superuser only. Note the endpoint difference (/api/externalTools instead of /api/admin/externalTools). + curl -s -H "X-Dataverse-key:$API_TOKEN" http://localhost:8080/api/externalTools/$TOOL_ID + Removing an External Tool From a Dataverse Installation +++++++++++++++++++++++++++++++++++++++++++++++++++++++ @@ -66,6 +75,9 @@ Assuming the external tool database id is "1", remove it with the following comm export TOOL_ID=1 curl -X DELETE http://localhost:8080/api/admin/externalTools/$TOOL_ID + This API is Superuser only. Note the endpoint difference (/api/externalTools instead of /api/admin/externalTools). + curl -s -H "X-Dataverse-key:$API_TOKEN" -X DELETE http://localhost:8080/api/externalTools/$TOOL_ID + .. _testing-external-tools: Testing External Tools diff --git a/src/main/java/edu/harvard/iq/dataverse/api/ExternalToolsApi.java b/src/main/java/edu/harvard/iq/dataverse/api/ExternalToolsApi.java new file mode 100644 index 00000000000..bf5634e09a8 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/api/ExternalToolsApi.java @@ -0,0 +1,62 @@ +package edu.harvard.iq.dataverse.api; + +import edu.harvard.iq.dataverse.api.auth.AuthRequired; +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import jakarta.inject.Inject; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.Response; + +@Path("externalTools") +public class ExternalToolsApi extends AbstractApiBean { + + @Inject + ExternalTools externalTools; + + @GET + @AuthRequired + public Response getExternalTools(@Context ContainerRequestContext crc) { + Response notAuthorized = authorize(crc); + return notAuthorized == null ? externalTools.getExternalTools() : notAuthorized; + } + + @GET + @AuthRequired + @Path("{id}") + public Response getExternalTool(@Context ContainerRequestContext crc, @PathParam("id") long externalToolIdFromUser) { + Response notAuthorized = authorize(crc); + return notAuthorized == null ? externalTools.getExternalTool(externalToolIdFromUser) : notAuthorized; + } + + @POST + @AuthRequired + public Response addExternalTool(@Context ContainerRequestContext crc, String manifest) { + Response notAuthorized = authorize(crc); + return notAuthorized == null ? externalTools.addExternalTool(manifest) : notAuthorized; + } + + @DELETE + @AuthRequired + @Path("{id}") + public Response deleteExternalTool(@Context ContainerRequestContext crc, @PathParam("id") long externalToolIdFromUser) { + Response notAuthorized = authorize(crc); + return notAuthorized == null ? externalTools.deleteExternalTool(externalToolIdFromUser) : notAuthorized; + } + + private Response authorize(ContainerRequestContext crc) { + try { + AuthenticatedUser user = getRequestAuthenticatedUserOrDie(crc); + if (!user.isSuperuser()) { + return error(Response.Status.FORBIDDEN, "Superusers only."); + } + } catch (WrappedResponse ex) { + return error(Response.Status.FORBIDDEN, "Superusers only."); + } + return null; + } +} diff --git a/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java b/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java index 22abf6fa2e3..a3e2cca329d 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java @@ -11,11 +11,11 @@ import java.nio.file.Paths; import jakarta.json.Json; import jakarta.json.JsonArray; -import jakarta.json.JsonObject; import jakarta.json.JsonObjectBuilder; import jakarta.json.JsonReader; 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.FORBIDDEN; import static jakarta.ws.rs.core.Response.Status.OK; import org.hamcrest.CoreMatchers; import org.hamcrest.Matchers; @@ -37,6 +37,93 @@ public void testGetExternalTools() { getExternalTools.prettyPrint(); } + @Test + public void testExternalToolsNonAdminEndpoint() { + Response createUser = UtilIT.createRandomUser(); + createUser.prettyPrint(); + createUser.then().assertThat() + .statusCode(OK.getStatusCode()); + String username = UtilIT.getUsernameFromResponse(createUser); + String apiToken = UtilIT.getApiTokenFromResponse(createUser); + UtilIT.setSuperuserStatus(username, true); + + Response createDataverseResponse = UtilIT.createRandomDataverse(apiToken); + createDataverseResponse.prettyPrint(); + createDataverseResponse.then().assertThat() + .statusCode(CREATED.getStatusCode()); + + String dataverseAlias = UtilIT.getAliasFromResponse(createDataverseResponse); + + Response createDataset = UtilIT.createRandomDatasetViaNativeApi(dataverseAlias, apiToken); + createDataset.prettyPrint(); + createDataset.then().assertThat() + .statusCode(CREATED.getStatusCode()); + + Integer datasetId = JsonPath.from(createDataset.getBody().asString()).getInt("data.id"); + String datasetPid = JsonPath.from(createDataset.getBody().asString()).getString("data.persistentId"); + + String toolManifest = """ +{ + "displayName": "Dataset Configurator", + "description": "Slices! Dices! More info.", + "types": [ + "configure" + ], + "scope": "dataset", + "toolUrl": "https://datasetconfigurator.com", + "toolParameters": { + "queryParameters": [ + { + "datasetPid": "{datasetPid}" + }, + { + "localeCode": "{localeCode}" + } + ] + } + } +"""; + + Response addExternalTool = UtilIT.addExternalTool(JsonUtil.getJsonObject(toolManifest), apiToken); + addExternalTool.prettyPrint(); + addExternalTool.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.displayName", CoreMatchers.equalTo("Dataset Configurator")); + + Long toolId = JsonPath.from(addExternalTool.getBody().asString()).getLong("data.id"); + Response getExternalToolsByDatasetId = UtilIT.getExternalToolForDatasetById(datasetId.toString(), "configure", apiToken, toolId.toString()); + getExternalToolsByDatasetId.prettyPrint(); + getExternalToolsByDatasetId.then().assertThat() + .body("data.displayName", CoreMatchers.equalTo("Dataset Configurator")) + .body("data.scope", CoreMatchers.equalTo("dataset")) + .body("data.types[0]", CoreMatchers.equalTo("configure")) + .body("data.toolUrlWithQueryParams", CoreMatchers.equalTo("https://datasetconfigurator.com?datasetPid=" + datasetPid)) + .statusCode(OK.getStatusCode()); + + Response getExternalTools = UtilIT.getExternalTools(apiToken); + getExternalTools.prettyPrint(); + getExternalTools.then().assertThat() + .statusCode(OK.getStatusCode()); + Response getExternalTool = UtilIT.getExternalTool(toolId, apiToken); + getExternalTool.prettyPrint(); + getExternalTool.then().assertThat() + .statusCode(OK.getStatusCode()); + + //Delete the tool added by this test... + Response deleteExternalTool = UtilIT.deleteExternalTool(toolId, apiToken); + deleteExternalTool.prettyPrint(); + deleteExternalTool.then().assertThat() + .statusCode(OK.getStatusCode()); + + // non superuser has no access + UtilIT.setSuperuserStatus(username, false); + getExternalTools = UtilIT.getExternalTools(apiToken); + getExternalTools.prettyPrint(); + getExternalTools.then().assertThat() + .statusCode(FORBIDDEN.getStatusCode()) + .body("message", CoreMatchers.equalTo("Superusers only.")); + } + @Test public void testFileLevelTool1() { 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 1930610532a..c6762c83bac 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java @@ -2538,6 +2538,42 @@ static Response deleteExternalTool(long externalToolid) { .delete("/api/admin/externalTools/" + externalToolid); } +// ExternalTools with token + static Response getExternalTools(String apiToken) { + RequestSpecification requestSpecification = given(); + if (apiToken != null) { + requestSpecification.header(UtilIT.API_TOKEN_HTTP_HEADER, apiToken); + } + return requestSpecification.get("/api/externalTools"); + } + + static Response getExternalTool(long id, String apiToken) { + RequestSpecification requestSpecification = given(); + if (apiToken != null) { + requestSpecification.header(UtilIT.API_TOKEN_HTTP_HEADER, apiToken); + } + return requestSpecification.get("/api/externalTools/" + id); + } + + static Response addExternalTool(JsonObject jsonObject, String apiToken) { + RequestSpecification requestSpecification = given(); + if (apiToken != null) { + requestSpecification.header(UtilIT.API_TOKEN_HTTP_HEADER, apiToken); + } + return requestSpecification + .body(jsonObject.toString()) + .contentType(ContentType.JSON) + .post("/api/externalTools"); + } + + static Response deleteExternalTool(long externalToolid, String apiToken) { + RequestSpecification requestSpecification = given(); + if (apiToken != null) { + requestSpecification.header(UtilIT.API_TOKEN_HTTP_HEADER, apiToken); + } + return requestSpecification.delete("/api/externalTools/" + externalToolid); + } + static Response getExternalToolsForDataset(String idOrPersistentIdOfDataset, String type, String apiToken) { String idInPath = idOrPersistentIdOfDataset; // Assume it's a number. String optionalQueryParam = ""; // If idOrPersistentId is a number we'll just put it in the path. 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 100/137] 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 101/137] 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 102/137] 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 103/137] 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 104/137] 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 105/137] 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 106/137] 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 107/137] 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 108/137] 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 109/137] 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 110/137] 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 111/137] 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 5bb6c002d575149b52da1842ee1f13776d2215ef Mon Sep 17 00:00:00 2001 From: Philip Durbin Date: Fri, 13 Dec 2024 14:16:10 -0500 Subject: [PATCH 112/137] reword #7961 --- .../source/admin/metadatacustomization.rst | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/doc/sphinx-guides/source/admin/metadatacustomization.rst b/doc/sphinx-guides/source/admin/metadatacustomization.rst index ffabb033c25..3112fdb44bd 100644 --- a/doc/sphinx-guides/source/admin/metadatacustomization.rst +++ b/doc/sphinx-guides/source/admin/metadatacustomization.rst @@ -244,6 +244,8 @@ Each of the three main sections own sets of properties: | | #metadataBlock) | | | +---------------------------+--------------------------------------------------------+----------------------------------------------------------+-----------------------+ +.. _cvoc-props: + #controlledVocabulary (enumerated) properties ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -259,10 +261,10 @@ Each of the three main sections own sets of properties: | | | an existing #datasetField from | | | | another metadata block.) | +--------------+--------------------------------------------+-----------------------------------------+ -| Value | A short display string, representing | Free text. As boolean, values "True" | -| | an enumerated value for this field. If | and "False" are recommended, "Unknown" | -| | the identifier property is empty, | value is an option. | -| | this value is used as the identifier. | | +| Value | A short display string, representing | Free text. When defining a boolean, the | +| | an enumerated value for this field. If | values "True" and "False" are | +| | the identifier property is empty, | recommended and "Unknown" can be added | +| | this value is used as the identifier. | if needed. | +--------------+--------------------------------------------+-----------------------------------------+ | identifier | A string used to encode the selected | Free text | | | enumerated value of a field. If this | | @@ -293,9 +295,9 @@ FieldType definitions +---------------+------------------------------------+ | text | Any text other than newlines may | | | be entered into this field. | -| | The text fieldtype can be used | -| | combined with | -| | #controlledVocabulary as boolean. | +| | The text fieldtype may used to | +| | define a boolean (see "Value" | +| | under :ref:`cvoc-props`). | +---------------+------------------------------------+ | textbox | Any text may be entered. For | | | input, the Dataverse Software | From 32de2443bc32a468a258dd0bc60470cfe7299b8b Mon Sep 17 00:00:00 2001 From: Victoria Lubitch Date: Fri, 13 Dec 2024 16:51:50 -0500 Subject: [PATCH 113/137] 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 114/137] 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 115/137] 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 116/137] 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 179d08576168b7f87431a4486a947c978f20f3cc Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Mon, 16 Dec 2024 09:16:02 -0500 Subject: [PATCH 117/137] update db script to 6.5 --- src/main/resources/db/migration/V6.4.0.3.sql | 1 - src/main/resources/db/migration/{V6.4.0.4.sql => V6.5.0.1.sql} | 0 2 files changed, 1 deletion(-) rename src/main/resources/db/migration/{V6.4.0.4.sql => V6.5.0.1.sql} (100%) diff --git a/src/main/resources/db/migration/V6.4.0.3.sql b/src/main/resources/db/migration/V6.4.0.3.sql index c3639b3fb7f..307d8ed206c 100644 --- a/src/main/resources/db/migration/V6.4.0.3.sql +++ b/src/main/resources/db/migration/V6.4.0.3.sql @@ -1,3 +1,2 @@ -- Add this boolean flag to accommodate a new harvesting client feature ALTER TABLE harvestingclient ADD COLUMN IF NOT EXISTS useOaiIdAsPid BOOLEAN DEFAULT FALSE; - diff --git a/src/main/resources/db/migration/V6.4.0.4.sql b/src/main/resources/db/migration/V6.5.0.1.sql similarity index 100% rename from src/main/resources/db/migration/V6.4.0.4.sql rename to src/main/resources/db/migration/V6.5.0.1.sql From 3fd62f6224fb5e8bf1f11554559495f3e83582b0 Mon Sep 17 00:00:00 2001 From: Philip Durbin Date: Mon, 16 Dec 2024 09:16:27 -0500 Subject: [PATCH 118/137] 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 119/137] 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 120/137] 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 649e2afe7622e215bc72893744ca1c7d159ad373 Mon Sep 17 00:00:00 2001 From: Philip Durbin Date: Mon, 16 Dec 2024 14:16:38 -0500 Subject: [PATCH 121/137] typo #7961 --- doc/sphinx-guides/source/admin/metadatacustomization.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/sphinx-guides/source/admin/metadatacustomization.rst b/doc/sphinx-guides/source/admin/metadatacustomization.rst index 3112fdb44bd..4c9dc693a0d 100644 --- a/doc/sphinx-guides/source/admin/metadatacustomization.rst +++ b/doc/sphinx-guides/source/admin/metadatacustomization.rst @@ -295,7 +295,7 @@ FieldType definitions +---------------+------------------------------------+ | text | Any text other than newlines may | | | be entered into this field. | -| | The text fieldtype may used to | +| | The text fieldtype may be used to | | | define a boolean (see "Value" | | | under :ref:`cvoc-props`). | +---------------+------------------------------------+ 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 122/137] 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 133b1db69b30da1d132c554e9b16f530925cc272 Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Tue, 17 Dec 2024 09:41:04 -0500 Subject: [PATCH 123/137] fix doc formatting --- doc/sphinx-guides/source/admin/external-tools.rst | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/doc/sphinx-guides/source/admin/external-tools.rst b/doc/sphinx-guides/source/admin/external-tools.rst index 50c2ec63c44..1669398e349 100644 --- a/doc/sphinx-guides/source/admin/external-tools.rst +++ b/doc/sphinx-guides/source/admin/external-tools.rst @@ -37,7 +37,8 @@ Configure the tool with the curl command below, making sure to replace the ``fab curl -X POST -H 'Content-type: application/json' http://localhost:8080/api/admin/externalTools --upload-file fabulousFileTool.json - This API is Superuser only. Note the endpoint difference (/api/externalTools instead of /api/admin/externalTools). +This API is Superuser only. Note the endpoint difference (/api/externalTools instead of /api/admin/externalTools). +.. code-block:: bash curl -s -H "X-Dataverse-key:$API_TOKEN" -X POST -H 'Content-type: application/json' http://localhost:8080/api/externalTools --upload-file fabulousFileTool.json Listing All External Tools in a Dataverse Installation @@ -49,7 +50,8 @@ To list all the external tools that are available in a Dataverse installation: curl http://localhost:8080/api/admin/externalTools - This API is Superuser only. Note the endpoint difference (/api/externalTools instead of /api/admin/externalTools). +This API is Superuser only. Note the endpoint difference (/api/externalTools instead of /api/admin/externalTools). +.. code-block:: bash curl -s -H "X-Dataverse-key:$API_TOKEN" http://localhost:8080/api/externalTools Showing an External Tool in a Dataverse Installation @@ -62,7 +64,8 @@ To show one of the external tools that are available in a Dataverse installation export TOOL_ID=1 curl http://localhost:8080/api/admin/externalTools/$TOOL_ID - This API is Superuser only. Note the endpoint difference (/api/externalTools instead of /api/admin/externalTools). +This API is Superuser only. Note the endpoint difference (/api/externalTools instead of /api/admin/externalTools). +.. code-block:: bash curl -s -H "X-Dataverse-key:$API_TOKEN" http://localhost:8080/api/externalTools/$TOOL_ID Removing an External Tool From a Dataverse Installation @@ -75,7 +78,8 @@ Assuming the external tool database id is "1", remove it with the following comm export TOOL_ID=1 curl -X DELETE http://localhost:8080/api/admin/externalTools/$TOOL_ID - This API is Superuser only. Note the endpoint difference (/api/externalTools instead of /api/admin/externalTools). +This API is Superuser only. Note the endpoint difference (/api/externalTools instead of /api/admin/externalTools). +.. code-block:: bash curl -s -H "X-Dataverse-key:$API_TOKEN" -X DELETE http://localhost:8080/api/externalTools/$TOOL_ID .. _testing-external-tools: From a8ee025eaa093be993394742f24c6395c27f8dd4 Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Tue, 17 Dec 2024 10:04:42 -0500 Subject: [PATCH 124/137] fix doc formatting --- doc/sphinx-guides/source/admin/external-tools.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/doc/sphinx-guides/source/admin/external-tools.rst b/doc/sphinx-guides/source/admin/external-tools.rst index 1669398e349..d654bcd1e8d 100644 --- a/doc/sphinx-guides/source/admin/external-tools.rst +++ b/doc/sphinx-guides/source/admin/external-tools.rst @@ -39,6 +39,7 @@ Configure the tool with the curl command below, making sure to replace the ``fab This API is Superuser only. Note the endpoint difference (/api/externalTools instead of /api/admin/externalTools). .. code-block:: bash + curl -s -H "X-Dataverse-key:$API_TOKEN" -X POST -H 'Content-type: application/json' http://localhost:8080/api/externalTools --upload-file fabulousFileTool.json Listing All External Tools in a Dataverse Installation @@ -52,6 +53,7 @@ To list all the external tools that are available in a Dataverse installation: This API is Superuser only. Note the endpoint difference (/api/externalTools instead of /api/admin/externalTools). .. code-block:: bash + curl -s -H "X-Dataverse-key:$API_TOKEN" http://localhost:8080/api/externalTools Showing an External Tool in a Dataverse Installation @@ -66,6 +68,7 @@ To show one of the external tools that are available in a Dataverse installation This API is Superuser only. Note the endpoint difference (/api/externalTools instead of /api/admin/externalTools). .. code-block:: bash + curl -s -H "X-Dataverse-key:$API_TOKEN" http://localhost:8080/api/externalTools/$TOOL_ID Removing an External Tool From a Dataverse Installation @@ -80,6 +83,7 @@ Assuming the external tool database id is "1", remove it with the following comm This API is Superuser only. Note the endpoint difference (/api/externalTools instead of /api/admin/externalTools). .. code-block:: bash + curl -s -H "X-Dataverse-key:$API_TOKEN" -X DELETE http://localhost:8080/api/externalTools/$TOOL_ID .. _testing-external-tools: From a65dbde46453a3cda2f0f4dee4644be2c6464eb2 Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Tue, 17 Dec 2024 13:54:55 -0500 Subject: [PATCH 125/137] fix doc formatting --- doc/sphinx-guides/source/admin/external-tools.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/doc/sphinx-guides/source/admin/external-tools.rst b/doc/sphinx-guides/source/admin/external-tools.rst index d654bcd1e8d..3d7c057bda5 100644 --- a/doc/sphinx-guides/source/admin/external-tools.rst +++ b/doc/sphinx-guides/source/admin/external-tools.rst @@ -38,6 +38,7 @@ Configure the tool with the curl command below, making sure to replace the ``fab curl -X POST -H 'Content-type: application/json' http://localhost:8080/api/admin/externalTools --upload-file fabulousFileTool.json This API is Superuser only. Note the endpoint difference (/api/externalTools instead of /api/admin/externalTools). + .. code-block:: bash curl -s -H "X-Dataverse-key:$API_TOKEN" -X POST -H 'Content-type: application/json' http://localhost:8080/api/externalTools --upload-file fabulousFileTool.json @@ -52,6 +53,7 @@ To list all the external tools that are available in a Dataverse installation: curl http://localhost:8080/api/admin/externalTools This API is Superuser only. Note the endpoint difference (/api/externalTools instead of /api/admin/externalTools). + .. code-block:: bash curl -s -H "X-Dataverse-key:$API_TOKEN" http://localhost:8080/api/externalTools @@ -67,6 +69,7 @@ To show one of the external tools that are available in a Dataverse installation curl http://localhost:8080/api/admin/externalTools/$TOOL_ID This API is Superuser only. Note the endpoint difference (/api/externalTools instead of /api/admin/externalTools). + .. code-block:: bash curl -s -H "X-Dataverse-key:$API_TOKEN" http://localhost:8080/api/externalTools/$TOOL_ID @@ -82,6 +85,7 @@ Assuming the external tool database id is "1", remove it with the following comm curl -X DELETE http://localhost:8080/api/admin/externalTools/$TOOL_ID This API is Superuser only. Note the endpoint difference (/api/externalTools instead of /api/admin/externalTools). + .. code-block:: bash curl -s -H "X-Dataverse-key:$API_TOKEN" -X DELETE http://localhost:8080/api/externalTools/$TOOL_ID From 4faadf6e9a881569bcafc44123c736bcfff353f8 Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Tue, 17 Dec 2024 14:39:29 -0500 Subject: [PATCH 126/137] open up get apis for non-superuser --- .../10930-marketplace-external-tools-apis.md | 6 ++-- .../source/admin/external-tools.rst | 8 ++--- .../iq/dataverse/api/ExternalToolsApi.java | 12 +++---- .../iq/dataverse/api/ExternalToolsIT.java | 31 ++++++++++++++----- 4 files changed, 34 insertions(+), 23 deletions(-) diff --git a/doc/release-notes/10930-marketplace-external-tools-apis.md b/doc/release-notes/10930-marketplace-external-tools-apis.md index 9e20c908823..e3350a8b2d2 100644 --- a/doc/release-notes/10930-marketplace-external-tools-apis.md +++ b/doc/release-notes/10930-marketplace-external-tools-apis.md @@ -1,14 +1,14 @@ ## New APIs for External Tools Registration for Marketplace -New API base path /api/externalTools created that mimics the admin APIs /api/admin/externalTools. These new apis require an authenticated superuser token. +New API base path /api/externalTools created that mimics the admin APIs /api/admin/externalTools. These new add and delete apis require an authenticated superuser token. Example: ``` API_TOKEN='xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' export TOOL_ID=1 - curl -s -H "X-Dataverse-key:$API_TOKEN" http://localhost:8080/api/externalTools - curl -s -H "X-Dataverse-key:$API_TOKEN" http://localhost:8080/api/externalTools/$TOOL_ID + curl http://localhost:8080/api/externalTools + curl http://localhost:8080/api/externalTools/$TOOL_ID curl -s -H "X-Dataverse-key:$API_TOKEN" -X POST -H 'Content-type: application/json' http://localhost:8080/api/externalTools --upload-file fabulousFileTool.json curl -s -H "X-Dataverse-key:$API_TOKEN" -X DELETE http://localhost:8080/api/externalTools/$TOOL_ID ``` diff --git a/doc/sphinx-guides/source/admin/external-tools.rst b/doc/sphinx-guides/source/admin/external-tools.rst index 3d7c057bda5..c3e71c13ac6 100644 --- a/doc/sphinx-guides/source/admin/external-tools.rst +++ b/doc/sphinx-guides/source/admin/external-tools.rst @@ -52,11 +52,11 @@ To list all the external tools that are available in a Dataverse installation: curl http://localhost:8080/api/admin/externalTools -This API is Superuser only. Note the endpoint difference (/api/externalTools instead of /api/admin/externalTools). +This API is open to any user. Note the endpoint difference (/api/externalTools instead of /api/admin/externalTools). .. code-block:: bash - curl -s -H "X-Dataverse-key:$API_TOKEN" http://localhost:8080/api/externalTools + curl http://localhost:8080/api/externalTools Showing an External Tool in a Dataverse Installation ++++++++++++++++++++++++++++++++++++++++++++++++++++ @@ -68,11 +68,11 @@ To show one of the external tools that are available in a Dataverse installation export TOOL_ID=1 curl http://localhost:8080/api/admin/externalTools/$TOOL_ID -This API is Superuser only. Note the endpoint difference (/api/externalTools instead of /api/admin/externalTools). +This API is open to any user. Note the endpoint difference (/api/externalTools instead of /api/admin/externalTools). .. code-block:: bash - curl -s -H "X-Dataverse-key:$API_TOKEN" http://localhost:8080/api/externalTools/$TOOL_ID + curl http://localhost:8080/api/externalTools/$TOOL_ID Removing an External Tool From a Dataverse Installation +++++++++++++++++++++++++++++++++++++++++++++++++++++++ diff --git a/src/main/java/edu/harvard/iq/dataverse/api/ExternalToolsApi.java b/src/main/java/edu/harvard/iq/dataverse/api/ExternalToolsApi.java index bf5634e09a8..92139d86caf 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/ExternalToolsApi.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/ExternalToolsApi.java @@ -19,18 +19,14 @@ public class ExternalToolsApi extends AbstractApiBean { ExternalTools externalTools; @GET - @AuthRequired - public Response getExternalTools(@Context ContainerRequestContext crc) { - Response notAuthorized = authorize(crc); - return notAuthorized == null ? externalTools.getExternalTools() : notAuthorized; + public Response getExternalTools() { + return externalTools.getExternalTools(); } @GET - @AuthRequired @Path("{id}") - public Response getExternalTool(@Context ContainerRequestContext crc, @PathParam("id") long externalToolIdFromUser) { - Response notAuthorized = authorize(crc); - return notAuthorized == null ? externalTools.getExternalTool(externalToolIdFromUser) : notAuthorized; + public Response getExternalTool(@PathParam("id") long externalToolIdFromUser) { + return externalTools.getExternalTool(externalToolIdFromUser); } @POST diff --git a/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java b/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java index a3e2cca329d..1956e0eb8df 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java @@ -109,19 +109,34 @@ public void testExternalToolsNonAdminEndpoint() { getExternalTool.then().assertThat() .statusCode(OK.getStatusCode()); - //Delete the tool added by this test... - Response deleteExternalTool = UtilIT.deleteExternalTool(toolId, apiToken); - deleteExternalTool.prettyPrint(); - deleteExternalTool.then().assertThat() - .statusCode(OK.getStatusCode()); - - // non superuser has no access + // non superuser can only view tools UtilIT.setSuperuserStatus(username, false); getExternalTools = UtilIT.getExternalTools(apiToken); - getExternalTools.prettyPrint(); getExternalTools.then().assertThat() + .statusCode(OK.getStatusCode()); + getExternalToolsByDatasetId = UtilIT.getExternalToolForDatasetById(datasetId.toString(), "configure", apiToken, toolId.toString()); + getExternalToolsByDatasetId.prettyPrint(); + getExternalToolsByDatasetId.then().assertThat() + .statusCode(OK.getStatusCode()); + + //Add by non-superuser will fail + addExternalTool = UtilIT.addExternalTool(JsonUtil.getJsonObject(toolManifest), apiToken); + addExternalTool.then().assertThat() + .statusCode(FORBIDDEN.getStatusCode()) + .body("message", CoreMatchers.equalTo("Superusers only.")); + + //Delete by non-superuser will fail + Response deleteExternalTool = UtilIT.deleteExternalTool(toolId, apiToken); + deleteExternalTool.then().assertThat() .statusCode(FORBIDDEN.getStatusCode()) .body("message", CoreMatchers.equalTo("Superusers only.")); + + //Delete the tool added by this test... + UtilIT.setSuperuserStatus(username, true); + deleteExternalTool = UtilIT.deleteExternalTool(toolId, apiToken); + deleteExternalTool.prettyPrint(); + deleteExternalTool.then().assertThat() + .statusCode(OK.getStatusCode()); } @Test From b2e271a403ce6a02a1b60183fcb5698374975f57 Mon Sep 17 00:00:00 2001 From: Florian Fritze Date: Wed, 18 Dec 2024 09:43:36 +0100 Subject: [PATCH 127/137] 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 128/137] 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 129/137] #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 130/137] 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 106ebe46105b213468460faa102b8dc33990198d Mon Sep 17 00:00:00 2001 From: Don Sizemore Date: Thu, 19 Dec 2024 10:33:34 -0500 Subject: [PATCH 131/137] #10707 document S3 RBAC preference on v5.14+ --- doc/sphinx-guides/source/installation/config.rst | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst index 30a36da9499..009d7775a13 100644 --- a/doc/sphinx-guides/source/installation/config.rst +++ b/doc/sphinx-guides/source/installation/config.rst @@ -1093,6 +1093,8 @@ The Dataverse Software S3 driver supports multi-part upload for large files (ove First: Set Up Accounts and Access Credentials ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +**Note:** As of version 5.14, if Dataverse is running in an EC2 instance it will prefer RBAC for S3, even if administrators configure Dataverse with programmatic access keys. This is preferential from a security perspective as there are no keys to rotate or have stolen. If you intend to assign a role to your EC2 instance, you will still need the ``~/.aws/config`` file to specify the region but you need not generate credentials. For more information please see https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2.html + The Dataverse Software and the AWS SDK make use of the "AWS credentials profile file" and "AWS config profile file" located in ``~/.aws/`` where ``~`` is the home directory of the user you run Payara as. This file can be generated via either of two methods described below: @@ -1116,13 +1118,6 @@ To **create a user** with full S3 access and nothing more for security reasons, for more info on this process. To use programmatic access, **Generate the user keys** needed for a Dataverse installation afterwards by clicking on the created user. -(You can skip this step when running on EC2, see below.) - -.. TIP:: - If you are hosting your Dataverse installation on an AWS EC2 instance alongside storage in S3, it is possible to use IAM Roles instead - of the credentials file (the file at ``~/.aws/credentials`` mentioned below). Please note that you will still need the - ``~/.aws/config`` file to specify the region. For more information on this option, see - https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2.html Preparation When Using Custom S3-Compatible Service ################################################### From 188f8dcc1b06467610d903f817f07efe72c96183 Mon Sep 17 00:00:00 2001 From: Philip Durbin Date: Thu, 19 Dec 2024 14:35:02 -0500 Subject: [PATCH 132/137] add space after link to prevent it from breaking #10384 --- 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 81f564838d7..d4057c5db0c 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -804,7 +804,7 @@ notification.email.greeting.html=Hello,
# Bundle file editors, please note that "notification.email.welcome" is used in a unit test notification.email.welcome=Welcome to {0}! Get started by adding or finding data. Have questions? Check out the User Guide at {1}/{2}/user or contact {3} at {4} for assistance. notification.email.welcomeConfirmEmailAddOn=\n\nPlease verify your email address at {0} . Note, the verify link will expire after {1}. Send another verification email by visiting your account page. -notification.email.requestFileAccess=File access requested for dataset: {0} by {1} ({2}). Manage permissions at {3}. +notification.email.requestFileAccess=File access requested for dataset: {0} by {1} ({2}). Manage permissions at {3} . notification.email.requestFileAccess.guestbookResponse=

Guestbook Response:

{0} notification.email.grantFileAccess=Access granted for files in dataset: {0} (view at {1} ). notification.email.rejectFileAccess=Your request for access was rejected for the requested files in the dataset: {0} (view at {1} ). If you have any questions about why your request was rejected, you may reach the dataset owner using the "Contact" link on the upper right corner of the dataset page. From 5abbc8d7bce684cba386b08a69d1b53b8c72f00e Mon Sep 17 00:00:00 2001 From: Philip Durbin Date: Thu, 19 Dec 2024 14:44:59 -0500 Subject: [PATCH 133/137] add release note #10384 --- doc/release-notes/10384-link.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 doc/release-notes/10384-link.md diff --git a/doc/release-notes/10384-link.md b/doc/release-notes/10384-link.md new file mode 100644 index 00000000000..7092241adf4 --- /dev/null +++ b/doc/release-notes/10384-link.md @@ -0,0 +1,3 @@ +### Broken Link in Email When Users Request Access to Files + +When users request access to a files, the people who have permission to grant access receive an email with a link in it that didn't work due to a trailing period (full stop) right next to the link (e.g. `https://demo.dataverse.org/permissions-manage-files.xhtml?id=9.`) A space has been added to fix this. See #10384 and #11115. From b77a7a1cc9c6719dc72ca0e7144f73f72535a793 Mon Sep 17 00:00:00 2001 From: Don Sizemore Date: Fri, 20 Dec 2024 11:10:29 -0500 Subject: [PATCH 134/137] #10707 Jim points out that this applies only to the default profile --- doc/sphinx-guides/source/installation/config.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst index 009d7775a13..b6d0287a88d 100644 --- a/doc/sphinx-guides/source/installation/config.rst +++ b/doc/sphinx-guides/source/installation/config.rst @@ -1093,7 +1093,7 @@ The Dataverse Software S3 driver supports multi-part upload for large files (ove First: Set Up Accounts and Access Credentials ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -**Note:** As of version 5.14, if Dataverse is running in an EC2 instance it will prefer RBAC for S3, even if administrators configure Dataverse with programmatic access keys. This is preferential from a security perspective as there are no keys to rotate or have stolen. If you intend to assign a role to your EC2 instance, you will still need the ``~/.aws/config`` file to specify the region but you need not generate credentials. For more information please see https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2.html +**Note:** As of version 5.14, if Dataverse is running in an EC2 instance it will prefer RBAC for the S3 default profile, even if administrators configure Dataverse with programmatic access keys. This is preferential from a security perspective as there are no keys to rotate or have stolen. If you intend to assign a role to your EC2 instance, you will still need the ``~/.aws/config`` file to specify the region but you need not generate credentials for the default profile. For more information please see https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2.html The Dataverse Software and the AWS SDK make use of the "AWS credentials profile file" and "AWS config profile file" located in ``~/.aws/`` where ``~`` is the home directory of the user you run Payara as. This file can be generated via either From 8b3f0e19ace18afaf22aeadbc04e9ed477392cca Mon Sep 17 00:00:00 2001 From: Don Sizemore Date: Fri, 20 Dec 2024 11:32:16 -0500 Subject: [PATCH 135/137] #10707 make named profiles more explicit per qqmyers --- doc/sphinx-guides/source/installation/config.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst index b6d0287a88d..3910580de9a 100644 --- a/doc/sphinx-guides/source/installation/config.rst +++ b/doc/sphinx-guides/source/installation/config.rst @@ -1093,7 +1093,7 @@ The Dataverse Software S3 driver supports multi-part upload for large files (ove First: Set Up Accounts and Access Credentials ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -**Note:** As of version 5.14, if Dataverse is running in an EC2 instance it will prefer RBAC for the S3 default profile, even if administrators configure Dataverse with programmatic access keys. This is preferential from a security perspective as there are no keys to rotate or have stolen. If you intend to assign a role to your EC2 instance, you will still need the ``~/.aws/config`` file to specify the region but you need not generate credentials for the default profile. For more information please see https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2.html +**Note:** As of version 5.14, if Dataverse is running in an EC2 instance it will prefer RBAC for the S3 default profile, even if administrators configure Dataverse with programmatic access keys. Named profiles can still be used to override RBAC for specific datastores. This is preferential from a security perspective as there are no keys to rotate or have stolen. If you intend to assign a role to your EC2 instance, you will still need the ``~/.aws/config`` file to specify the region but you need not generate credentials for the default profile. For more information please see https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2.html The Dataverse Software and the AWS SDK make use of the "AWS credentials profile file" and "AWS config profile file" located in ``~/.aws/`` where ``~`` is the home directory of the user you run Payara as. This file can be generated via either 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 136/137] 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') }} From ac60f4bcc895465e1d13965e9e54645646dbd7dc Mon Sep 17 00:00:00 2001 From: Don Sizemore Date: Fri, 20 Dec 2024 15:27:39 -0500 Subject: [PATCH 137/137] #10707 final round of corrections per qqmyers --- doc/sphinx-guides/source/installation/config.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst index 3910580de9a..a2cbd36e694 100644 --- a/doc/sphinx-guides/source/installation/config.rst +++ b/doc/sphinx-guides/source/installation/config.rst @@ -1093,7 +1093,7 @@ The Dataverse Software S3 driver supports multi-part upload for large files (ove First: Set Up Accounts and Access Credentials ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -**Note:** As of version 5.14, if Dataverse is running in an EC2 instance it will prefer RBAC for the S3 default profile, even if administrators configure Dataverse with programmatic access keys. Named profiles can still be used to override RBAC for specific datastores. This is preferential from a security perspective as there are no keys to rotate or have stolen. If you intend to assign a role to your EC2 instance, you will still need the ``~/.aws/config`` file to specify the region but you need not generate credentials for the default profile. For more information please see https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2.html +**Note:** As of version 5.14, if Dataverse is running in an EC2 instance it will prefer Role-Based Access Control over the S3 default profile, even if administrators configure Dataverse with programmatic access keys. Named profiles can still be used to override RBAC for specific datastores. RBAC is preferential from a security perspective as there are no keys to rotate or have stolen. If you intend to assign a role to your EC2 instance, you will still need the ``~/.aws/config`` file to specify the region but you need not generate credentials for the default profile. For more information please see https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2.html The Dataverse Software and the AWS SDK make use of the "AWS credentials profile file" and "AWS config profile file" located in ``~/.aws/`` where ``~`` is the home directory of the user you run Payara as. This file can be generated via either