Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SP-1023 Java / Spring Boot Kiosk Demo: Add HMAC verification #25

Merged
merged 1 commit into from
Feb 4, 2025
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
package com.bitpay.demo;

import java.util.Map;
import java.util.Objects;
import javax.annotation.Nullable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
Expand Down Expand Up @@ -46,11 +48,16 @@ protected ResultActions post(

protected ResultActions post(
final String url,
final String requestBody
final String requestBody,
@Nullable final Map<String, String> headers
) throws Exception {
final var post = MockMvcRequestBuilders.post(url).content(requestBody)
.contentType(MediaType.APPLICATION_JSON);

if (!Objects.isNull(headers)) {
headers.forEach((key, value) -> post.header(key, value));
}

return getResultActions(post);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

import com.bitpay.demo.AbstractUiIntegrationTest;
import com.bitpay.demo.invoice.domain.Invoice;
import java.util.Map;
import javax.annotation.Nullable;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
Expand All @@ -28,7 +30,11 @@ public void shouldUpdateInvoice() throws Exception {
final var invoice = createInvoice();

// when
final var result = getResultActions(invoice.getUuid().value(), getDataFromFile("updateData.json"));
final var result = getResultActions(
invoice.getUuid().value(),
getDataFromFile("updateData.json"),
Map.of("x-signature", "dIFy1UloHI6k5rpdVdl3cADtPF01g/xHKt6rqKuo5Ls=")
);

result.andExpect(MockMvcResultMatchers.status().isOk());
Assertions.assertEquals(
Expand All @@ -43,7 +49,11 @@ public void shouldNotUpdateInvoiceWhenInvoiceForUuidDoesNotExists() throws Excep
final var invoice = createInvoice();

// when
final var result = getResultActions("12312412", getDataFromFile("updateData.json"));
final var result = getResultActions(
"12312412",
getDataFromFile("updateData.json"),
Map.of("x-signature", "dIFy1UloHI6k5rpdVdl3cADtPF01g/xHKt6rqKuo5Ls=")
);

result.andExpect(MockMvcResultMatchers.status().isNotFound());
Assertions.assertEquals(
Expand All @@ -60,7 +70,8 @@ public void shouldNotUpdateInvoiceWhenUpdateDataAreInvalid() throws Exception {
// when
final var result = getResultActions(
invoice.getUuid().value(),
getDataFromFile("invalidUpdateData.json")
getDataFromFile("invalidUpdateData.json"),
Map.of("x-signature", "33SW42rFbxKuGj7s4166nOrWuHHHM1EMxgHmgT5tksU=")
);

// then
Expand All @@ -72,11 +83,31 @@ public void shouldNotUpdateInvoiceWhenUpdateDataAreInvalid() throws Exception {
);
}

@Test
public void shouldNotUpdateInvoiceWhenWebhookSignatureVerificationFailed() throws Exception {
// given
final var invoice = createInvoice();

// when
final var result = getResultActions(
"12312412",
getDataFromFile("updateData.json"),
Map.of("x-signature", "randomsignature")
);

result.andExpect(MockMvcResultMatchers.status().isOk());
Assertions.assertEquals(
"new",
this.invoiceRepository.findById(invoice.getInvoiceId()).getStatus().value()
);
}

private ResultActions getResultActions(
final String invoiceUuId,
final String requestBody
final String requestBody,
@Nullable final Map<String, String> headers
) throws Exception {
return post(URL + invoiceUuId, requestBody);
return post(URL + invoiceUuId, requestBody, headers);
}

private Invoice createInvoice() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright 2023 BitPay.
* All rights reserved.
*/

package com.bitpay.demo.invoice.infrastructure.features.tasks.updateinvoice;

import static java.nio.charset.StandardCharsets.UTF_8;

import com.bitpay.demo.DependencyInjection;
import java.io.UnsupportedEncodingException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import lombok.NonNull;

@DependencyInjection
public class WebhookVerifier {
public Boolean verify(
@NonNull final String signingKey,
@NonNull final String sigHeader,
@NonNull final String webhookBody
) throws NoSuchAlgorithmException, InvalidKeyException, UnsupportedEncodingException {
String algorithm = "HmacSHA256";

Mac mac = Mac.getInstance(algorithm);
SecretKeySpec secretKeySpec = new SecretKeySpec(signingKey.getBytes(UTF_8), algorithm);
mac.init(secretKeySpec);

byte[] signatureBytes = mac.doFinal(webhookBody.getBytes(UTF_8));

String calculated = Base64.getEncoder().encodeToString(signatureBytes);
Boolean match = sigHeader.equals(calculated);

return match;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,35 +9,81 @@
import com.bitpay.demo.invoice.application.features.tasks.updateinvoice.ValidationInvoiceUpdateDataFailed;
import com.bitpay.demo.invoice.domain.InvoiceNotFound;
import com.bitpay.demo.invoice.domain.InvoiceUuid;
import com.bitpay.demo.invoice.infrastructure.features.tasks.updateinvoice.WebhookVerifier;
import com.bitpay.demo.shared.bitpayproperties.BitPayProperties;
import com.bitpay.demo.shared.logger.LogCode;
import com.bitpay.demo.shared.logger.Logger;
import com.google.common.reflect.TypeToken;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.ToNumberPolicy;
import java.io.UnsupportedEncodingException;
import java.lang.reflect.Type;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Map;
import lombok.NonNull;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HttpUpdateInvoice {
private final UpdateInvoice updateInvoice;
private final BitPayProperties bitPayProperties;
private final WebhookVerifier webhookVerifier;
private final Logger logger;

public HttpUpdateInvoice(@NonNull final UpdateInvoice updateInvoice) {
public HttpUpdateInvoice(
@NonNull final UpdateInvoice updateInvoice,
@NonNull final BitPayProperties bitPayProperties,
@NonNull final WebhookVerifier webhookVerifier,
@NonNull final Logger logger
) {
this.updateInvoice = updateInvoice;
this.webhookVerifier = webhookVerifier;
this.bitPayProperties = bitPayProperties;
this.logger = logger;
}

@PostMapping("/invoices/{uuid}")
public void execute(
@NonNull @PathVariable("uuid") final String invoiceUuid,
@NonNull @RequestBody final Map<String, Object> updateData
) throws ReflectiveOperationException, ValidationInvoiceUpdateDataFailed, InvoiceNotFound {
var data = (Map<String, Object>) updateData.get("data");
var event = (Map<String, Object>) updateData.get("event");
if (!event.isEmpty() && event.containsKey("name")) {
data.put("eventName", event.get("name"));
}
@NonNull @RequestBody final String requestBody,
@NonNull @RequestHeader("x-signature") final String signature
) throws ReflectiveOperationException,
ValidationInvoiceUpdateDataFailed,
InvoiceNotFound,
NoSuchAlgorithmException,
InvalidKeyException,
UnsupportedEncodingException {
if (this.webhookVerifier.verify(this.bitPayProperties.getToken(), signature, requestBody)) {
Type mapType = new TypeToken<Map<String, Object>>(){}.getType();
Gson gson = new GsonBuilder()
.setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE)
.create();
Map<String, Object> updateData = gson.fromJson(requestBody, mapType);

Map<String, Object> data = (Map<String, Object>) updateData.get("data");
Map<String, Object> event = (Map<String, Object>) updateData.get("event");
if (!event.isEmpty() && event.containsKey("name")) {
data.put("eventName", event.get("name"));
}

this.updateInvoice.execute(
new InvoiceUuid(invoiceUuid),
data
);
this.updateInvoice.execute(
new InvoiceUuid(invoiceUuid),
data
);
} else {
this.logger.error(
LogCode.IPN_SIGNATURE_VERIFICATION_FAIL,
"Webhook signature verification failed",
Map.of(
"uuid", invoiceUuid
)
);
}
}
}
3 changes: 2 additions & 1 deletion src/main/java/com/bitpay/demo/shared/logger/LogCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ public enum LogCode {
INVOICE_UPDATE_FAIL,
IPN_RECEIVED,
IPN_VALIDATE_SUCCESS,
IPN_VALIDATE_FAIL
IPN_VALIDATE_FAIL,
IPN_SIGNATURE_VERIFICATION_FAIL
}