Skip to content

Commit

Permalink
1.0
Browse files Browse the repository at this point in the history
  • Loading branch information
Osiris-Team committed Mar 19, 2021
1 parent 0a62e20 commit da615ce
Show file tree
Hide file tree
Showing 23 changed files with 2,856 additions and 0 deletions.
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
**/target/

# IDEA
.idea/
*.iml

# Compiled class file
*.class

Expand Down
42 changes: 42 additions & 0 deletions README.md
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>
107 changes: 107 additions & 0 deletions pom.xml
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>
204 changes: 204 additions & 0 deletions src/main/java/com/osiris/payhook/PayHook.java
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();
}

}
Loading

0 comments on commit da615ce

Please sign in to comment.