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);
+ }
}