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