From e1b6edc378b8b621bf5634dc8e1c4dc8a2d3aa44 Mon Sep 17 00:00:00 2001
From: Sheikah45 <66929319+Sheikah45@users.noreply.github.com>
Date: Sat, 13 Aug 2022 14:08:01 -0400
Subject: [PATCH] Add Cacheable URLs for FeatureModFiles (#638)
---
.../FeaturedModsControllerTest.java | 7 ++-
.../api/cloudflare/CloudflareService.java | 52 +++++++++++++++++++
.../api/featuredmods/FeaturedModFile.java | 43 ++++++++++++---
.../featuredmods/FeaturedModFileEnricher.java | 36 +++++--------
.../featuredmods/FeaturedModsController.java | 9 ++--
.../api/cloudflare/CloudflareServiceTest.java | 47 +++++++++++++++++
6 files changed, 159 insertions(+), 35 deletions(-)
create mode 100644 src/main/java/com/faforever/api/cloudflare/CloudflareService.java
create mode 100644 src/test/java/com/faforever/api/cloudflare/CloudflareServiceTest.java
diff --git a/src/inttest/java/com/faforever/api/featuredmods/FeaturedModsControllerTest.java b/src/inttest/java/com/faforever/api/featuredmods/FeaturedModsControllerTest.java
index 03788d4ed..dcaedf9ce 100644
--- a/src/inttest/java/com/faforever/api/featuredmods/FeaturedModsControllerTest.java
+++ b/src/inttest/java/com/faforever/api/featuredmods/FeaturedModsControllerTest.java
@@ -5,9 +5,11 @@
import org.junit.jupiter.api.Test;
import org.springframework.test.context.jdbc.Sql;
+import static org.hamcrest.Matchers.hasKey;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.matchesRegex;
+import static org.hamcrest.Matchers.not;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -24,7 +26,10 @@ public void featuredModFileUrlCorrectWithLobbyScope() throws Exception {
.andExpect(status().isOk())
.andExpect(jsonPath("$.data", hasSize(1)))
.andExpect(jsonPath("$.data[0].type", is("featuredModFile")))
- .andExpect(jsonPath("$.data[0].attributes.url", matchesRegex(".*\\?verify=[0-9]+-.*")));
+ .andExpect(jsonPath("$.data[0].attributes", hasKey("hmacParameter")))
+ .andExpect(jsonPath("$.data[0].attributes.url", matchesRegex(".*\\?verify=[0-9]+-.*")))
+ .andExpect(jsonPath("$.data[0].attributes.cacheableUrl", not(matchesRegex(".*\\?.*"))))
+ .andExpect(jsonPath("$.data[0].attributes.hmacToken", matchesRegex("[0-9]+-.*")));
}
@Test
diff --git a/src/main/java/com/faforever/api/cloudflare/CloudflareService.java b/src/main/java/com/faforever/api/cloudflare/CloudflareService.java
new file mode 100644
index 000000000..c70766742
--- /dev/null
+++ b/src/main/java/com/faforever/api/cloudflare/CloudflareService.java
@@ -0,0 +1,52 @@
+package com.faforever.api.cloudflare;
+
+import com.faforever.api.config.FafApiProperties;
+import org.springframework.stereotype.Service;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.time.Instant;
+import java.util.Base64;
+
+@Service
+public class CloudflareService {
+
+ private static final String HMAC_SHA256 = "HmacSHA256";
+
+ private final Mac mac = Mac.getInstance(HMAC_SHA256);
+
+ public CloudflareService(FafApiProperties fafApiProperties) throws NoSuchAlgorithmException, InvalidKeyException {
+ String secret = fafApiProperties.getCloudflare().getHmacSecret();
+ mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_SHA256));
+ }
+
+ /**
+ * Builds hmac token for cloudflare firewall verification as specified
+ * here
+ * @param uri uri to generate the hmac token for
+ * @return string representing the hmac token formatted as {timestamp}-{hashedContent}
+ */
+ public String generateCloudFlareHmacToken(String uri) {
+ return generateCloudFlareHmacToken(URI.create(uri));
+ }
+
+ /**
+ * Builds hmac token for cloudflare firewall verification as specified
+ * here
+ * @param uri uri to generate the hmac token for
+ * @return string representing the hmac token formatted as {timestamp}-{hashedContent}
+ */
+ public String generateCloudFlareHmacToken(URI uri) {
+ long timeStamp = Instant.now().getEpochSecond();
+
+ byte[] macMessage = (uri.getPath() + timeStamp).getBytes(StandardCharsets.UTF_8);
+
+ String hmacEncoded = URLEncoder.encode(new String(Base64.getEncoder().encode(mac.doFinal(macMessage)), StandardCharsets.UTF_8), StandardCharsets.UTF_8);
+ return "%d-%s".formatted(timeStamp, hmacEncoded);
+ }
+}
diff --git a/src/main/java/com/faforever/api/featuredmods/FeaturedModFile.java b/src/main/java/com/faforever/api/featuredmods/FeaturedModFile.java
index ef8feb3e0..dfc36e2e7 100644
--- a/src/main/java/com/faforever/api/featuredmods/FeaturedModFile.java
+++ b/src/main/java/com/faforever/api/featuredmods/FeaturedModFile.java
@@ -26,6 +26,9 @@ public class FeaturedModFile {
private int version;
// Enriched in FeaturedModFileEnricher
private String url;
+ private String cacheableUrl;
+ private String hmacToken;
+ private String hmacParameter;
private String folderName;
private int fileId;
@@ -73,12 +76,6 @@ public int getFileId() {
return fileId;
}
- @Transient
- // Enriched by FeaturedModFileEnricher
- public String getUrl() {
- return url;
- }
-
/**
* Returns the name of the folder on the server in which the file resides (e.g. {@code updates_faf_files}). Used by
* the {@link FeaturedModFileEnricher}.
@@ -87,4 +84,38 @@ public String getUrl() {
public String getFolderName() {
return folderName;
}
+
+ // Enriched by FeaturedModFileEnricher
+
+ /**
+ * URL with hmac token as query parameter for backwards compatibility with clients, cannot be cached by cloudflare
+ */
+ @Transient
+ public String getUrl() {
+ return url;
+ }
+
+ /**
+ * URL without any query parameters so it can be cached by cloudflare
+ */
+ @Transient
+ public String getCacheableUrl() {
+ return cacheableUrl;
+ }
+
+ /**
+ * Token to be set as header parameter for cloudflare validation
+ */
+ @Transient
+ public String getHmacToken() {
+ return hmacToken;
+ }
+
+ /**
+ * Parameter to set the token as
+ */
+ @Transient
+ public String getHmacParameter() {
+ return hmacParameter;
+ }
}
diff --git a/src/main/java/com/faforever/api/featuredmods/FeaturedModFileEnricher.java b/src/main/java/com/faforever/api/featuredmods/FeaturedModFileEnricher.java
index fd8ec3ef0..4b1e6ab77 100644
--- a/src/main/java/com/faforever/api/featuredmods/FeaturedModFileEnricher.java
+++ b/src/main/java/com/faforever/api/featuredmods/FeaturedModFileEnricher.java
@@ -1,20 +1,12 @@
package com.faforever.api.featuredmods;
+import com.faforever.api.cloudflare.CloudflareService;
import com.faforever.api.config.FafApiProperties;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;
-import javax.crypto.Mac;
-import javax.crypto.spec.SecretKeySpec;
import javax.inject.Inject;
import javax.persistence.PostLoad;
-import java.net.URI;
-import java.net.URLEncoder;
-import java.nio.charset.StandardCharsets;
-import java.security.InvalidKeyException;
-import java.security.NoSuchAlgorithmException;
-import java.time.Instant;
-import java.util.Base64;
@Component
public class FeaturedModFileEnricher {
@@ -22,30 +14,26 @@ public class FeaturedModFileEnricher {
private static final String HMAC_SHA256 = "HmacSHA256";
private static FafApiProperties fafApiProperties;
+ private static CloudflareService cloudflareService;
@Inject
- public void init(FafApiProperties fafApiProperties) {
+ public void init(FafApiProperties fafApiProperties, CloudflareService cloudflareService) {
FeaturedModFileEnricher.fafApiProperties = fafApiProperties;
+ FeaturedModFileEnricher.cloudflareService = cloudflareService;
}
@PostLoad
- public void enhance(FeaturedModFile featuredModFile) throws NoSuchAlgorithmException, InvalidKeyException {
+ public void enhance(FeaturedModFile featuredModFile) {
String folder = featuredModFile.getFolderName();
String urlFormat = fafApiProperties.getFeaturedMod().getFileUrlFormat();
- String secret = fafApiProperties.getCloudflare().getHmacSecret();
- long timeStamp = Instant.now().getEpochSecond();
- URI featuredModUri = URI.create(urlFormat.formatted(folder, featuredModFile.getOriginalFileName()));
+ String urlString = urlFormat.formatted(folder, featuredModFile.getOriginalFileName());
- // Builds hmac token for cloudflare firewall verification as specified at
- // https://support.cloudflare.com/hc/en-us/articles/115001376488-Configuring-Token-Authentication
- Mac mac = Mac.getInstance(HMAC_SHA256);
- mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_SHA256));
- byte[] macMessage = (featuredModUri.getPath() + timeStamp).getBytes(StandardCharsets.UTF_8);
+ String hmacToken = cloudflareService.generateCloudFlareHmacToken(urlString);
+ String hmacParam = fafApiProperties.getCloudflare().getHmacParam();
- String hmacEncoded = URLEncoder.encode(new String(Base64.getEncoder().encode(mac.doFinal(macMessage)), StandardCharsets.UTF_8), StandardCharsets.UTF_8);
- String parameterValue = "%d-%s".formatted(timeStamp, hmacEncoded);
-
- String queryParam = fafApiProperties.getCloudflare().getHmacParam();
- featuredModFile.setUrl(UriComponentsBuilder.fromUri(featuredModUri).queryParam(queryParam, parameterValue).build().toString());
+ featuredModFile.setUrl(UriComponentsBuilder.fromUriString(urlString).queryParam(hmacParam, hmacToken).build().toString());
+ featuredModFile.setCacheableUrl(urlString);
+ featuredModFile.setHmacToken(hmacToken);
+ featuredModFile.setHmacParameter(hmacParam);
}
}
diff --git a/src/main/java/com/faforever/api/featuredmods/FeaturedModsController.java b/src/main/java/com/faforever/api/featuredmods/FeaturedModsController.java
index 90deebcc5..9504864de 100644
--- a/src/main/java/com/faforever/api/featuredmods/FeaturedModsController.java
+++ b/src/main/java/com/faforever/api/featuredmods/FeaturedModsController.java
@@ -8,6 +8,7 @@
import com.yahoo.elide.jsonapi.models.JsonApiDocument;
import com.yahoo.elide.jsonapi.models.Resource;
import io.swagger.annotations.ApiOperation;
+import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -23,14 +24,11 @@
@RestController
@RequestMapping(path = "/featuredMods")
+@RequiredArgsConstructor
public class FeaturedModsController {
private final FeaturedModService featuredModService;
- public FeaturedModsController(FeaturedModService featuredModService) {
- this.featuredModService = featuredModService;
- }
-
@RequestMapping(path = "/{modId}/files/{version}")
@ApiOperation("Lists the required files for a specific featured mod version")
@PreAuthorize("hasScope('" + OAuthScope._LOBBY + "')")
@@ -61,6 +59,9 @@ private Function modFileMapper() {
"md5", file.getMd5(),
"name", file.getName(),
"url", file.getUrl(),
+ "cacheableUrl", file.getCacheableUrl(),
+ "hmacToken", file.getHmacToken(),
+ "hmacParameter", file.getHmacParameter(),
"version", file.getVersion()
), null, null, null);
}
diff --git a/src/test/java/com/faforever/api/cloudflare/CloudflareServiceTest.java b/src/test/java/com/faforever/api/cloudflare/CloudflareServiceTest.java
new file mode 100644
index 000000000..0df4a926f
--- /dev/null
+++ b/src/test/java/com/faforever/api/cloudflare/CloudflareServiceTest.java
@@ -0,0 +1,47 @@
+package com.faforever.api.cloudflare;
+
+import com.faforever.api.config.FafApiProperties;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+@ExtendWith(MockitoExtension.class)
+public class CloudflareServiceTest {
+
+ private static final String HMAC_SHA256 = "HmacSHA256";
+
+ private FafApiProperties fafApiProperties = new FafApiProperties();
+ private CloudflareService instance;
+
+ @BeforeEach
+ public void setup() throws Exception {
+ String secret = "foo";
+ fafApiProperties.getCloudflare().setHmacSecret(secret);
+
+ instance = new CloudflareService(fafApiProperties);
+ }
+
+ @Test
+ public void hmacTokenGeneration() throws Exception {
+ String token = instance.generateCloudFlareHmacToken("http://example.com/bar");
+
+ String[] tokenParts = token.split("-");
+ String timeStamp = tokenParts[0];
+
+ Mac mac = Mac.getInstance(HMAC_SHA256);
+ mac.init(new SecretKeySpec(fafApiProperties.getCloudflare().getHmacSecret().getBytes(StandardCharsets.UTF_8), HMAC_SHA256));
+ byte[] macMessage = ("/bar" + timeStamp).getBytes(StandardCharsets.UTF_8);
+
+ String hmacEncoded = URLEncoder.encode(new String(Base64.getEncoder().encode(mac.doFinal(macMessage)), StandardCharsets.UTF_8), StandardCharsets.UTF_8);
+ assertEquals(hmacEncoded, tokenParts[1]);
+ }
+}