diff --git a/pass-data-client/pom.xml b/pass-data-client/pom.xml index 1c27dd7c..b8b78001 100644 --- a/pass-data-client/pom.xml +++ b/pass-data-client/pom.xml @@ -11,6 +11,7 @@ + 3.2.1 4.12.0 4.12.0 1.1.0 @@ -19,20 +20,17 @@ 3.14.0 5.10.1 5.10.0 - - 8080 - http://localhost:8080 - backend - backend + 1.19.4 + 3.9.6 - + com.markomilos.jsonapi jsonapi-adapters ${jsonapi-adapters.version} - + com.squareup.okhttp3 okhttp @@ -80,58 +78,30 @@ test + + org.springframework.boot + spring-boot-starter-test + ${spring-boot-maven-plugin.version} + test + + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + + org.apache.maven + maven-model + ${maven-model.version} + test + - - io.fabric8 - docker-maven-plugin - - - start - pre-integration-test - - start - - - - stop - post-integration-test - - stop - - - - - - - ghcr.io/eclipse-pass/pass-core-main:%v - - ${docker.platforms} - - ${pass.core.url} - ${pass.core.user} - ${pass.core.password} - - - - - ${pass.core.url}/data/grant - - 401 - - - - - ${pass.core.port}:${pass.core.port} - - - - - - - org.apache.maven.plugins maven-failsafe-plugin @@ -147,7 +117,7 @@ ${pass.core.url} ${pass.core.user} - ${pass.core.password} + ${pass.core.password} @@ -194,12 +164,17 @@ org.junit.jupiter:junit-jupiter-api org.junit.jupiter:junit-jupiter-params + + org.testcontainers:: + com.github.docker-java:: + org.springframework:spring-test ch.qos.logback:logback-classic org.junit.jupiter:junit-jupiter + org.springframework.boot:spring-boot-starter-test diff --git a/pass-data-client/src/main/java/org/eclipse/pass/support/client/JsonApiPassClient.java b/pass-data-client/src/main/java/org/eclipse/pass/support/client/JsonApiPassClient.java index 90aff389..62f992b8 100644 --- a/pass-data-client/src/main/java/org/eclipse/pass/support/client/JsonApiPassClient.java +++ b/pass-data-client/src/main/java/org/eclipse/pass/support/client/JsonApiPassClient.java @@ -24,6 +24,7 @@ import jsonapi.Document; import jsonapi.Document.IncludedSerialization; import jsonapi.JsonApiFactory; +import okhttp3.Call; import okhttp3.HttpUrl; import okhttp3.MediaType; import okhttp3.MultipartBody; @@ -97,6 +98,8 @@ public JsonApiPassClient(String baseUrl, String user, String pass) { client_builder.addInterceptor(new OkHttpBasicAuthInterceptor(user, pass)); } + client_builder.addInterceptor(new OkHttpCsrfInterceptor(this::csrf_token_call)); + client = client_builder.build(); moshi = create_moshi(false); @@ -104,6 +107,27 @@ public JsonApiPassClient(String baseUrl, String user, String pass) { update_moshi = create_moshi(true); } + // Return a GET call which will provide a CSRF token + private Call csrf_token_call() { + String url = get_url(Repository.class, null); + Request request = new Request.Builder().url(url).header("Accept", JSON_API_CONTENT_TYPE) + .header("Content-Type", JSON_API_CONTENT_TYPE).get().build(); + return client.newCall(request); + } + + /** + * Set the cached CSRF token. + * + * @param token new value + */ + protected void setCsrfToken(String token) { + client.interceptors().forEach(i -> { + if (i instanceof OkHttpCsrfInterceptor) { + OkHttpCsrfInterceptor.class.cast(i).setCsrfToken(token); + } + }); + } + private Moshi create_moshi(boolean serialize_nulls) { Factory factory = new JsonApiFactory.Builder().addTypes(Deposit.class, File.class, Funder.class, Grant.class, Journal.class, Policy.class, Publication.class, @@ -176,14 +200,16 @@ public void createObject(T obj) throws IOException { String url = baseUrl + "data/" + get_json_type(obj.getClass()); RequestBody body = RequestBody.create(json, JSON_API_MEDIA_TYPE); Request request = new Request.Builder().url(url).header("Accept", JSON_API_CONTENT_TYPE) - .addHeader("Content-Type", JSON_API_CONTENT_TYPE).post(body).build(); + .header("Content-Type", JSON_API_CONTENT_TYPE).post(body).build(); try (Response response = client.newCall(request).execute()) { + String result = response.body().string(); + if (!response.isSuccessful()) { throw new IOException( - "Create failed: " + url + " returned " + response.code() + " " + response.body().string()); + "Create failed: " + url + " returned " + response.code() + " " + result); } - Document result_doc = adapter.fromJson(response.body().string()); + Document result_doc = adapter.fromJson(result); obj.setId(result_doc.requireData().getId()); setVersionIfNeeded(result_doc, obj); } @@ -204,14 +230,16 @@ public void updateObject(T obj) throws IOException { String url = get_url(obj); RequestBody body = RequestBody.create(json, JSON_API_MEDIA_TYPE); Request request = new Request.Builder().url(url).header("Accept", JSON_API_CONTENT_TYPE) - .addHeader("Content-Type", JSON_API_CONTENT_TYPE).patch(body).build(); + .header("Content-Type", JSON_API_CONTENT_TYPE).patch(body).build(); try (Response response = client.newCall(request).execute()) { + String result = response.body().string(); + if (!response.isSuccessful()) { throw new IOException( - "Update failed: " + url + " returned " + response.code() + " " + response.body().string()); + "Update failed: " + url + " returned " + response.code() + " " + result); } - Document result_doc = adapter.fromJson(response.body().string()); + Document result_doc = adapter.fromJson(result); setVersionIfNeeded(result_doc, obj); } } @@ -557,7 +585,7 @@ public T getObject(Class type, String id, String... in HttpUrl url = url_builder.build(); Request request = new Request.Builder().url(url).header("Accept", JSON_API_CONTENT_TYPE) - .addHeader("Content-Type", JSON_API_CONTENT_TYPE).get().build(); + .header("Content-Type", JSON_API_CONTENT_TYPE).get().build(); String body; try (Response response = client.newCall(request).execute()) { @@ -586,10 +614,11 @@ public void deleteObject(Class type, String id) throws String url = get_url(type, id); Request request = new Request.Builder().url(url).delete().build(); + try (Response response = client.newCall(request).execute()) { if (!response.isSuccessful()) { throw new IOException( - "Delete failed: " + url + " returned " + response.code() + " " + response.body().string()); + "Delete failed: " + url + " returned " + response.code()); } } } @@ -620,7 +649,7 @@ public PassClientResult selectObjects(PassClientSelect HttpUrl url = url_builder.build(); Request request = new Request.Builder().url(url).header("Accept", JSON_API_CONTENT_TYPE) - .addHeader("Content-Type", JSON_API_CONTENT_TYPE).get().build(); + .header("Content-Type", JSON_API_CONTENT_TYPE).get().build(); String body; try (Response response = client.newCall(request).execute()) { @@ -700,8 +729,7 @@ public URI uploadBinary(String name, byte[] data) throws IOException { .addEncodedPathSegment("file").build(); RequestBody body = new MultipartBody.Builder().setType(MultipartBody.FORM) - .addFormDataPart("file", name, RequestBody.create(data)) - .build(); + .addFormDataPart("file", name, RequestBody.create(data)).build(); Request request = new Request.Builder().url(url).post(body).build(); @@ -709,8 +737,7 @@ public URI uploadBinary(String name, byte[] data) throws IOException { if (!response.isSuccessful()) { throw new IOException( - "File upload failed: " + url + " returned " + response.code() - + " " + response.body().string()); + "File upload failed: " + url + " returned " + response.code()); } // Grab the id field diff --git a/pass-data-client/src/main/java/org/eclipse/pass/support/client/OkHttpCsrfInterceptor.java b/pass-data-client/src/main/java/org/eclipse/pass/support/client/OkHttpCsrfInterceptor.java new file mode 100644 index 00000000..246aa2bd --- /dev/null +++ b/pass-data-client/src/main/java/org/eclipse/pass/support/client/OkHttpCsrfInterceptor.java @@ -0,0 +1,114 @@ +package org.eclipse.pass.support.client; + +import java.io.IOException; +import java.util.function.Supplier; + +import okhttp3.Call; +import okhttp3.Interceptor; +import okhttp3.Request; +import okhttp3.Response; + +/** + * Add CSRF token as a header to protected requests. The token is provided as a + * cookie. Refresh the token as needed and attempt to handle access by multiple + * threads. + */ +public class OkHttpCsrfInterceptor implements Interceptor { + private volatile String cached_csrf_token; + private Supplier csrf_token_call; + + /** + * @param csrf_token_call Return a new call which will generate a CSRF token + */ + public OkHttpCsrfInterceptor(Supplier csrf_token_call) { + this.csrf_token_call = csrf_token_call; + } + + private String get_csrf_token() throws IOException { + if (cached_csrf_token == null) { + refresh_csrf_token(); + } + + return cached_csrf_token; + } + + private void update_csrf_token(Response response) throws IOException { + String token = parse_csrf_token(response); + + if (token != null) { + cached_csrf_token = token; + } + } + + private String parse_csrf_token(Response response) { + String prefix = "XSRF-TOKEN="; + int start = prefix.length(); + + for (String c : response.headers("Set-Cookie")) { + if (c.startsWith(prefix)) { + int end = c.indexOf(';'); + if (end == -1) { + end = c.length() - 1; + } + + return c.substring(start, end); + } + } + + return null; + } + + // Make a GET request to trigger a CSRF returned as a cookie + private void refresh_csrf_token() throws IOException { + try (Response response = csrf_token_call.get().execute()) { + if (!response.isSuccessful()) { + throw new IOException("Failed to make CSRF token request"); + } + + update_csrf_token(response); + } + } + + private Response make_csrf_request(Chain chain) throws IOException { + // Make sure that the CSRF header and cookie value match + String token = get_csrf_token(); + + Request request = chain.request().newBuilder().header("X-XSRF-TOKEN", token) + .header("Cookie", "XSRF-TOKEN=" + token).build(); + + Response response = chain.proceed(request); + update_csrf_token(response); + + return response; + } + + @Override + public Response intercept(Chain chain) throws IOException { + // GET is not protected + if (chain.request().method().equals("GET")) { + Response response = chain.proceed(chain.request()); + update_csrf_token(response); + return response; + } + + Response response = make_csrf_request(chain); + + // If there is a 403, refresh the token and try again + if (response.code() == 403) { + response.close(); + refresh_csrf_token(); + response = make_csrf_request(chain); + } + + return response; + } + + /** + * Set the cached CSRF token. + * + * @param token new token value + */ + protected void setCsrfToken(String token) { + cached_csrf_token = token; + } +} diff --git a/pass-data-client/src/test/java/org/eclipse/pass/support/client/JsonApiPassClientIT.java b/pass-data-client/src/test/java/org/eclipse/pass/support/client/JsonApiPassClientIT.java index a2f14460..bbb30091 100644 --- a/pass-data-client/src/test/java/org/eclipse/pass/support/client/JsonApiPassClientIT.java +++ b/pass-data-client/src/test/java/org/eclipse/pass/support/client/JsonApiPassClientIT.java @@ -1,5 +1,6 @@ package org.eclipse.pass.support.client; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertIterableEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -7,6 +8,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.FileReader; import java.io.IOException; import java.io.InputStream; import java.net.URI; @@ -17,9 +19,19 @@ import java.util.Collections; import java.util.List; import java.util.Objects; +import java.util.Random; import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +import com.github.dockerjava.api.model.ExposedPort; +import com.github.dockerjava.api.model.PortBinding; +import com.github.dockerjava.api.model.Ports; +import org.apache.maven.model.Model; +import org.apache.maven.model.io.xpp3.MavenXpp3Reader; import org.eclipse.pass.support.client.model.AggregatedDepositStatus; import org.eclipse.pass.support.client.model.AwardStatus; import org.eclipse.pass.support.client.model.CopyStatus; @@ -45,15 +57,49 @@ import org.eclipse.pass.support.client.model.SubmissionStatus; import org.eclipse.pass.support.client.model.User; import org.eclipse.pass.support.client.model.UserRole; -import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; - +import org.springframework.test.annotation.DirtiesContext; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +@DirtiesContext public class JsonApiPassClientIT { - private static PassClient client; + private static final DockerImageName PASS_CORE_IMG; + + static { + MavenXpp3Reader reader = new MavenXpp3Reader(); + try { + Model model = reader.read(new FileReader("pom.xml")); + String version = model.getParent().getVersion(); + PASS_CORE_IMG = DockerImageName.parse("ghcr.io/eclipse-pass/pass-core-main:" + version); + } catch (Exception e) { + throw new RuntimeException(e); + } + + System.setProperty("pass.core.url", "http://localhost:8080"); + System.setProperty("pass.core.user", "backend"); + System.setProperty("pass.core.password", "moo"); + } + + @Container + private static final GenericContainer PASS_CORE_CONTAINER = new GenericContainer<>(PASS_CORE_IMG) + .withCreateContainerCmdModifier(cmd -> { + cmd.getHostConfig().withPortBindings(new PortBinding(Ports.Binding.bindPort(8080), + new ExposedPort(8080))); + }) + .withExposedPorts(8080) + .waitingFor(Wait.forLogMessage(".*Jetty started on port 8080.*", 1)); + + private JsonApiPassClient client; - @BeforeAll - public static void setup() { - client = PassClient.newInstance(); + @BeforeEach + public void setup() { + client = (JsonApiPassClient) PassClient.newInstance(); } @Test @@ -763,4 +809,48 @@ public void testDeleteFile() throws IOException { File actualFile = client.getObject(File.class, file.getId()); assertNull(actualFile); } + + @Test + public void testConcurrency() throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(4); + + List> results = new ArrayList>(); + Random rand = new Random(); + + for (int i = 0; i < 200; i++) { + final int num = i; + + Future result = pool.submit(() -> { + User user = new User(); + user.setFirstName("" + num); + user.setLastName(Thread.currentThread().getName()); + + assertDoesNotThrow(() -> { + client.createObject(user); + + // Simulate an expired token + //if (rand.nextInt(4) == 0) { + // client.setCsrfToken("" + rand.nextInt(100)); + //} + + Thread.sleep(rand.nextLong(100)); + user.setMiddleName("middle"); + client.updateObject(user); + + Thread.sleep(rand.nextLong(100)); + client.deleteObject(user); + }); + }); + + results.add(result); + } + + // Throw assertion failures + for (Future f: results) { + f.get(); + } + + pool.shutdown(); + pool.awaitTermination(5, TimeUnit.MINUTES); + } }