Skip to content

Commit

Permalink
Add support for CSRF tokens to pass-data-client
Browse files Browse the repository at this point in the history
  • Loading branch information
markpatton committed May 30, 2024
1 parent 65592b8 commit 14c15f0
Show file tree
Hide file tree
Showing 4 changed files with 281 additions and 75 deletions.
87 changes: 31 additions & 56 deletions pass-data-client/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

<properties>
<!-- Properties for dependency versions -->
<spring-boot-maven-plugin.version>3.2.1</spring-boot-maven-plugin.version>
<okhttp.version>4.12.0</okhttp.version>
<moshi.version>4.12.0</moshi.version>
<jsonapi-adapters.version>1.1.0</jsonapi-adapters.version>
Expand All @@ -19,20 +20,17 @@
<commons-lang3.version>3.14.0</commons-lang3.version>
<junit.jupiter.version>5.10.1</junit.jupiter.version>
<mockito.version>5.10.0</mockito.version>
<!-- Properties for integration tests -->
<pass.core.port>8080</pass.core.port>
<pass.core.url>http://localhost:8080</pass.core.url>
<pass.core.user>backend</pass.core.user>
<pass.core.password>backend</pass.core.password>
<testcontainers.version>1.19.4</testcontainers.version>
<maven-model.version>3.9.6</maven-model.version>
</properties>

<dependencies>
<dependency>
<groupId>com.markomilos.jsonapi</groupId>
<artifactId>jsonapi-adapters</artifactId>
<version>${jsonapi-adapters.version}</version>
</dependency>

<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
Expand Down Expand Up @@ -80,58 +78,30 @@
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>${spring-boot-maven-plugin.version}</version>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.apache.maven</groupId>
<artifactId>maven-model</artifactId>
<version>${maven-model.version}</version>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>io.fabric8</groupId>
<artifactId>docker-maven-plugin</artifactId>
<executions>
<execution>
<id>start</id>
<phase>pre-integration-test</phase>
<goals>
<goal>start</goal>
</goals>
</execution>
<execution>
<id>stop</id>
<phase>post-integration-test</phase>
<goals>
<goal>stop</goal>
</goals>
</execution>
</executions>
<configuration>
<images>
<image>
<name>ghcr.io/eclipse-pass/pass-core-main:%v</name>
<run>
<platform>${docker.platforms}</platform>
<env>
<PASS_CORE_BASE_URL>${pass.core.url}</PASS_CORE_BASE_URL>
<PASS_CORE_BACKEND_USER>${pass.core.user}</PASS_CORE_BACKEND_USER>
<PASS_CORE_BACKEND_PASSWORD>${pass.core.password}</PASS_CORE_BACKEND_PASSWORD>
</env>
<wait>
<http>
<url>
${pass.core.url}/data/grant
</url>
<status>401</status>
</http>
<time>60000</time>
</wait>
<ports>
<port>${pass.core.port}:${pass.core.port}</port>
</ports>
</run>
</image>
</images>
</configuration>
</plugin>

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
Expand All @@ -147,7 +117,7 @@
<systemPropertyVariables>
<pass.core.url>${pass.core.url}</pass.core.url>
<pass.core.user>${pass.core.user}</pass.core.user>
<pass.core.password>${pass.core.password}</pass.core.password>
<pass.core.password>${pass.core.password}</pass.core.password>
</systemPropertyVariables>
</configuration>
</plugin>
Expand Down Expand Up @@ -194,12 +164,17 @@
<!-- These both come from junit-jupiter, so version is tied to that direct dependency -->
<ignoredUsedUndeclaredDependency>org.junit.jupiter:junit-jupiter-api</ignoredUsedUndeclaredDependency>
<ignoredUsedUndeclaredDependency>org.junit.jupiter:junit-jupiter-params</ignoredUsedUndeclaredDependency>
<!-- These come from testcontainers junit-jupiter -->
<ignoredUsedUndeclaredDependency>org.testcontainers::</ignoredUsedUndeclaredDependency>
<ignoredUsedUndeclaredDependency>com.github.docker-java::</ignoredUsedUndeclaredDependency>
<ignoredUsedUndeclaredDependency>org.springframework:spring-test</ignoredUsedUndeclaredDependency>
</ignoredUsedUndeclaredDependencies>
<ignoredUnusedDeclaredDependencies>
<!-- slf4j is the API used in the code, logback is the logging provider not used directly -->
<ignoredUnusedDeclaredDependency>ch.qos.logback:logback-classic</ignoredUnusedDeclaredDependency>
<!-- junit-jupiter is a module containing the junit api jars used directly -->
<ignoredUnusedDeclaredDependency>org.junit.jupiter:junit-jupiter</ignoredUnusedDeclaredDependency>
<ignoredUnusedDeclaredDependency>org.springframework.boot:spring-boot-starter-test</ignoredUnusedDeclaredDependency>
</ignoredUnusedDeclaredDependencies>
</configuration>
</execution>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -97,13 +98,36 @@ 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);

// Serialize null value of attributes for the JSON API document
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,
Expand Down Expand Up @@ -176,14 +200,16 @@ public <T extends PassEntity> 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<T> result_doc = adapter.fromJson(response.body().string());
Document<T> result_doc = adapter.fromJson(result);
obj.setId(result_doc.requireData().getId());
setVersionIfNeeded(result_doc, obj);
}
Expand All @@ -204,14 +230,16 @@ public <T extends PassEntity> 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<T> result_doc = adapter.fromJson(response.body().string());
Document<T> result_doc = adapter.fromJson(result);
setVersionIfNeeded(result_doc, obj);
}
}
Expand Down Expand Up @@ -557,7 +585,7 @@ public <T extends PassEntity> T getObject(Class<T> 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()) {
Expand Down Expand Up @@ -586,10 +614,11 @@ public <T extends PassEntity> void deleteObject(Class<T> 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());
}
}
}
Expand Down Expand Up @@ -620,7 +649,7 @@ public <T extends PassEntity> PassClientResult<T> 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()) {
Expand Down Expand Up @@ -700,17 +729,15 @@ 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();

try (Response response = client.newCall(request).execute()) {

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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Call> csrf_token_call;

/**
* @param csrf_token_call Return a new call which will generate a CSRF token
*/
public OkHttpCsrfInterceptor(Supplier<Call> 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;
}
}
Loading

0 comments on commit 14c15f0

Please sign in to comment.