-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
0a62e20
commit da615ce
Showing
23 changed files
with
2,856 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,9 @@ | ||
**/target/ | ||
|
||
# IDEA | ||
.idea/ | ||
*.iml | ||
|
||
# Compiled class file | ||
*.class | ||
|
||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,44 @@ | ||
# PayHook | ||
A Java-API for validating PayPals Webhooks. | ||
# NOTE: Functionality was not fully tested yet! | ||
## Installation | ||
[Click here for maven/gradle/sbt/leinigen instructions.](https://jitpack.io/#Osiris-Team/PayHook/LATEST) | ||
Java 8+ required. | ||
Make sure to watch this repository to get notified of future updates. | ||
## Motivation | ||
Basically PayPals latest [Checkout v2 Java-SDK](https://github.com/paypal/Checkout-Java-SDK) | ||
is missing the webhook event validation feature, which was available in the old, deprecated | ||
[PayPal Java-SDK](https://github.com/paypal/PayPal-Java-SDK). | ||
That's why PayHook exists. Its aim, is to provide an easy to use Java-API for validating | ||
webhook events. | ||
## Usage example | ||
This example uses spring(tomcat) to listen for POST post requests. | ||
Nevertheless this can be easily ported to your web application. | ||
```java | ||
Under construction... | ||
``` | ||
## Validation workflow | ||
1. Receive a POST http request | ||
2. Parse it into a WebHookEvent object | ||
3. Validate the event | ||
## FAQ | ||
<div> | ||
<details> | ||
<summary>What is a POST http request?</summary> | ||
Every request has a header and a body. | ||
By design, the POST request method requests that a web server accepts the data enclosed in the body of the request message, most likely for storing it. | ||
</details> | ||
<details> | ||
<summary>What does the header contain?</summary> | ||
In our case it contains: content-length, paypal-transmission-sig, | ||
paypal-cert-url, paypal-auth-algo, correlation-id, | ||
paypal-transmission-id, client_pid, | ||
accept, cal_poolstack, paypal-transmission-time, paypal-auth-version, | ||
host, content-type and finally the user-agent. | ||
</details> | ||
<details> | ||
<summary>What does the body contain?</summary> | ||
The body is a json string with a bunch of event specific data. | ||
For more details see the paypal docs: <a href="https://developer.paypal.com/docs/api-basics/notifications/webhooks/notification-messages/">webhooks/notification-messages</a> | ||
</details> | ||
</div> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,107 @@ | ||
<?xml version="1.0" encoding="UTF-8"?> | ||
<project xmlns="http://maven.apache.org/POM/4.0.0" | ||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | ||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> | ||
<modelVersion>4.0.0</modelVersion> | ||
|
||
<groupId>com.osiris.payhook</groupId> | ||
<artifactId>PayHook</artifactId> | ||
<version>1.0</version> | ||
<packaging>jar</packaging> | ||
|
||
<name>PayHook</name> | ||
<description>A Java-API for validating PayPals Webhooks.</description> | ||
|
||
<properties> | ||
<maven.compiler.source>8</maven.compiler.source> | ||
<maven.compiler.target>8</maven.compiler.target> | ||
<java.version>8</java.version> | ||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> | ||
</properties> | ||
|
||
<dependencies> | ||
<dependency> | ||
<groupId>com.google.code.gson</groupId> | ||
<artifactId>gson</artifactId> | ||
<version>2.8.6</version> | ||
</dependency> | ||
|
||
<dependency> | ||
<groupId>org.junit.jupiter</groupId> | ||
<artifactId>junit-jupiter</artifactId> | ||
<version>RELEASE</version> | ||
<scope>test</scope> | ||
</dependency> | ||
</dependencies> | ||
|
||
<build> | ||
<finalName>${project.name}</finalName> | ||
<defaultGoal>clean package</defaultGoal> | ||
<plugins> | ||
|
||
<plugin> | ||
<groupId>org.apache.maven.plugins</groupId> | ||
<artifactId>maven-compiler-plugin</artifactId> | ||
<version>3.8.1</version> | ||
<configuration> | ||
<source>${java.version}</source> | ||
<target>${java.version}</target> | ||
</configuration> | ||
</plugin> | ||
|
||
<plugin> | ||
<groupId>org.apache.maven.plugins</groupId> | ||
<artifactId>maven-shade-plugin</artifactId> | ||
<version>3.2.1</version> | ||
<executions> | ||
<execution> | ||
<phase>package</phase> | ||
<goals> | ||
<goal>shade</goal> | ||
</goals> | ||
<configuration> | ||
<createDependencyReducedPom>false</createDependencyReducedPom> | ||
</configuration> | ||
</execution> | ||
</executions> | ||
</plugin> | ||
|
||
<plugin> | ||
<groupId>org.apache.maven.plugins</groupId> | ||
<artifactId>maven-source-plugin</artifactId> | ||
<version>3.2.1</version> | ||
<executions> | ||
<execution> | ||
<id>attach-sources</id> | ||
<goals> | ||
<goal>jar</goal> | ||
</goals> | ||
</execution> | ||
</executions> | ||
</plugin> | ||
|
||
<plugin> | ||
<groupId>org.apache.maven.plugins</groupId> | ||
<artifactId>maven-javadoc-plugin</artifactId> | ||
<version>3.0.0</version> | ||
<executions> | ||
<execution> | ||
<id>attach-javadocs</id> | ||
<goals> | ||
<goal>jar</goal> | ||
</goals> | ||
</execution> | ||
</executions> | ||
</plugin> | ||
|
||
</plugins> | ||
|
||
<resources> | ||
<resource> | ||
<directory>src/main/resources</directory> | ||
<filtering>true</filtering> | ||
</resource> | ||
</resources> | ||
</build> | ||
|
||
</project> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,204 @@ | ||
package com.osiris.payhook; | ||
|
||
import com.google.gson.*; | ||
import com.osiris.payhook.exceptions.ParseBodyException; | ||
import com.osiris.payhook.exceptions.ParseHeaderException; | ||
import com.osiris.payhook.exceptions.WebHookValidationException; | ||
import com.osiris.payhook.paypal.Constants; | ||
import com.osiris.payhook.paypal.SSLUtil; | ||
|
||
import java.io.BufferedInputStream; | ||
import java.io.IOException; | ||
import java.net.URL; | ||
import java.security.InvalidKeyException; | ||
import java.security.KeyStoreException; | ||
import java.security.NoSuchAlgorithmException; | ||
import java.security.SignatureException; | ||
import java.security.cert.CertificateException; | ||
import java.security.cert.X509Certificate; | ||
import java.util.Collection; | ||
import java.util.List; | ||
import java.util.Map; | ||
import java.util.Objects; | ||
|
||
public class PayHook { | ||
|
||
/** | ||
* Parses the header, represented as {@link Map}, | ||
* into a {@link WebhookEventHeader} object and returns it. | ||
* @throws ParseHeaderException if this operation fails. | ||
*/ | ||
public WebhookEventHeader parseAndGetHeader(Map<String, String> headerAsMap) throws ParseHeaderException { | ||
// Check if all keys we need exist | ||
String transmissionId = validateAndGet(headerAsMap, Constants.PAYPAL_HEADER_TRANSMISSION_ID); | ||
String timestamp = validateAndGet(headerAsMap, Constants.PAYPAL_HEADER_TRANSMISSION_TIME); | ||
String transmissionSignature = validateAndGet(headerAsMap, Constants.PAYPAL_HEADER_TRANSMISSION_SIG); | ||
String certUrl = validateAndGet(headerAsMap, Constants.PAYPAL_HEADER_CERT_URL); | ||
String authAlgorithm = validateAndGet(headerAsMap, Constants.PAYPAL_HEADER_AUTH_ALGO); | ||
|
||
// Note that the webhook id and crc32 get set after the validation was run | ||
return new WebhookEventHeader(transmissionId, timestamp, transmissionSignature, authAlgorithm, certUrl); | ||
} | ||
|
||
/** | ||
* Parses the body, represented as {@link String}, | ||
* into a {@link JsonObject} and returns it. | ||
* @throws ParseBodyException if this operation fails. | ||
*/ | ||
public JsonObject parseAndGetBody(String bodyString) throws ParseBodyException { | ||
try{ | ||
return new Gson().fromJson(bodyString, JsonObject.class); | ||
} catch (Exception e) { | ||
throw new ParseBodyException(e.getMessage()); | ||
} | ||
} | ||
|
||
/** | ||
* Checks if the provided key exists in the map and returns its value. | ||
* The keys existence is checked by {@link String#equalsIgnoreCase(String)}, so that its case is ignored. | ||
* @return the value mapped to the provided key. | ||
* @throws WebHookValidationException | ||
*/ | ||
public String validateAndGet(Map<String, String> map, String key) throws ParseHeaderException { | ||
Objects.requireNonNull(map); | ||
Objects.requireNonNull(key); | ||
|
||
String value = map.get(key); | ||
if (value == null || value.equals("")) { | ||
for (Map.Entry<String, String> entry : map.entrySet()) { | ||
if (entry.getKey().equalsIgnoreCase(key)) { | ||
value = entry.getValue(); | ||
break; | ||
} | ||
} | ||
|
||
if (value == null || value.equals("")) { | ||
throw new ParseHeaderException("Header is missing the '"+key+"' key or its value!"); | ||
} | ||
} | ||
return value; | ||
} | ||
|
||
/** | ||
* See {@link #validateWebHookEvent(WebhookEvent)} (WebHookEvent)} for details. | ||
* @param validId your webhooks valid id. Get it from here: https://developer.paypal.com/developer/applications/ | ||
* @param validTypes your webhooks valid types/names. Here is a full list: https://developer.paypal.com/docs/api-basics/notifications/webhooks/event-names/ | ||
* @param header the http messages header as string. | ||
* @param body the http messages body as string. | ||
*/ | ||
public void validateWebHookEvent(String validId, List<String> validTypes, WebhookEventHeader header, String body) throws ParseBodyException, WebHookValidationException, CertificateException, NoSuchAlgorithmException, IOException, KeyStoreException, SignatureException, InvalidKeyException { | ||
validateWebHookEvent(new WebhookEvent(validId, validTypes, header, body)); | ||
} | ||
|
||
/** | ||
* Performs various checks to see if the received {@link WebhookEvent} is valid or not. | ||
* Performed checks: | ||
* Is this events name/type in the valid events list? | ||
* Are this events certificates valid? | ||
* Is this events data/transmission-signature valid? | ||
* Do the webhook ids match? | ||
* @param event | ||
* @return true if the webhook event is valid | ||
* @throws WebHookValidationException if validation failed. IMPORTANT: MESSAGE MAY CONTAIN SENSITIVE INFORMATION! | ||
* @throws ParseBodyException | ||
*/ | ||
public void validateWebHookEvent(WebhookEvent event) throws WebHookValidationException, ParseBodyException, IOException, CertificateException, KeyStoreException, NoSuchAlgorithmException, SignatureException, InvalidKeyException { | ||
WebhookEventHeader header = event.getHeader(); | ||
|
||
// Check if the webhook types match | ||
List<String> validEventTypes = event.getValidTypesList(); | ||
|
||
// event_type can be either an json array or object. Do stuff accordingly. | ||
JsonElement elementEventType = event.getBody().get("event_type"); | ||
if (elementEventType==null) elementEventType = event.getBody().get("event_types"); // Check for event_types | ||
if (elementEventType==null) throw new ParseBodyException("Failed to find key 'event_type' or 'event_types' in the provided json body."); // if the element is still null | ||
|
||
if (elementEventType.isJsonArray()){ | ||
// This means we have multiple event_type objects in the array | ||
JsonArray arrayEventType = elementEventType.getAsJsonArray(); | ||
for (JsonElement singleElementEventType : | ||
arrayEventType) { | ||
JsonObject o = singleElementEventType.getAsJsonObject(); | ||
if (!validEventTypes.contains(o.get("name").getAsString())) | ||
throw new WebHookValidationException("No valid type("+o.get("name")+") found in the valid types list: "+validEventTypes.toString()); | ||
} | ||
} | ||
else{ | ||
// This means we only have one event_type in the json | ||
JsonObject objectEventType = elementEventType.getAsJsonObject(); | ||
String webHookType = objectEventType.getAsString(); | ||
if (!validEventTypes.contains(webHookType)) | ||
throw new WebHookValidationException("No valid type("+webHookType+") found in the valid types list: "+validEventTypes.toString()); | ||
} | ||
|
||
// Load certs | ||
String clientCertificateLocation = event.getHeader().getCertUrl(); | ||
String trustCertificateLocation = Constants.PAYPAL_TRUST_DEFAULT_CERT; | ||
Collection<X509Certificate> clientCerts = SSLUtil.getCertificateFromStream(new BufferedInputStream(new URL(clientCertificateLocation).openStream())); | ||
Collection<X509Certificate> trustCerts = SSLUtil.getCertificateFromStream(PayHook.class.getClassLoader().getResourceAsStream(trustCertificateLocation)); | ||
|
||
// Check the chain | ||
SSLUtil.validateCertificateChain(clientCerts, trustCerts, "RSA"); | ||
|
||
// Construct expected signature | ||
String validWebhookId = event.getValidWebHookId(); | ||
String actualSignatureEncoded = header.getTransmissionSignature(); | ||
String authAlgo = header.getAuthAlgorithm(); | ||
String transmissionId = header.getTransmissionId(); | ||
String transmissionTime = header.getTimestamp(); | ||
String bodyString = event.getBodyString(); | ||
String expectedSignature = String.format("%s|%s|%s|%s", transmissionId, transmissionTime, validWebhookId, SSLUtil.crc32(bodyString)); | ||
|
||
// Compare the encoded signature with the expected signature | ||
String decodedSignature = SSLUtil.validateAndGetSignatureAsString(clientCerts, authAlgo, actualSignatureEncoded, expectedSignature); | ||
if (decodedSignature!=null){ | ||
String[] arrayDecodedSignature = decodedSignature.split("\\|"); // Split by | char | ||
header.setWebhookId(arrayDecodedSignature[2]); | ||
header.setCrc32(arrayDecodedSignature[3]); | ||
|
||
// Lastly check if the webhook ids match | ||
if (!header.getWebhookId().equals(event.getValidWebHookId())) | ||
throw new WebHookValidationException("Provided webhook id("+header.getWebhookId()+") does not match the valid id("+event.getValidWebHookId()+")!"); | ||
} | ||
else | ||
throw new WebHookValidationException("Transmission signature is not valid!"); | ||
} | ||
|
||
/** | ||
* Formats all of the WebHooks information to a String and returns it. | ||
* @param webHookEvent | ||
*/ | ||
public String getWebHookAsString(WebhookEvent webHookEvent) { | ||
Objects.requireNonNull(webHookEvent); | ||
|
||
StringBuilder stringBuilder = new StringBuilder(); | ||
stringBuilder.append("Information for object "+webHookEvent +System.lineSeparator()); | ||
|
||
// Add your info | ||
stringBuilder.append(System.lineSeparator()); | ||
stringBuilder.append("webhook-id: "+webHookEvent.getValidWebHookId() +System.lineSeparator()); | ||
stringBuilder.append("webhook-type: "+webHookEvent.getValidTypesList() +System.lineSeparator()); | ||
|
||
// Add header info | ||
stringBuilder.append(System.lineSeparator()); | ||
WebhookEventHeader header = webHookEvent.getHeader(); | ||
stringBuilder.append("header stuff: "); | ||
stringBuilder.append("webhook-id: "+header.getWebhookId() +System.lineSeparator()); | ||
stringBuilder.append("transmission-id: "+header.getTransmissionId() +System.lineSeparator()); | ||
stringBuilder.append("timestamp: "+header.getTimestamp() +System.lineSeparator()); | ||
stringBuilder.append("transmission-sig: "+header.getTransmissionSignature() +System.lineSeparator()); | ||
stringBuilder.append("auth-algo: "+header.getAuthAlgorithm() +System.lineSeparator()); | ||
stringBuilder.append("cert-url: "+header.getCertUrl() +System.lineSeparator()); | ||
stringBuilder.append("crc32: "+header.getCrc32() +System.lineSeparator()); | ||
|
||
// Add the json body in a pretty format | ||
stringBuilder.append(System.lineSeparator()); | ||
Gson gson = new GsonBuilder().setPrettyPrinting().create(); | ||
String jsonOutput = gson.toJson(webHookEvent.getBodyString()); | ||
stringBuilder.append("body-string: "+webHookEvent.getBodyString() +System.lineSeparator()); | ||
stringBuilder.append("body: "+jsonOutput +System.lineSeparator()); | ||
|
||
return stringBuilder.toString(); | ||
} | ||
|
||
} |
Oops, something went wrong.