diff --git a/src/main/java/com/webhook/root/message/WebhookMessageSender.java b/src/main/java/com/webhook/root/message/WebhookMessageSender.java index 00d07da..9ef92d6 100644 --- a/src/main/java/com/webhook/root/message/WebhookMessageSender.java +++ b/src/main/java/com/webhook/root/message/WebhookMessageSender.java @@ -9,7 +9,6 @@ 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; @@ -17,33 +16,32 @@ @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> 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 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> payload = new HttpEntity<>(webhookMessage.getPayload(), headers); - ResponseEntity response = restTemplate.postForEntity( + ResponseEntity response = webhookRestTemplate.postForEntity( webhookMessage.getEndpoint().getUrl(), payload, String.class); diff --git a/src/main/java/com/webhook/root/message/WebhookRestTemplateConfig.java b/src/main/java/com/webhook/root/message/WebhookRestTemplateConfig.java new file mode 100644 index 0000000..3b13ae8 --- /dev/null +++ b/src/main/java/com/webhook/root/message/WebhookRestTemplateConfig.java @@ -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; + } +} diff --git a/src/main/java/com/webhook/root/security/SecurityUtil.java b/src/main/java/com/webhook/root/security/SecurityUtil.java index f2a37d7..fae757c 100644 --- a/src/main/java/com/webhook/root/security/SecurityUtil.java +++ b/src/main/java/com/webhook/root/security/SecurityUtil.java @@ -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; @@ -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(); diff --git a/src/main/java/com/webhook/root/security/WebhookSigningInterceptor.java b/src/main/java/com/webhook/root/security/WebhookSigningInterceptor.java new file mode 100644 index 0000000..4d3c0a0 --- /dev/null +++ b/src/main/java/com/webhook/root/security/WebhookSigningInterceptor.java @@ -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); + } + +} diff --git a/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/src/main/resources/META-INF/additional-spring-configuration-metadata.json index cbad661..0141602 100644 --- a/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -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'" } ]} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 793d835..e69a8a5 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -21,4 +21,7 @@ spring.kafka.consumer.group-id=my-group # JWT config - probably hide this somewhere lol jwt.secret=thisIsMysecregtfrdesww233eggtffeeddgkjjhhtdhttebd54ndhdhfhhhshs8877465sbbdd -jwt.expiration=3600000 \ No newline at end of file +jwt.expiration=3600000 + +# webhook signing config +webhook.secret=shh-secret-key \ No newline at end of file diff --git a/src/test/java/com/webhook/root/message/WebhookSigningTest.java b/src/test/java/com/webhook/root/message/WebhookSigningTest.java new file mode 100644 index 0000000..1819892 --- /dev/null +++ b/src/test/java/com/webhook/root/message/WebhookSigningTest.java @@ -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 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 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)); + } +}