Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 11 additions & 13 deletions src/main/java/com/webhook/root/message/WebhookMessageSender.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,41 +9,39 @@
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;

import com.webhook.root.model.WebhookMessage;

@Service
public class WebhookMessageSender {

private final RestTemplate restTemplate = new RestTemplate();
private final RestTemplate webhookRestTemplate;

public void sendWebhookMessage(WebhookMessage webhookMessage) {
public WebhookMessageSender(RestTemplate webhookRestTemplate) {
this.webhookRestTemplate = webhookRestTemplate;
}

public HttpStatusCode sendWebhookMessage(WebhookMessage webhookMessage) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);

HttpEntity<Map<String, Object>> payload = new HttpEntity<>(webhookMessage.getPayload(), headers);

try {
String response = restTemplate.postForObject(webhookMessage.getEndpoint().getUrl(), payload, String.class);
System.out.println("Webhook forwarding successful");
System.out.println(response);
} catch (RestClientException e){
System.out.println("Error forwarding webhook: ");
System.out.println(e.getMessage());
}
ResponseEntity<String> response = webhookRestTemplate.postForEntity(webhookMessage.getEndpoint().getUrl(), payload, String.class);

return response.getStatusCode();
}

public HttpStatusCode tryRealSendWithHighFakeFailureRate(WebhookMessage webhookMessage) {
Double r = Math.random();
if (r < 0.1) {
if (r < 0.9) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);

HttpEntity<Map<String, Object>> payload = new HttpEntity<>(webhookMessage.getPayload(), headers);

ResponseEntity<String> response = restTemplate.postForEntity(
ResponseEntity<String> response = webhookRestTemplate.postForEntity(
webhookMessage.getEndpoint().getUrl(),
payload,
String.class);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.webhook.root.message;

import java.util.List;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

import com.webhook.root.security.WebhookSigningInterceptor;

@Configuration
public class WebhookRestTemplateConfig {

@Bean
public RestTemplate webhookRestTemplate(@Value("${webhook.secret}") String secret) {
RestTemplate restTemplate = new RestTemplate();
restTemplate.setInterceptors(
List.of(new WebhookSigningInterceptor(secret))
);
return restTemplate;
}
}
45 changes: 45 additions & 0 deletions src/main/java/com/webhook/root/security/SecurityUtil.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
package com.webhook.root.security;

import java.util.Base64;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;

Expand All @@ -8,6 +13,46 @@

public class SecurityUtil {

private static final String HMAC_ALGORITHM = "HmacSHA256";

/**
* Sign provided payload using provided timestamp, secret, and Hmac algorithm
* @param payload
* @param timestamp
* @param secret
* @return signed string
* @throws Exception
*/
public static String signWebhookPayload(String payload, long timestamp, String secret) throws Exception {
String unsignedString = timestamp + "." + payload;

Mac sha256Hmac = Mac.getInstance(HMAC_ALGORITHM);
SecretKeySpec keySpec = new SecretKeySpec(secret.getBytes(), HMAC_ALGORITHM);
sha256Hmac.init(keySpec);

byte[] hash = sha256Hmac.doFinal(unsignedString.getBytes());
return Base64.getEncoder().encodeToString(hash);
}

/**
* Verify that the webhook signature received matches that expected given the payload, timestamp, and secret
* @param payload
* @param timestamp
* @param secret
* @param signature
* @return true if signature matches expected, false otherwise
* @throws Exception
*/
public static boolean verifyWebhookSignature(String payload, long timestamp, String secret, String signature) throws Exception {
String expected = signWebhookPayload(payload, timestamp, secret);
return expected.equals(signature);
}

/**
* Get details of currently logged in user from SecurityContextHolder
* @param publisherRepository
* @return
*/
public static PublisherAccount getCurrentPublisher(PublisherAccountRepository publisherRepository) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.webhook.root.security;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.lang.NonNull;

public class WebhookSigningInterceptor implements ClientHttpRequestInterceptor {

private final String secret;

public WebhookSigningInterceptor(String secret) {
this.secret = secret;
}

@Override
@NonNull
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution)
throws IOException {

try {
String jsonPayload = new String(body, StandardCharsets.UTF_8);

long timestamp = System.currentTimeMillis() / 1000L;
String signature = SecurityUtil.signWebhookPayload(jsonPayload, timestamp, secret);

request.getHeaders().set("X-Signature", signature);
request.getHeaders().set("X-Timestamp", String.valueOf(timestamp));
} catch (Exception e) {
throw new RuntimeException("Failed to sign webhook request", e);
}

return execution.execute(request, body);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,10 @@
"name": "jwt.expiration",
"type": "java.lang.String",
"description": "A description for 'jwt.expiration'"
},
{
"name": "webhook.secret",
"type": "java.lang.String",
"description": "A description for 'webhook.secret'"
}
]}
5 changes: 4 additions & 1 deletion src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,7 @@ spring.kafka.consumer.group-id=my-group

# JWT config - probably hide this somewhere lol
jwt.secret=thisIsMysecregtfrdesww233eggtffeeddgkjjhhtdhttebd54ndhdhfhhhshs8877465sbbdd
jwt.expiration=3600000
jwt.expiration=3600000

# webhook signing config
webhook.secret=shh-secret-key
90 changes: 90 additions & 0 deletions src/test/java/com/webhook/root/message/WebhookSigningTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package com.webhook.root.message;

import java.util.Map;

import org.hamcrest.Matchers;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.client.RestClientTest;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestTemplate;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.webhook.root.model.Endpoint;
import com.webhook.root.model.WebhookMessage;
import com.webhook.root.security.SecurityUtil;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.*;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;

@RestClientTest(WebhookMessageSender.class)
public class WebhookSigningTest {

@Autowired
private RestTemplate webhookRestTemplate;

@Autowired
private WebhookMessageSender sender;

@Autowired
private MockRestServiceServer server;

private final ObjectMapper objectMapper = new ObjectMapper();
private final String secret = "shh-secret-key";
private final String url = "https://webhook.site/6e8e440a-df1d-4a08-8c5d-47746da2554d";

@BeforeEach
void setup() {
server.reset();
}

@Test
void testWebhookSigningHeadersPresentAndValid() throws Exception {
Map<String, Object> payload = Map.of("foo", "bar");

server.expect(requestTo(url))
.andExpect(method(HttpMethod.POST))
.andExpect(header("X-Signature", Matchers.notNullValue()))
.andExpect(header("X-Timestamp", Matchers.notNullValue()))
.andRespond(withSuccess("ok", MediaType.APPLICATION_JSON));

WebhookMessage msg = new WebhookMessage();
msg.setPayload(payload);
msg.setEndpoint(new Endpoint(url, "shh-secret-key", true));

sender.sendWebhookMessage(msg);
server.verify();
}

@Test
void testWebhookSignatureActuallyValid() throws Exception {
Map<String, Object> payload = Map.of("foo", "bar");
String body = objectMapper.writeValueAsString(payload);

server.expect(requestTo(url))
.andExpect(method(HttpMethod.POST))
.andExpect(request -> {
String signature = request.getHeaders().getFirst("X-Signature");
String timestamp = request.getHeaders().getFirst("X-Timestamp");

assertThat(signature).isNotNull();
assertThat(timestamp).isNotNull();

// recalculate expected signature
String expected;
try {
expected = SecurityUtil.signWebhookPayload(body, Long.parseLong(timestamp), secret);
assertThat(signature).isEqualTo(expected);
} catch (Exception e) {

e.printStackTrace();
}

})
.andRespond(withSuccess("ok", MediaType.APPLICATION_JSON));
}
}