Testing DocuSign webhooks with a connect key - php
I am using DocuSign Connect to retrieve webhooks from DocuSign and digest them within my Larave; application. Here is the basic idea.
<?php
namespace App\Http\Controllers;
use App\Http\Middleware\VerifyDocusignWebhookSignature;
use App\Mail\PaymentRequired;
use App\Models\PaymentAttempt;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class DocusignWebhookController extends Controller
{
/**
* Create a new controller instance.
* If a DocuSign Connect key is preset, validate the request.
*/
public function __construct()
{
$this->gocardlessTabs = ['GoCardless Agreement Number', 'GoCardless Amount', 'GoCardless Centre'];
$this->assumedCustomer = 2;
if (config('docusign.connect_key')) {
$this->middleware(VerifyDocusignWebhookSignature::class);
}
}
/**
* Handle an incoming DocuSign webhook.
*/
public function handleWebhook(Request $request)
{
$payload = json_decode($request->getContent(), true);
$shouldProcessWebhook = $this->determineIfEnvelopeRelevant($payload);
if ($shouldProcessWebhook) {
switch ($payload['status']) {
case 'sent':
return $this->handleSentEnvelopeStatus($payload);
break;
case 'completed':
return $this->handleCompletedEnvelopeStatus($payload);
break;
case 'voided':
// ...
break;
default:
}
}
}
}
The logic itself works fine but if you look here:
if (config('docusign.connect_key')) {
$this->middleware(VerifyDocusignWebhookSignature::class);
}
If I specify a connect key I run some middleware to verify the webhook came from DocuSign.
The class to verify the signature came from DocuSign and looks like this:
<?php
namespace App\DocuSign;
/**
* This class is used to validate HMAC keys sent from DocuSign webhooks.
* For more information see: https://developers.docusign.com/platform/webhooks/connect/hmac/
*
* Class taken from: https://developers.docusign.com/platform/webhooks/connect/validate/
*
* Sample headers
* [X-Authorization-Digest, HMACSHA256]
* [X-DocuSign-AccountId, caefc2a3-xxxx-xxxx-xxxx-073c9681515f]
* [X-DocuSign-Signature-1, DfV+OtRSnsuy.....NLXUyTfY=]
* [X-DocuSign-Signature-2, CL9zR6MI/yUa.....O09tpBhk=]
*/
class HmacVerifier
{
/**
* Compute a hmac hash from the given payload.
*
* Useful reference: https://www.php.net/manual/en/function.hash-hmac.php
* NOTE: Currently DocuSign only supports SHA256.
*
* #param string $secret
* #param string $payload
*/
public static function computeHash($secret, $payload)
{
$hexHash = hash_hmac('sha256', $payload, utf8_encode($secret));
$base64Hash = base64_encode(hex2bin($hexHash));
return $base64Hash;
}
/**
* Validate that a given hash is valid.
*
* #param string $secret: the secret known only by our application
* #param string $payload: the payload received from the webhook
* #param string $verify: the string we want to verify in the request header
*/
public static function validateHash($secret, $payload, $verify)
{
return hash_equals($verify, self::computeHash($secret, $payload));
}
}
Now, in order to test this locally I've written a test but whenever I run it, the middleware tells me the webhook isn't valid.
Here is my test class
<?php
namespace Tests\Feature\Http\Middleware;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
class VerifyDocusignWebhookSignatureTest extends TestCase
{
use RefreshDatabase, WithFaker;
public function setUp(): void
{
parent::setUp();
config(['docusign.connect_key' => 'probably-best-not-put-on-stack-overflow']);
$this->docusignConnectKey = config('docusign.connect_key');
}
/**
* Given a JSON payload, can we parse it and do what we need to do?
*
* #test
*/
public function it_can_retrieve_a_webhook_with_a_connect_key()
{
Mail::fake();
$payload = '{"status":"sent","documentsUri":"/envelopes/2ba67e2f-0db6-46af-865a-e217c9a1c514/documents","recipientsUri":"/envelopes/2ba67e2f-0db6-46af-865a-e217c9a1c514/recipients","attachmentsUri":"/envelopes/2ba67e2f-0db6-46af-865a-e217c9a1c514/attachments","envelopeUri":"/envelopes/2ba67e2f-0db6-46af-865a-e217c9a1c514","emailSubject":"Please DocuSign: newflex doc test.docx","envelopeId":"2ba67e2f-0db6-46af-865a-e217c9a1c514","signingLocation":"online","customFieldsUri":"/envelopes/2ba67e2f-0db6-46af-865a-e217c9a1c514/custom_fields","notificationUri":"/envelopes/2ba67e2f-0db6-46af-865a-e217c9a1c514/notification","enableWetSign":"true","allowMarkup":"false","allowReassign":"true","createdDateTime":"2022-02-14T11:36:01.18Z","lastModifiedDateTime":"2022-02-14T11:37:48.633Z","initialSentDateTime":"2022-02-14T11:37:49.477Z","sentDateTime":"2022-02-14T11:37:49.477Z","statusChangedDateTime":"2022-02-14T11:37:49.477Z","documentsCombinedUri":"/envelopes/2ba67e2f-0db6-46af-865a-e217c9a1c514/documents/combined","certificateUri":"/envelopes/2ba67e2f-0db6-46af-865a-e217c9a1c514/documents/certificate","templatesUri":"/envelopes/2ba67e2f-0db6-46af-865a-e217c9a1c514/templates","expireEnabled":"true","expireDateTime":"2022-06-14T11:37:49.477Z","expireAfter":"120","sender":{"userName":"Newable eSignature","userId":"f947420b-6897-4f29-80b3-4deeaf73a3c5","accountId":"366e9845-963a-41dd-9061-04f61c921f28","email":"e-signature#newable.co.uk"},"recipients":{"signers":[{"tabs":{"textTabs":[{"validationPattern":"","validationMessage":"","shared":"false","requireInitialOnSharedChange":"false","requireAll":"false","value":"","required":"true","locked":"false","concealValueOnDocument":"false","disableAutoSize":"false","maxLength":"4000","tabLabel":"GoCardless Amount","font":"lucidaconsole","fontColor":"black","fontSize":"size9","localePolicy":{},"documentId":"1","recipientId":"56041698","pageNumber":"1","xPosition":"319","yPosition":"84","width":"84","height":"22","tabId":"207f970c-4d3c-4d0c-be6b-1f3aeecf5f95","tabType":"text"},{"validationPattern":"","validationMessage":"","shared":"false","requireInitialOnSharedChange":"false","requireAll":"false","value":"","required":"true","locked":"false","concealValueOnDocument":"false","disableAutoSize":"false","maxLength":"4000","tabLabel":"GoCardless Centre","font":"lucidaconsole","fontColor":"black","fontSize":"size9","localePolicy":{},"documentId":"1","recipientId":"56041698","pageNumber":"1","xPosition":"324","yPosition":"144","width":"84","height":"22","tabId":"f6919e94-d4b7-4ef4-982d-3fc6c16024ab","tabType":"text"},{"validationPattern":"","validationMessage":"","shared":"false","requireInitialOnSharedChange":"false","requireAll":"false","value":"","required":"true","locked":"false","concealValueOnDocument":"false","disableAutoSize":"false","maxLength":"4000","tabLabel":"GoCardless Agreement Number","font":"lucidaconsole","fontColor":"black","fontSize":"size9","localePolicy":{},"documentId":"1","recipientId":"56041698","pageNumber":"1","xPosition":"332","yPosition":"200","width":"84","height":"22","tabId":"9495a53c-1f5e-42a5-beec-9abcf77b4387","tabType":"text"}]},"creationReason":"sender","isBulkRecipient":"false","requireUploadSignature":"false","name":"Jesse","firstName":"","lastName":"","email":"Jesse.Orange#newable.co.uk","recipientId":"56041698","recipientIdGuid":"246ce44f-0c11-4632-ac24-97f31911594e","requireIdLookup":"false","userId":"b23ada8e-577e-4517-b0fa-e6d8fd440f21","routingOrder":"1","note":"","status":"sent","completedCount":"0","deliveryMethod":"email","totalTabCount":"3","recipientType":"signer"},{"tabs":{"signHereTabs":[{"stampType":"signature","name":"SignHere","tabLabel":"Signature 7ac0c7c8-f838-4674-9e37-10a0df2f81c1","scaleValue":"1","optional":"false","documentId":"1","recipientId":"38774161","pageNumber":"1","xPosition":"161","yPosition":"275","tabId":"371bc702-1a91-4b71-8c77-a2e7abe3210e","tabType":"signhere"}]},"creationReason":"sender","isBulkRecipient":"false","requireUploadSignature":"false","name":"Jesse Orange","firstName":"","lastName":"","email":"jesseorange360#gmail.com","recipientId":"38774161","recipientIdGuid":"844f781c-1516-4a5a-821a-9d8fb2319369","requireIdLookup":"false","userId":"f544f7ff-91bb-4175-894e-b42ce736f273","routingOrder":"2","note":"","status":"created","completedCount":"0","deliveryMethod":"email","totalTabCount":"1","recipientType":"signer"}],"agents":[],"editors":[],"intermediaries":[],"carbonCopies":[],"certifiedDeliveries":[],"inPersonSigners":[],"seals":[],"witnesses":[],"notaries":[],"recipientCount":"2","currentRoutingOrder":"1"},"purgeState":"unpurged","envelopeIdStamping":"true","is21CFRPart11":"false","signerCanSignOnMobile":"true","autoNavigation":"true","isSignatureProviderEnvelope":"false","hasFormDataChanged":"false","allowComments":"true","hasComments":"false","allowViewHistory":"true","envelopeMetadata":{"allowAdvancedCorrect":"true","enableSignWithNotary":"false","allowCorrect":"true"},"anySigner":null,"envelopeLocation":"current_site","isDynamicEnvelope":"false"}';
// Compute a hash as in production this will come from DocuSign
$hash = $this->computeHash($this->docusignConnectKey, $payload);
// Validate the hash as we're going to use it as the header
$this->assertTrue($this->validateHash($this->docusignConnectKey, $payload, $hash));
// Convert this response to an array for the test
$payload = json_decode($payload, true);
// Post as JSON as Laravel only accepts POSTing arrays
$this->postJson(route('webhook-docusign'), $payload, [
'x-docusign-signature-3' => $hash
])->assertStatus(200);
$this->assertDatabaseHas('payment_attempts', [
'envelope_id' => $payload['envelopeId']
]);
Mail::assertNothingSent();
}
/**
* As we're testing we need a way to verify the signature so we're computing the hash.
*/
private function computeHash($secret, $payload)
{
$hexHash = hash_hmac('sha256', $payload, utf8_encode($secret));
$base64Hash = base64_encode(hex2bin($hexHash));
return $base64Hash;
}
/**
* Validate that a given hash is valid.
*
* #param string $secret: the secret known only by our application
* #param string $payload: the payload received from the webhook
* #param string $verify: the string we want to verify in the request header
*/
private function validateHash($secret, $payload, $verify)
{
return hash_equals($verify, self::computeHash($secret, $payload));
}
}
I'm also using webhook.site to compare hashes:
Given this I can tell you that x-docusign-signature-3 matches the hash I generate when I run
$hash = $this->computeHash($this->docusignConnectKey, $payload);
So, my issue surely must stem from the way I'm sending the data back through?
When you compute your own HMAC on the incoming payload (to see if it matches the HMAC that was sent in the header), you must use the incoming payload as is.
In your code:
public function handleWebhook(Request $request)
{
$payload = json_decode($request->getContent(), true);
$shouldProcessWebhook = $this->determineIfEnvelopeRelevant($payload);
you are sending the json decoded payload to your check method. That is not right, you should send the raw payload, as it arrived.
(Decoding, then encoding JSON doesn't necessarily give you the same byte sequence as the original.)
The JSON decode method should only be applied to the payload after you've confirmed that the payload came from DocuSign.
Plus, doing the JSON decode before you've authenticated the sender is a security issue. A bad guy could be trying to send you some bad input. The rule is trust nothing until you've verified the sender (via the HMAC in this case).
Bonus comment
I recommend that you also configure DocuSign Connect webhook's Basic Authentication feature. Basic Authentication is often checked at the web server level. HMAC, since it must be computed, is usually check at the app level. Using both provides solid defense against bad guys.
Related
Issue with DocuSign sending Envelopes
I recently changed my DocuSign integration to use the JWT OAuth flow. To achieve this I have a few classes. OAuth Client <?php namespace App\DocuSign; use DocuSign\eSign\Client\ApiClient; use DocuSign\eSign\Client\Auth\OAuth; use DocuSign\eSign\Configuration; use Exception; use Illuminate\Support\Facades\Log; /** * Helper class to generate a DocuSign Client instance using JWT OAuth2. * * #see * */ class OAuthClient { /** * Create a new DocuSign API Client instance using JWT based OAuth2. */ public static function createApiClient() { $config = (new Configuration())->setHost(config('docusign.host')); $oAuth = (new OAuth())->setOAuthBasePath(config('docusign.oauth_base_path')); $apiClient = new ApiClient($config, $oAuth); try { $response = $apiClient->requestJWTUserToken( config('docusign.integrator_key'), config('docusign.user_id'), config('docusign.private_key'), 'signature impersonation', 60 ); if ($response) { $accessToken = $response[0]['access_token']; $config->addDefaultHeader('Authorization', 'Bearer ' . $accessToken); $apiClient = new ApiClient($config); return $apiClient; } } catch (Exception $e) { // If consent is required we just need to give the consent URL. if (strpos($e->getMessage(), 'consent_required') !== false) { $authorizationUrl = config('docusign.oauth_base_path') . '/oauth/auth?' . http_build_query([ 'scope' => 'signature impersonation', 'redirect_uri' => config('docusign.redirect_url'), 'client_id' => config('docusign.integrator_key'), 'response_type' => 'code' ]); Log::critical('Consent not given for DocuSign API', [ 'authorization_url' => $authorizationUrl ]); abort(500, 'Consent has not been given to use the DocuSign API'); } throw $e; } } } Signature Client Service <?php namespace App\DocuSign; use DocuSign\eSign\Api\EnvelopesApi; use DocuSign\eSign\Client\ApiClient; class SignatureClientService { /** * DocuSign API Client */ public ApiClient $apiClient; /** * Create a new instance of our class. */ public function __construct() { $this->apiClient = OAuthClient::createApiClient(); } /** * Getter for the EnvelopesApi */ public function getEnvelopeApi(): EnvelopesApi { return new EnvelopesApi($this->apiClient); } } Then, in my constructors where I want to use it I'm doing /** * Create a new controller instance */ public function __construct() { $this->clientService = new SignatureClientService(); $this->envelopesApi = $this->clientService->getEnvelopeApi(); } Finally, I use it like so $envelopeSummary = $this->envelopesApi->createEnvelope(config('docusign.api_account_id'), $envelopeDefinition); But I get an error that reads DocuSign\eSign\Client\ApiException: Error while requesting server, received a non successful HTTP code [400] with response Body: O:8:"stdClass":2:{s:9:"errorCode";s:21:"USER_LACKS_MEMBERSHIP";s:7:"message";s:60:"The UserID does not have a valid membership in this Account.";} in /homepages/45/d641872465/htdocs/sites/ita-portal/vendor/docusign/esign-client/src/Client/ApiClient.php:344 I researched this and this would imply that the user is not within the account, but they are. I also checked that this account owns the envelopes that I'm trying to send. For reference I took inspiration for envelope sending from here: https://developers.docusign.com/docs/esign-rest-api/how-to/request-signature-template-remote/
What I think is happening is that the request is going to the wrong server or the wrong account. I'd suggest using a packet analyser like Fiddler or Wireshark to log where your requests are headed (or just log the request within your application) The auth URLs seem to be correct since you're not getting a 401 unauthorised error but the envelopes and other queries' must match the base URL located in your account under the Apps and Keys page. It would be of the form demo.docusign.net for our demo environment or xxx.docusign.net for our production environment
ThePHPLeague OAuth2 Client `getAccessToken()` throws "An OAuth server error was encountered that did not contain a JSON body" error
I have been attempting to develop an API and client which communicate to each other via an implementation of ThePHPLeague's OAuth2 server and client. Using the curl command in a CLI, I am able to generate a token and use it to gain access to protected resources. User authentication relies on a bespoke PHP solution with Slim framework, which accepts a username and encrypted password stored in a database table. The same table is used for the OAuth2 implementation's user management. When a user login attempt is successfully validated, the AbstractProvider 's getAccessToken() method is called and an access token is requested from the API. Here is where the problem lies. I have tested functionality using the GenericProvider class. I've also extended the provider to create my own class. Using the both providers, I see the following error when I attempt to login: Slim Application Error Type: UnexpectedValueException Code: 0 Message: An OAuth server error was encountered that did not contain a JSON body File: /var/www/sloth2-client-php/vendor/league/oauth2-client/src/Provider/AbstractProvider.php Line: 693 #0 /.../vendor/league/oauth2-client/src/Provider/AbstractProvider.php(626): League\OAuth2\Client\Provider\AbstractProvider->parseResponse(Object(GuzzleHttp\Psr7\Response)) #1 /.../src/SlothProvider.php(113): League\OAuth2\Client\Provider\AbstractProvider->getParsedResponse(Object(GuzzleHttp\Psr7\Request)) #2 /.../src/Controller/AuthenticationController.php(69): App\SlothProvider->getAccessToken(Object(League\OAuth2\Client\Grant\ClientCredentials)) #3 /.../vendor/slim/slim/Slim/Handlers/Strategies/RequestResponse.php(42): App\Controller\AuthenticationController->authenticate(Object(Slim\Psr7\Request), Object(Slim\Psr7\Response), Array) #4 /.../vendor/slim/slim/Slim/Routing/Route.php(372): Slim\Handlers\Strategies\RequestResponse->__invoke(Array, Object(Slim\Psr7\Request), Object(Slim\Psr7\Response), Array) #5 /.../vendor/slim/slim/Slim/MiddlewareDispatcher.php(73): Slim\Routing\Route->handle(Object(Slim\Psr7\Request)) #6 /.../vendor/slim/slim/Slim/MiddlewareDispatcher.php(73): Slim\MiddlewareDispatcher->handle(Object(Slim\Psr7\Request)) #7 /.../vendor/slim/slim/Slim/Routing/Route.php(333): Slim\MiddlewareDispatcher->handle(Object(Slim\Psr7\Request)) #8 /.../vendor/slim/slim/Slim/Routing/RouteRunner.php(65): Slim\Routing\Route->run(Object(Slim\Psr7\Request)) #9 /.../vendor/slim/slim/Slim/Middleware/RoutingMiddleware.php(58): Slim\Routing\RouteRunner->handle(Object(Slim\Psr7\Request)) #10 /.../vendor/slim/slim/Slim/MiddlewareDispatcher.php(132): Slim\Middleware\RoutingMiddleware->process(Object(Slim\Psr7\Request), Object(Slim\Routing\RouteRunner)) #11 /.../vendor/slim/slim/Slim/Middleware/ErrorMiddleware.php(89): class#anonymous->handle(Object(Slim\Psr7\Request)) #12 /.../vendor/slim/slim/Slim/MiddlewareDispatcher.php(132): Slim\Middleware\ErrorMiddleware->process(Object(Slim\Psr7\Request), Object(class#anonymous)) #13 /.../vendor/slim/slim/Slim/MiddlewareDispatcher.php(73): class#anonymous->handle(Object(Slim\Psr7\Request)) #14 /.../vendor/slim/slim/Slim/App.php(206): Slim\MiddlewareDispatcher->handle(Object(Slim\Psr7\Request)) #15 /.../vendor/slim/slim/Slim/App.php(190): Slim\App->handle(Object(Slim\Psr7\Request)) #16 /.../public/index.php(8): Slim\App->run() #17 {main} The SlothProvider class mentioned in the stack trace is as follows: <?php namespace App; use League\OAuth2\Client\Provider\AbstractProvider; use League\OAuth2\Client\Token\AccessToken; use League\OAuth2\Client\Tool\BearerAuthorizationTrait; use Psr\Http\Message\ResponseInterface; use UnexpectedValueException; class SlothProvider extends AbstractProvider { use BearerAuthorizationTrait; public function __construct() { $this->clientId = getenv('OAUTH2_CLIENT_ID'); $this->clientSecret = getenv('OAUTH2_CLIENT_SECRET'); $this->redirectUri = getenv('OAUTH2_REDIRECT_URI'); } /** * Get authorization url to begin OAuth flow * * #return string */ public function getBaseAuthorizationUrl() { return getenv('OAUTH2_AUTHORIZATION_URL'); } /** * Get access token url to retrieve token * * #param array $params * * #return string */ public function getBaseAccessTokenUrl(array $params) { return getenv('OAUTH2_ACCESS_TOKEN_URL'); } /** * Get provider url to fetch user details * * #param AccessToken $token * * #return string */ public function getResourceOwnerDetailsUrl(AccessToken $token) { // You don't have one. You might consider throwing an exception here so // that, when this is called, you get an error and can code your // application to ensure that nothing calls this. // // Note that $this->getResourceOwner() is the most likely culprit for // calling this. Just don't call getResourceOwner() in your code. } /** * Get the default scopes used by this provider. * * This should not be a complete list of all scopes, but the minimum * required for the provider user interface! * * #return array */ protected function getDefaultScopes() { return ['basic']; } /** * Check a provider response for errors. * * #throws IdentityProviderException * #param ResponseInterface $response * #param array $data Parsed response data * #return void */ protected function checkResponse(ResponseInterface $response, $data) { // Write code here that checks the response for errors and throws // an exception if you find any. } /** * Generate a user object from a successful user details request. * * #param array $response * #param AccessToken $token * #return \League\OAuth2\Client\Provider\ResourceOwnerInterface */ protected function createResourceOwner(array $response, AccessToken $token) { // Leave empty. You can't use this, since you don't have a clear // resource owner details URL. You might consider throwing an // exception from here, as well. See note on // getResourceOwnerDetailsUrl() above. } /** * Requests an access token using a specified grant and option set. * * #param mixed $grant * #param array $options * #throws IdentityProviderException * #return AccessTokenInterface */ public function getAccessToken($grant, array $options = []) { $grant = $this->verifyGrant($grant); $params = [ 'client_id' => $this->clientId, 'client_secret' => $this->clientSecret, 'redirect_uri' => $this->redirectUri, ]; $params = $grant->prepareRequestParameters($params, $options); $request = $this->getAccessTokenRequest($params); $response = $this->getParsedResponse($request); if (false === is_array($response)) { throw new UnexpectedValueException( 'Invalid response received from Authorization Server. Expected JSON.' ); } $prepared = $this->prepareAccessTokenResponse($response); $token = $this->createAccessToken($prepared, $grant); return $token; } } I would like to know what this error means and how to solve it.
The server is responded with a 500 status code with a body that couldn't be decoded by json_decode(). the actual decoding message from json_last_error_msg() can be found in the UnexpectedValueException's previous exception's `getMessage(). To find out what it is, try catching the exception from $response = $this->getParsedResponse($request); and then throwing the previous exception. e.g. try { $response = $this->getParsedResponse($request); } catch (UnexpectedValueException $e) { if ($e->getPrevious()) { // json_decode() error message is in $e->getPrevious()->getMessage(). // An easy way to see it is to throw the previous exception: throw $e->getPrevious(); } } Hopefully the error message will give you a clue as to what went wrong. Otherwise, you need to look at the Request and Response objects that were sent/received. You'll need to inspect the getParsedResponse() method within League\OAuth2\Client\Provider\AbstractProvider to do that.
How to get google review reply using PHP my business api?
I am using the PHP Google MyBusiness API for my application. I have business reviews. Now I want to get any replies related to a particular review. I am able to post a reply, but I want to get replies (responses) for the review using PHP GMB API. How do I do so?
see developers.google.com in the response you get reviewReply object which holds your reply { "reviewId": string, "reviewer": { object(Reviewer) }, "starRating": enum(StarRating), "comment": string, "createTime": string, "updateTime": string, "reviewReply": { object(ReviewReply) }, } more info to get the review use the get method of Google_Service_MyBusiness_AccountsLocationsReviews_Resource class Google_Service_MyBusiness_AccountsLocationsReviews_Resource extends Google_Service_Resource { /** * Returns the specified review. This operation is only valid if the specified * location is verified. Returns `NOT_FOUND` if the review does not exist, or * has been deleted. (reviews.get) * * #param string $name The name of the review to fetch. * #param array $optParams Optional parameters. * #return Google_Service_MyBusiness_Review */ public function get($name, $optParams = array()) { $params = array('name' => $name); $params = array_merge($params, $optParams); return $this->call('get', array($params), "Google_Service_MyBusiness_Review"); }
How to decode WooCommerce Webhook Secret?
I can't find any information on what algorithm to use to decode WooCommerce webhook field X-Wc-Webhook-Signature in PHP. Does anyone know how to decode it? Thanks!
Expanding on the current answers, this is the PHP code snippet you need: $sig = base64_encode(hash_hmac('sha256', $request_body, $secret, true)); Where $secret is your secret, $request_body is the request body, which can be fetched with file_get_contents('php://input'); The $sig value should then be equal to the X-Wc-Webhook-Signature request header.
To expand on the laravel solution this is how I created middleware to validate the incoming webhook. Create middleware. The application that I am using keeps the WooCommerce consumer key and secret in a table assigned to a given store. class ValidateWebhook { /** * Validate that the incoming request has been signed by the correct consumer key for the supplied store id * * #param \Illuminate\Http\Request $request * #param \Closure $next * #return mixed */ public function handle(Request $request, Closure $next) { $signature = $request->header('X-WC-Webhook-Signature'); if (empty($signature)) { return response(['Invalid key'], 401); } $store_id = $request['store']; $consumer_key = ConsumerKeys::fetchConsumerSecretByStoreId($store_id); $payload = $request->getContent(); $calculated_hmac = base64_encode(hash_hmac('sha256', $payload, $consumer_key, true)); if ($signature != $calculated_hmac) { return response(['Invalid key'], 401); } return $next($request); } } Register the middleware in Kernel.php 'webhook' => \App\Http\Middleware\ValidateWebhook::class, Protect the webhook route with the middleware Route::post('webhook', 'PrintController#webhook')->middleware('webhook');
Here is my solution for this question. You need to generate a sha256 hash for the content that was sent [payload] and encode it in base64. Then just compare the generated hash with the received one. $secret = 'your-secret-here'; $payload = file_get_contents('php://input'); $receivedHeaders = apache_request_headers(); $receivedHash = $receivedHeaders['X-WC-Webhook-Signature']; $generatedHash = base64_encode(hash_hmac('sha256', $payload, $secret, true)); if($receivedHash === $generatedHash): //Verified, continue your code else: //exit endif;
According to the docs: "A base64 encoded HMAC-SHA256 hash of the payload" I'm guessing that the payload in this instances is the secret you've supplied in the webhook properties. Source: https://woocommerce.github.io/woocommerce-rest-api-docs/v3.html#webhooks-properties EDIT Further digging indicates that the payload is the body of the request. So you'd use a HMAC library to create a sha256 hash of the payload using your webhook secret, and then base64 encode the result, and do the comparison with X-Wc-Webhook-Signature and see if they match. You can generate a key pair from the following URL: /wc-auth/v1/authorize?app_name=my_app&scope=read_write&user_id=<a_unique_id_that_survives_the_roundtrip>&return_url=<where_to_go_when_the_flow_completes>&callback_url=<your_server_url_that_will_receieve_the_following_params> These are the params the callback URL receieves in the request body: { consumer_key: string, consumer_secret: string, key_permissions: string, user_id: string, }
Here's my laravel / lumen function to verify the webhook request, hope it helps someone! private function verifyWebhook(Request $request) { $signature = $request->header('x-wc-webhook-signature'); $payload = $request->getContent(); $calculated_hmac = base64_encode(hash_hmac('sha256', $payload, 'WOOCOMMERCE_KEY', true)); if($signature != $calculated_hmac) { abort(403); } return true; }
Amazon ElasticSearch service Signature mismatch for PUT Request - Amazon SDK php V2
I am using Amazon ElasticSearch Service and when i tried to create SignatureV4 Request it is working fine for search operations (GET Requests). But when i tried to do some operations like create indices (Using PUT request), it will trough the Signature mismatch error. I am using Amazon SDK version 2 SignatureV4 library for signing the requests. Also created a custom Elasticsearch handler to add tokens to the request. Does anybody have such issue with SignatureV4 library in Amazon SDK php V2. {"message":"The request signature we calculated does not match the signature you provided. Check your AWS Secret Access Key and signing method. Consult the service documentation for details.\n\nThe Canonical String for this request should have been\n'PUT\n/test_index_2\n\nhost:search-test-gps2gj4zx654muo6a5m3vxm3cy.eu-west-1.es.amazonaws.com\nx-amz-date:XXXXXXXXXXXX\n\nhost;x-amz-date\n271d5ef919251148dc0b5b3f3968c3debc911a41b60ef4e92c55b98057d6cdd4'\n\nThe String-to-Sign should have been\n'AWS4-HMAC-SHA256\XXXXXXXXXXXX\n20170511/eu-west-1/es/aws4_request\n0bd34812e0727fba7c54068b0ae1114db235cfc2f97059b88be43e8b264e1d57'\n"}
This tweak only necessary for the users who are still using Amazon SDK PHP version 2. In version 3, it supported by default. For signed request i updated the current elsticsearch client handler by adding a middle ware for signing the request. $elasticConfig = Configure::read('ElasticSearch'); $middleware = new AwsSignatureMiddleware(); $defaultHandler = \Elasticsearch\ClientBuilder::defaultHandler(); $awsHandler = $middleware($defaultHandler); $clientBuilder = \Elasticsearch\ClientBuilder::create(); $clientBuilder->setHandler($awsHandler) ->setHosts([$elasticConfig['host'].':'.$elasticConfig['port']]); $client = $clientBuilder->build(); I used the following library for this purpose use Aws\Common\Credentials\CredentialsInterface; use Aws\Common\Signature\SignatureInterface; use Guzzle\Http\Message\Request; class AwsSignatureMiddleware { /** * #var \Aws\Credentials\CredentialsInterface */ protected $credentials; /** * #var \Aws\Signature\SignatureInterface */ protected $signature; /** * #param CredentialsInterface $credentials * #param SignatureInterface $signature */ public function __construct() { $amazonConf = Configure::read('AmazonSDK'); $this->credentials = new \Aws\Common\Credentials\Credentials($amazonConf['key'], $amazonConf['secret']); $this->signature = new \Aws\Common\Signature\SignatureV4('es', 'eu-west-1'); } /** * #param $handler * #return callable */ public function __invoke($handler) { return function ($request) use ($handler) { $headers = $request['headers']; if ($headers['host']) { if (is_array($headers['host'])) { $headers['host'] = array_map([$this, 'removePort'], $headers['host']); } else { $headers['host'] = $this->removePort($headers['host']); } } if (!empty($request['body'])) { $headers['x-amz-content-sha256'] = hash('sha256', $request['body']); } $psrRequest = new Request($request['http_method'], $request['uri'], $headers); $this->signature->signRequest($psrRequest, $this->credentials); $headerObj = $psrRequest->getHeaders(); $allHeaders = $headerObj->getAll(); $signedHeaders = array(); foreach ($allHeaders as $header => $allHeader) { $signedHeaders[$header] = $allHeader->toArray(); } $request['headers'] = array_merge($signedHeaders, $request['headers']); return $handler($request); }; } protected function removePort($host) { return parse_url($host)['host']; } } The exact line i tweaked for this purpose is if (!empty($request['body'])) { $headers['x-amz-content-sha256'] = hash('sha256', $request['body']); } For PUT and POST request the payload hash was wrong because i was not considering the request body while generating payload. Hope this code is beneficial for anyone who is using Amazon SDK PHP version 2 and using the IAM based authentication for Elasticsearch Hosted service in Amazon cloud.