0

我正在使用 DocuSign Connect 从 DocuSign 检索 webhook 并在我的 Larave 中消化它们;应用。这是基本的想法。

<?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:
            }
        }
    }
}

逻辑本身工作正常,但如果你看这里:


if (config('docusign.connect_key')) {
    $this->middleware(VerifyDocusignWebhookSignature::class);
}

如果我指定一个连接密钥,我会运行一些中间件来验证 webhook 来自 DocuSign。

验证签名的类来自 DocuSign,如下所示:

<?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));
    }
}

现在,为了在本地进行测试,我编写了一个测试,但是每当我运行它时,中间件都会告诉我 webhook 无效。

这是我的测试课

<?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));
    }
}

我还使用 webhook.site 来比较哈希:

在此处输入图像描述

鉴于此,我可以告诉您x-docusign-signature-3与我运行时生成的哈希匹配

$hash = $this->computeHash($this->docusignConnectKey, $payload);

那么,我的问题肯定源于我发送数据的方式吗?

4

1 回答 1

1

当您在传入有效负载上计算自己的 HMAC 时(以查看它是否与在标头中发送的 HMAC 匹配),您必须按原样使用传入有效负载。

在您的代码中:

public function handleWebhook(Request $request)
{
    $payload = json_decode($request->getContent(), true);

    $shouldProcessWebhook = $this->determineIfEnvelopeRelevant($payload);

您正在将 json 解码的有效负载发送到您的检查方法。这是不对的,您应该在原始有效负载到达时发送它。

(解码,然后编码 JSON 不一定会给你与原始相同的字节序列。)

只有在您确认有效负载来自 DocuSign,才应将 JSON 解码方法应用于有效负载。

另外,在验证发件人之前进行 JSON 解码是一个安全问题。一个坏人可能正试图向您发送一些错误的输入。在您验证发件人之前(在本例中通过 HMAC),规则是不信任任何内容。

奖金评论

我建议您还配置 DocuSign Connect webhook 的基本身份验证功能。基本身份验证通常在 Web 服务器级别进行检查。由于必须计算 HMAC,因此通常在应用程序级别进行检查。同时使用两者可以为坏人提供坚实的防御。

于 2022-02-14T20:15:14.967 回答