diff --git a/pom.xml b/pom.xml index 2297fd76f..8b8b7eee2 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,7 @@ nl.dtls fairdatapoint - 1.18.1 + 1.19.0 jar FairDataPoint @@ -210,6 +210,20 @@ rdf4j-sail-nativerdf ${rdf4j-runtime.version} + + org.eclipse.rdf4j + rdf4j-spring-boot-sparql-web + ${rdf4j-runtime.version} + + + + * + * + + + io.jsonwebtoken jjwt-api diff --git a/src/main/java/nl/dtls/fairdatapoint/Application.java b/src/main/java/nl/dtls/fairdatapoint/Application.java index c9ce5bb46..ef9aa5ca8 100644 --- a/src/main/java/nl/dtls/fairdatapoint/Application.java +++ b/src/main/java/nl/dtls/fairdatapoint/Application.java @@ -32,7 +32,10 @@ @SpringBootApplication @EnableWebMvc @EnableAsync -@ComponentScan(basePackages = "nl.dtls.fairdatapoint.*") +@ComponentScan(basePackages = { + "org.eclipse.rdf4j.http.server.readonly.sparql", + "nl.dtls.fairdatapoint.*" +}) @ConfigurationPropertiesScan("nl.dtls.fairdatapoint.config.*") public class Application { diff --git a/src/main/java/nl/dtls/fairdatapoint/api/controller/search/SearchSparqlController.java b/src/main/java/nl/dtls/fairdatapoint/api/controller/search/SearchSparqlController.java new file mode 100644 index 000000000..3550c429c --- /dev/null +++ b/src/main/java/nl/dtls/fairdatapoint/api/controller/search/SearchSparqlController.java @@ -0,0 +1,152 @@ +/** + * The MIT License + * Copyright © 2017 DTL + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +// This code is mostly copied from rdf4j spring-boot-sparql-web, with some customizations: +// +// https://github.com/eclipse-rdf4j/rdf4j/blob/main/spring-components/spring-boot-sparql-web +// +// Copyright (c) 2021 Eclipse RDF4J contributors. +// +// All rights reserved. This program and the accompanying materials +// are made available under the terms of the Eclipse Distribution License v1.0 +// which accompanies this distribution, and is available at +// http://www.eclipse.org/org/documents/edl-v10.php. +// +// SPDX-License-Identifier: BSD-3-Clause + +package nl.dtls.fairdatapoint.api.controller.search; + +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import org.eclipse.rdf4j.http.server.readonly.sparql.EvaluateResult; +import org.eclipse.rdf4j.http.server.readonly.sparql.SparqlQueryEvaluator; +import org.eclipse.rdf4j.query.MalformedQueryException; +import org.eclipse.rdf4j.repository.Repository; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.io.IOException; +import java.io.OutputStream; + +@Tag(name = "Search") +@RestController +public class SearchSparqlController { + + private static final String[] ALL_GRAPHS = {}; + + private static final String JSON_MEDIA_TYPES = "application/json, application/ld+json"; + + private final Repository rdf4jRepository; + + private final SparqlQueryEvaluator sparqlQueryEvaluator; + + /** + * Constructor + */ + public SearchSparqlController(Repository rdf4jRepository, SparqlQueryEvaluator sparqlQueryEvaluator) { + this.rdf4jRepository = rdf4jRepository; + this.sparqlQueryEvaluator = sparqlQueryEvaluator; + } + + /** + * Allows authenticated users to POST a full SPARQL query. + * Method body copied from org.eclipse.rdf4j.http.server.readonly.QueryResponder. + * The "Accept" header is required, and allowable media types depend on the type of query, + * as defined in org.eclipse.rdf4j.http.server.readonly.sparql.QueryTypes.formats. + * However, to simplify things, we restrict the allowable media types to JSON and/or JSON-LD. + */ + @PreAuthorize("isAuthenticated()") + @PostMapping( + path = "/search/sparql", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = { MediaType.APPLICATION_JSON_VALUE, "application/ld+json" } + ) + public void sparqlPost( + @RequestHeader(value = HttpHeaders.ACCEPT, defaultValue = JSON_MEDIA_TYPES) String acceptHeader, + @RequestBody SparqlQuery sparqlQuery, + HttpServletResponse response + ) throws IOException { + // enforce default accept header for wildcard + final String accept = ("*/*".equals(acceptHeader)) ? JSON_MEDIA_TYPES : acceptHeader; + try { + final EvaluateResultHttpResponse result = new EvaluateResultHttpResponse(response); + sparqlQueryEvaluator.evaluate( + result, + rdf4jRepository, + sparqlQuery.query, + accept, + toArray(sparqlQuery.defaultGraphUri), + toArray(sparqlQuery.namedGraphUri) + ); + } + catch (MalformedQueryException | IllegalStateException | IOException exception) { + response.sendError(HttpServletResponse.SC_BAD_REQUEST); + } + } + + private String[] toArray(String graphUri) { + if (graphUri != null && !graphUri.isEmpty()) { + return new String[]{graphUri}; + } + return ALL_GRAPHS; + } + + /** + * Encapsulates the {@link HttpServletResponse}. + * Copied from org.eclipse.rdf4j.http.server.readonly.EvaluateResultHttpResponse. + */ + protected static class EvaluateResultHttpResponse implements EvaluateResult { + + private final HttpServletResponse response; + + public EvaluateResultHttpResponse(HttpServletResponse response) { + this.response = response; + } + + @Override + public void setContentType(String contentType) { + response.setContentType(contentType); + } + + @Override + public String getContentType() { + return response.getContentType(); + } + + @Override + public OutputStream getOutputstream() throws IOException { + return response.getOutputStream(); + } + } + + /** + * Defines the content of the query request body, for JSON deserialization. + * @param query + * @param defaultGraphUri + * @param namedGraphUri + */ + public record SparqlQuery(String query, String defaultGraphUri, String namedGraphUri) { + } +} diff --git a/src/test/java/nl/dtls/fairdatapoint/acceptance/search/sparql/TestSearchSparqlController.java b/src/test/java/nl/dtls/fairdatapoint/acceptance/search/sparql/TestSearchSparqlController.java new file mode 100644 index 000000000..bcc6e8b47 --- /dev/null +++ b/src/test/java/nl/dtls/fairdatapoint/acceptance/search/sparql/TestSearchSparqlController.java @@ -0,0 +1,199 @@ +/** + * The MIT License + * Copyright © 2017 DTL + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package nl.dtls.fairdatapoint.acceptance.search.sparql; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import nl.dtls.fairdatapoint.WebIntegrationTest; +import nl.dtls.fairdatapoint.api.controller.search.SearchSparqlController; +import org.eclipse.rdf4j.rio.RDFFormat; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.http.*; + +import java.net.URI; +import java.util.HashMap; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("POST /search/sparql") +public class TestSearchSparqlController extends WebIntegrationTest { + + private final URI url = URI.create("/search/sparql"); + + private final ObjectMapper jsonMapper = new ObjectMapper(); + + private final String querySelectAll = "SELECT * WHERE { ?s ?p ?o }"; + + @Test + public void postSparqlUnauthenticated() throws JsonProcessingException { + // prepare request + SearchSparqlController.SparqlQuery sparqlQuery = new SearchSparqlController.SparqlQuery( + querySelectAll, null, null); + RequestEntity request = RequestEntity + .post(url) + .accept(MediaType.APPLICATION_JSON) + .body(sparqlQuery); + + // perform + ResponseEntity response = client.exchange(request, String.class); + + // evaluate + // TODO: this should actually be HttpStatus.UNAUTHORIZED, but FDP returns the wrong status code (see #704) + assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode()); + HashMap responseBodyMap = jsonMapper.readValue(response.getBody(), new TypeReference<>() { + }); + assertTrue(responseBodyMap.containsKey("error")); + } + + @ParameterizedTest + @ValueSource(strings = { MediaType.APPLICATION_JSON_VALUE, "*/*" }) + public void postSparqlSelectAll(String acceptHeader) throws JsonProcessingException { + // prepare request + SearchSparqlController.SparqlQuery sparqlQuery = new SearchSparqlController.SparqlQuery( + querySelectAll, null, null); + RequestEntity request = RequestEntity + .post(url) + .header(HttpHeaders.AUTHORIZATION, ALBERT_TOKEN) + .accept(MediaType.valueOf(acceptHeader)) + .contentType(MediaType.APPLICATION_JSON) + .body(sparqlQuery); + + // perform + ResponseEntity response = client.exchange(request, String.class); + + // evaluate + assertEquals(HttpStatus.OK, response.getStatusCode()); + HashMap responseBodyMap = jsonMapper.readValue(response.getBody(), new TypeReference<>() { + }); + + // expected SPARQL SELECT result structure: https://www.w3.org/TR/sparql11-results-json/ + assertEquals(Set.of("head", "results"), responseBodyMap.keySet()); + if (responseBodyMap.get("results") instanceof HashMap results) { + assertEquals(Set.of("bindings"), results.keySet()); + } + } + + @Test + public void postSparqlAskAny() throws JsonProcessingException { + // prepare request + SearchSparqlController.SparqlQuery sparqlQuery = new SearchSparqlController.SparqlQuery( + "ASK { ?s ?p ?o }", null, null); + RequestEntity request = RequestEntity + .post(url) + .header(HttpHeaders.AUTHORIZATION, ALBERT_TOKEN) + .accept(MediaType.APPLICATION_JSON) + .body(sparqlQuery); + + // perform + ResponseEntity response = client.exchange(request, String.class); + + // evaluate + assertEquals(HttpStatus.OK, response.getStatusCode()); + HashMap responseBodyMap = jsonMapper.readValue(response.getBody(), new TypeReference<>() { + }); + + // expected SPARQL ASK result structure: https://www.w3.org/TR/sparql11-results-json/ + assertEquals(Set.of("head", "boolean"), responseBodyMap.keySet()); + if (responseBodyMap.get("boolean") instanceof Boolean bool) { + assertTrue(bool); + } + } + + @ParameterizedTest + @ValueSource(strings = { + "CONSTRUCT WHERE { ?s a }", + "DESCRIBE ?s WHERE { ?s a }" + }) + public void postSparqlConstructOrDescribe(String query) throws JsonProcessingException { + // prepare request + SearchSparqlController.SparqlQuery sparqlQuery = new SearchSparqlController.SparqlQuery( + query, null, null); + RequestEntity request = RequestEntity + .post(url) + .header(HttpHeaders.AUTHORIZATION, ALBERT_TOKEN) + // QueryTypes.CONSTRUCT_OR_DESCRIBE does not support simple JSON, only application/ld+json (or ttl, n3) + .accept(MediaType.valueOf(RDFFormat.JSONLD.getDefaultMIMEType())) + .body(sparqlQuery); + + // perform + ResponseEntity response = client.exchange(request, String.class); + + // evaluate + assertEquals(HttpStatus.OK, response.getStatusCode()); + List> responseBodyList = jsonMapper.readValue( + response.getBody(), new TypeReference<>() {} + ); + assertFalse(responseBodyList.isEmpty()); + } + + + /** + * Verify that SPARQL Update operations are disallowed. + * The SparqlQueryEvaluator implements a whitelist in the QueryTypes enum. + */ + @ParameterizedTest + @ValueSource(strings = { + // https://www.w3.org/TR/sparql11-update/#graphUpdate + "INSERT DATA { ex:test1 dc:title \"test\" }", + "INSERT { ?s dc:title ?ol } WHERE { ?s dc:title ?o . BIND( STRLANG(STR(?o), \"en\") AS ?ol ) . }", + "DELETE DATA { ?s dc:title ?o } WHERE { ?s dc:title ?o }", + "DELETE WHERE { ?s dc:title ?o }", + "LOAD dc:", + "CLEAR GRAPH ex:", + // https://www.w3.org/TR/sparql11-update/#graphManagement + "CREATE GRAPH ex:", + "DROP GRAPH ex:", + "COPY DEFAULT TO GRAPH ex:", + "MOVE DEFAULT TO GRAPH ex:", + "ADD DEFAULT TO GRAPH ex:" + }) + public void postSparqlUpdateDenied(String update) throws JsonProcessingException { + // common prefixes (part of prologue in sparql grammar) + final String prologue = """ + PREFIX dc: + PREFIX ex: + """; + + // prepare request + SearchSparqlController.SparqlQuery sparqlQuery = new SearchSparqlController.SparqlQuery( + prologue + update, null, null); + RequestEntity request = RequestEntity + .post(url) + .header(HttpHeaders.AUTHORIZATION, ALBERT_TOKEN) + .accept(MediaType.APPLICATION_JSON) + .body(sparqlQuery); + + // perform + ResponseEntity response = client.exchange(request, String.class); + + // SPARQL Update operations should always be denied + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } +}