Skip to content

Commit e1b6edc

Browse files
authored
Add Cacheable URLs for FeatureModFiles (#638)
1 parent 8bfb582 commit e1b6edc

6 files changed

Lines changed: 159 additions & 35 deletions

File tree

src/inttest/java/com/faforever/api/featuredmods/FeaturedModsControllerTest.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
import org.junit.jupiter.api.Test;
66
import org.springframework.test.context.jdbc.Sql;
77

8+
import static org.hamcrest.Matchers.hasKey;
89
import static org.hamcrest.Matchers.hasSize;
910
import static org.hamcrest.Matchers.is;
1011
import static org.hamcrest.Matchers.matchesRegex;
12+
import static org.hamcrest.Matchers.not;
1113
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
1214
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
1315
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -24,7 +26,10 @@ public void featuredModFileUrlCorrectWithLobbyScope() throws Exception {
2426
.andExpect(status().isOk())
2527
.andExpect(jsonPath("$.data", hasSize(1)))
2628
.andExpect(jsonPath("$.data[0].type", is("featuredModFile")))
27-
.andExpect(jsonPath("$.data[0].attributes.url", matchesRegex(".*\\?verify=[0-9]+-.*")));
29+
.andExpect(jsonPath("$.data[0].attributes", hasKey("hmacParameter")))
30+
.andExpect(jsonPath("$.data[0].attributes.url", matchesRegex(".*\\?verify=[0-9]+-.*")))
31+
.andExpect(jsonPath("$.data[0].attributes.cacheableUrl", not(matchesRegex(".*\\?.*"))))
32+
.andExpect(jsonPath("$.data[0].attributes.hmacToken", matchesRegex("[0-9]+-.*")));
2833
}
2934

3035
@Test
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.faforever.api.cloudflare;
2+
3+
import com.faforever.api.config.FafApiProperties;
4+
import org.springframework.stereotype.Service;
5+
6+
import javax.crypto.Mac;
7+
import javax.crypto.spec.SecretKeySpec;
8+
import java.net.URI;
9+
import java.net.URLEncoder;
10+
import java.nio.charset.StandardCharsets;
11+
import java.security.InvalidKeyException;
12+
import java.security.NoSuchAlgorithmException;
13+
import java.time.Instant;
14+
import java.util.Base64;
15+
16+
@Service
17+
public class CloudflareService {
18+
19+
private static final String HMAC_SHA256 = "HmacSHA256";
20+
21+
private final Mac mac = Mac.getInstance(HMAC_SHA256);
22+
23+
public CloudflareService(FafApiProperties fafApiProperties) throws NoSuchAlgorithmException, InvalidKeyException {
24+
String secret = fafApiProperties.getCloudflare().getHmacSecret();
25+
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_SHA256));
26+
}
27+
28+
/**
29+
* Builds hmac token for cloudflare firewall verification as specified
30+
* <a href="https://support.cloudflare.com/hc/en-us/articles/115001376488-Configuring-Token-Authentication">here</a>
31+
* @param uri uri to generate the hmac token for
32+
* @return string representing the hmac token formatted as {timestamp}-{hashedContent}
33+
*/
34+
public String generateCloudFlareHmacToken(String uri) {
35+
return generateCloudFlareHmacToken(URI.create(uri));
36+
}
37+
38+
/**
39+
* Builds hmac token for cloudflare firewall verification as specified
40+
* <a href="https://support.cloudflare.com/hc/en-us/articles/115001376488-Configuring-Token-Authentication">here</a>
41+
* @param uri uri to generate the hmac token for
42+
* @return string representing the hmac token formatted as {timestamp}-{hashedContent}
43+
*/
44+
public String generateCloudFlareHmacToken(URI uri) {
45+
long timeStamp = Instant.now().getEpochSecond();
46+
47+
byte[] macMessage = (uri.getPath() + timeStamp).getBytes(StandardCharsets.UTF_8);
48+
49+
String hmacEncoded = URLEncoder.encode(new String(Base64.getEncoder().encode(mac.doFinal(macMessage)), StandardCharsets.UTF_8), StandardCharsets.UTF_8);
50+
return "%d-%s".formatted(timeStamp, hmacEncoded);
51+
}
52+
}

src/main/java/com/faforever/api/featuredmods/FeaturedModFile.java

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ public class FeaturedModFile {
2626
private int version;
2727
// Enriched in FeaturedModFileEnricher
2828
private String url;
29+
private String cacheableUrl;
30+
private String hmacToken;
31+
private String hmacParameter;
2932
private String folderName;
3033
private int fileId;
3134

@@ -73,12 +76,6 @@ public int getFileId() {
7376
return fileId;
7477
}
7578

76-
@Transient
77-
// Enriched by FeaturedModFileEnricher
78-
public String getUrl() {
79-
return url;
80-
}
81-
8279
/**
8380
* Returns the name of the folder on the server in which the file resides (e.g. {@code updates_faf_files}). Used by
8481
* the {@link FeaturedModFileEnricher}.
@@ -87,4 +84,38 @@ public String getUrl() {
8784
public String getFolderName() {
8885
return folderName;
8986
}
87+
88+
// Enriched by FeaturedModFileEnricher
89+
90+
/**
91+
* URL with hmac token as query parameter for backwards compatibility with clients, cannot be cached by cloudflare
92+
*/
93+
@Transient
94+
public String getUrl() {
95+
return url;
96+
}
97+
98+
/**
99+
* URL without any query parameters so it can be cached by cloudflare
100+
*/
101+
@Transient
102+
public String getCacheableUrl() {
103+
return cacheableUrl;
104+
}
105+
106+
/**
107+
* Token to be set as header parameter for cloudflare validation
108+
*/
109+
@Transient
110+
public String getHmacToken() {
111+
return hmacToken;
112+
}
113+
114+
/**
115+
* Parameter to set the token as
116+
*/
117+
@Transient
118+
public String getHmacParameter() {
119+
return hmacParameter;
120+
}
90121
}
Lines changed: 12 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,39 @@
11
package com.faforever.api.featuredmods;
22

3+
import com.faforever.api.cloudflare.CloudflareService;
34
import com.faforever.api.config.FafApiProperties;
45
import org.springframework.stereotype.Component;
56
import org.springframework.web.util.UriComponentsBuilder;
67

7-
import javax.crypto.Mac;
8-
import javax.crypto.spec.SecretKeySpec;
98
import javax.inject.Inject;
109
import javax.persistence.PostLoad;
11-
import java.net.URI;
12-
import java.net.URLEncoder;
13-
import java.nio.charset.StandardCharsets;
14-
import java.security.InvalidKeyException;
15-
import java.security.NoSuchAlgorithmException;
16-
import java.time.Instant;
17-
import java.util.Base64;
1810

1911
@Component
2012
public class FeaturedModFileEnricher {
2113

2214
private static final String HMAC_SHA256 = "HmacSHA256";
2315

2416
private static FafApiProperties fafApiProperties;
17+
private static CloudflareService cloudflareService;
2518

2619
@Inject
27-
public void init(FafApiProperties fafApiProperties) {
20+
public void init(FafApiProperties fafApiProperties, CloudflareService cloudflareService) {
2821
FeaturedModFileEnricher.fafApiProperties = fafApiProperties;
22+
FeaturedModFileEnricher.cloudflareService = cloudflareService;
2923
}
3024

3125
@PostLoad
32-
public void enhance(FeaturedModFile featuredModFile) throws NoSuchAlgorithmException, InvalidKeyException {
26+
public void enhance(FeaturedModFile featuredModFile) {
3327
String folder = featuredModFile.getFolderName();
3428
String urlFormat = fafApiProperties.getFeaturedMod().getFileUrlFormat();
35-
String secret = fafApiProperties.getCloudflare().getHmacSecret();
36-
long timeStamp = Instant.now().getEpochSecond();
37-
URI featuredModUri = URI.create(urlFormat.formatted(folder, featuredModFile.getOriginalFileName()));
29+
String urlString = urlFormat.formatted(folder, featuredModFile.getOriginalFileName());
3830

39-
// Builds hmac token for cloudflare firewall verification as specified at
40-
// https://support.cloudflare.com/hc/en-us/articles/115001376488-Configuring-Token-Authentication
41-
Mac mac = Mac.getInstance(HMAC_SHA256);
42-
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_SHA256));
43-
byte[] macMessage = (featuredModUri.getPath() + timeStamp).getBytes(StandardCharsets.UTF_8);
31+
String hmacToken = cloudflareService.generateCloudFlareHmacToken(urlString);
32+
String hmacParam = fafApiProperties.getCloudflare().getHmacParam();
4433

45-
String hmacEncoded = URLEncoder.encode(new String(Base64.getEncoder().encode(mac.doFinal(macMessage)), StandardCharsets.UTF_8), StandardCharsets.UTF_8);
46-
String parameterValue = "%d-%s".formatted(timeStamp, hmacEncoded);
47-
48-
String queryParam = fafApiProperties.getCloudflare().getHmacParam();
49-
featuredModFile.setUrl(UriComponentsBuilder.fromUri(featuredModUri).queryParam(queryParam, parameterValue).build().toString());
34+
featuredModFile.setUrl(UriComponentsBuilder.fromUriString(urlString).queryParam(hmacParam, hmacToken).build().toString());
35+
featuredModFile.setCacheableUrl(urlString);
36+
featuredModFile.setHmacToken(hmacToken);
37+
featuredModFile.setHmacParameter(hmacParam);
5038
}
5139
}

