问题
I'm trying to integrate a service with Zoho subscriptions, and I want to make sure that the call actually comes from Zoho. To do that, in the webhook, I tick "I want to secure this webhook" and follow the documentation on their linked page - but I struggle to generate matching hash values. What are the tricks of correctly verifying the hash?
回答1:
I managed to crack it, here are some gotcha's I found in the process, and a working .Net implementation.
Gotchas
When configuring the webhook
- In the webhook URL, do not specify a port
- When clicking Save, the service must be up and running and available for calls from Zoho. This is because Zoho immediately makes a test call on clicking save, and rejects storing your changes if the http call doesn't go through. It means that your development or test box needs to be available publicly available. For me, specifying the IP address in the webhook didn't work, I needed a domain name. Not being able to access the developer machine from the Internet, in the end I had to test the integration from a home environment, using a dynamic DNS entry and a router port-forward.
- In my case, using the DELETE http verb didn't work. Zoho always appended the full JSON body or just a pair of curly braces to the call url, making the API call fail. However, Zoho support claims that this approach should work, and this issue doesn't occur in their test environments.
- Before calculating the hash, you need to build a string to calculate the hash for. It wasn't clear from their documentation how to do it for JSON payloads (affecting their "Raw" and "Default Payload" settings). Some unanswered questions on the web asked whether it needed to be deserialized, flattened, ordered. The answer is no, just use the payload as retrieved from the http request. So the correct way to assemble the string is: take URL query arguments (if any) and form fields (if any), sort them by key in alphabetically ascending order, append their key and value strings to the string without quotes, spaces, equal signs. Append the http call content (if any) without any processing to that string.
- In hash generation, use UTF8Encoding.
- Http headers to use in case of Zoho Default payload and Raw: Content-Type=application/json;charset=UTF-8 and X-Zoho-Webhook-Signature=${Generated Hash Value}. When using x-www-form-urlencoded webhooks: Content-Type=application/x-www-form-urlencoded and X-Zoho-Webhook-Signature=${Generated Hash Value}
Code
Our validation was implemented as an ASP-Net filter, I removed that bit to concentrate on the hash calculation bit.
public async Task ValidateZohoCall(HttpRequest request)
{
var zohoCalculatedHashValue = request.Headers.GetHeaderValue("X-Zoho-Webhook-Signature");
if (string.IsNullOrEmpty(zohoCalculatedHashValue))
{
throw new Exception("Webhook signature is missing.");
}
else
{
var toHash = BuildZohoStringToHash(request);
string locallyCalculatedHashValue = GetHash(toHash);
// Compare our value against what is in the request headers
if (locallyCalculatedHashValue != zohoCalculatedHashValue)
throw new Exception("Webhook signature is invalid.");
}
}
public string GetRequestBody(HttpRequest request)
{
string requestBody = "";
request.EnableRewind();
using (var stream = new StreamReader(request.Body))
{
stream.BaseStream.Position = 0;
requestBody = stream.ReadToEnd();
}
return requestBody;
}
/// <summary>
/// Concatenates parts of the http request into a single string according to
/// Zoho specifications.
/// </summary>
public string BuildZohoStringToHash(HttpRequest request)
{
StringBuilder sb = new StringBuilder();
// Get request fields from query string and form content.
var mergedRequestFields = new Dictionary<string, object>();
mergedRequestFields.Add(GetItemsFromQuery(request));
mergedRequestFields.Add(GetItemsFromForm(request));
// Sort those fields alphabetically by key name and append to output string.
foreach (var kv in mergedRequestFields.OrderBy(x =>
x.Key).ToDictionary(x => x.Key, y => y.Value))
sb.Append($"{kv.Key}{kv.Value}");
// Default-payload and raw type messages should not be processed,
// just appended to the end of the string.
sb.Append(GetRequestBody(request));
return sb.ToString();
}
public Dictionary<string, object> GetItemsFromQuery(HttpRequest request)
{
return request.Query.ToDictionary(x => x.Key, y => (object)y.Value);
}
public Dictionary<string, object> GetItemsFromForm(HttpRequest request)
{
if (!request.HasFormContentType || (request.Form == null) || !request.Form.Any())
return new Dictionary<string, object>();
return request.Form.ToDictionary(x => x.Key, y => (object)y.Value);
}
public string GetHash(string text)
{
var encoding = new UTF8Encoding();
byte[] textBytes = encoding.GetBytes(text);
byte[] keyBytes = encoding.GetBytes(_zohoWebhookSecret);
byte[] hashBytes;
using (HMACSHA256 hash = new HMACSHA256(keyBytes))
hashBytes = hash.ComputeHash(textBytes);
return BitConverter.ToString(hashBytes).Replace("-", "").ToLower();
}
来源:https://stackoverflow.com/questions/58933463/how-to-implement-zoho-subscriptions-webhook-hash-validation