src/main/java/com/faforever/api/featuredmods/FeaturedModsController.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import com.yahoo.elide.jsonapi.models.JsonApiDocument;
99
import com.yahoo.elide.jsonapi.models.Resource;
1010
import io.swagger.annotations.ApiOperation;
11+
import lombok.RequiredArgsConstructor;
1112
import org.springframework.security.access.prepost.PreAuthorize;
1213
import org.springframework.web.bind.annotation.PathVariable;
1314
import org.springframework.web.bind.annotation.RequestMapping;
@@ -23,14 +24,11 @@
2324

2425
@RestController
2526
@RequestMapping(path = "/featuredMods")
27+
@RequiredArgsConstructor
2628
public class FeaturedModsController {
2729

2830
private final FeaturedModService featuredModService;
2931

30-
public FeaturedModsController(FeaturedModService featuredModService) {
31-
this.featuredModService = featuredModService;
32-
}
33-
3432
@RequestMapping(path = "/{modId}/files/{version}")
3533
@ApiOperation("Lists the required files for a specific featured mod version")
3634
@PreAuthorize("hasScope('" + OAuthScope._LOBBY + "')")
@@ -61,6 +59,9 @@ private Function<FeaturedModFile, Resource> modFileMapper() {
6159
"md5", file.getMd5(),
6260
"name", file.getName(),
6361
"url", file.getUrl(),
62+
"cacheableUrl", file.getCacheableUrl(),
63+
"hmacToken", file.getHmacToken(),
64+
"hmacParameter", file.getHmacParameter(),
6465
"version", file.getVersion()
6566
), null, null, null);
6667
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package com.faforever.api.cloudflare;
2+
3+
import com.faforever.api.config.FafApiProperties;
4+
import org.junit.jupiter.api.BeforeEach;
5+
import org.junit.jupiter.api.Test;
6+
import org.junit.jupiter.api.extension.ExtendWith;
7+
import org.mockito.junit.jupiter.MockitoExtension;
8+
9+
import javax.crypto.Mac;
10+
import javax.crypto.spec.SecretKeySpec;
11+
import java.net.URLEncoder;
12+
import java.nio.charset.StandardCharsets;
13+
import java.util.Base64;
14+
15+
import static org.junit.jupiter.api.Assertions.assertEquals;
16+
17+
@ExtendWith(MockitoExtension.class)
18+
public class CloudflareServiceTest {
19+
20+
private static final String HMAC_SHA256 = "HmacSHA256";
21+
22+
private FafApiProperties fafApiProperties = new FafApiProperties();
23+
private CloudflareService instance;
24+
25+
@BeforeEach
26+
public void setup() throws Exception {
27+
String secret = "foo";
28+
fafApiProperties.getCloudflare().setHmacSecret(secret);
29+
30+
instance = new CloudflareService(fafApiProperties);
31+
}
32+
33+
@Test
34+
public void hmacTokenGeneration() throws Exception {
35+
String token = instance.generateCloudFlareHmacToken("http://example.com/bar");
36+
37+
String[] tokenParts = token.split("-");
38+
String timeStamp = tokenParts[0];
39+
40+
Mac mac = Mac.getInstance(HMAC_SHA256);
41+
mac.init(new SecretKeySpec(fafApiProperties.getCloudflare().getHmacSecret().getBytes(StandardCharsets.UTF_8), HMAC_SHA256));
42+
byte[] macMessage = ("/bar" + timeStamp).getBytes(StandardCharsets.UTF_8);
43+
44+
String hmacEncoded = URLEncoder.encode(new String(Base64.getEncoder().encode(mac.doFinal(macMessage)), StandardCharsets.UTF_8), StandardCharsets.UTF_8);
45+
assertEquals(hmacEncoded, tokenParts[1]);
46+
}
47+
}

0 commit comments

Comments
 (0)