signnow / SignNowPHPSDK

The Official SignNow PHP SDK library for interacting with SignNow REST API. Sign documents, request e-signatures, and build role-based workflows with multiple signers using this client.
https://www.signnow.com/developers
MIT License
8 stars 10 forks source link

Allow self-signed certificate in chain for devel test app #39

Open lubosdz opened 1 year ago

lubosdz commented 1 year ago

When attempting to upload a file with sample code (developer test app):

use SignNow\Api\Entity\Document\Upload as DocumentUpload;
$uploadFile = (new DocumentUpload(new \SplFileInfo('realFilePath')));
$document = $entityManager->create($uploadFile);

Throws error:

Unexpected exception of type [SignNow\Rest\EntityManager\Exception\EntityManagerException] with message [Request failed! Reason: cURL error 60: SSL certificate problem: self signed certificate in certificate chain (see https://curl.haxx.se/libcurl/c/libcurl-errors.html) for https://api-eval.signnow.com/document] in [...\app\vendor\signnow\rest-entity-manager\src\EntityManager.php:318]

How to allow Guzzle to accept self signed certificate in client wrapper?

lubosdz commented 1 year ago

Looks like uploading document does not work at all. Following is standalone code that fails uploading PDF as descripbed in API documentation:

// verify => false ... bypass error in devel "self signed certificate in certificate chain"
$client = new \GuzzleHttp\Client(['verify' => false]); 

$response = $client->request('POST', 'https://api-eval.signnow.com/document', [
  'multipart' => [
    [
        'name' => 'file',
        'contents' => file_get_contents($pathToPdfFile),
    ]
  ],
  'headers' => [
    'Accept' => 'application/json',
    'Authorization' => 'Bearer '.$bearerAccessToken,
  ],
]);

$resp = $response->getBody(); // false

$resp holds false and in dashboard API logs is {"errors":[{"code":65579,"message":"Must upload one file"}]}.

I tried few other clients - cURL, multipart file_get_contents - same error.

Seems related - https://github.com/signnow/SignNowNodeSDK/issues/1

EDIT: Figured out reason - online web documentation is not quite complete, additional attribute must be included in order to allow detecting file type (pdf, docx, ..) as well as stored file name - following works:

// verify => false ... bypass error in devel "self signed certificate in certificate chain"
$client = new \GuzzleHttp\Client(['verify' => false]); 

$response = $client->request('POST', 'https://api-eval.signnow.com/document', [
  'multipart' => [
    [
        'name' => 'file',
        'filename' => basename($path), // <-- (!) improtant, tell file extension and stored filename
        'contents' => file_get_contents($pathToPdfFile),
    ]
  ],
  'headers' => [
    'Accept' => 'application/json',
    'Authorization' => 'Bearer '.$bearerAccessToken,
  ],
]);

$resp = $response->getBody(); // OK
lubosdz commented 1 year ago

Here is working PHP wrapper draft around Signnow API - gets bearer token & uploads file. Usage:

// init class
$signnow = new Signnow("https://api-eval.signnow.com", "your@email", "login.PWD!", "abc123-YOUR-API-TOKEN***");

// get bearer token
$accessToken = $signnow->getAccessToken();

// upload file
$path = '/var/test/sample-contract.pdf';
$resp = $signnow->uploadFile($path); // e.g. {'id' => 'abc123*****'}

PHP wrapper:

/**
* Standalone PHP wrapper around Signnow API - basic working draft, add more methods as needed ..
* (no dependencies, no guzzle, ..)
*/
class Signnow
{
    protected
        /** @var string API URL */
        $urlEndpoint = 'https://api-eval.signnow.com',

        /** @var string (Static) auth token under user's account needed to obtain access/bearer token */
        $basicAuthToken = 'XD8ODNmNTU6NWNmN....',

        /** @var string Signnow account user creadentials */
        $loginEmail = 'your@email.com',
        $loginPassword = 'Your.pwd!',

        /** @var string (Dynamic) access (bearer) token used across most of requests */
        $accessToken,

        /** @var string Cache key specific to credentials */
        $cacheKey;

    /**
    * Constructor - set user credentials for API requests
    * @param string $url
    * @param string $email
    * @param string $pwd
    * @param string $basicToken
    */
    public function __construct($url, $email, $pwd, $basicToken)
    {
        $this->urlEndpoint = trim($url);
        $this->loginEmail = trim($email);
        $this->loginPassword = trim($pwd);
        $this->basicAuthToken = trim($basicToken);
        $this->cacheKey = substr(md5($url.$email.$basicToken), 0, 10);
        if(!$this->cacheKey){
            throw new \Exception('Cache key may not be empty.');
        }
        $this->cacheKey = "signnow.bearer.resp.{$this->cacheKey}";
    }

    /**
    * Return bearer (access) token
    * Either load valid from cache or obtain a new token
    */
    public function getAccessToken($forceNew = false, $refresh = false) : string
    {
        if($this->accessToken && !$forceNew && !$refresh){
            return $this->accessToken;
        }

        // optional - set your cache handler, this is just example for Yii2 framework
        //$cache = Yii::$app->cache;
        $cache = null;
        $resp = $forceNew || !$cache? '' : $cache->get($this->cacheKey);

        if(!$resp || $forceNew){
            // generate bearer token
            $data = [
                // password|refresh_token|authorization_code
                'grant_type' => 'password',
                'username' => $this->loginEmail,
                'password' => $this->loginPassword,
            ];

            $options = [
                'headers' => [
                    'Authorization: Basic '.$this->basicAuthToken,
                    'Content-type: application/x-www-form-urlencoded',
            ]];

            $resp = $this->doHttpRequest('/oauth2/token', $data, $options);

            if(!empty($resp['access_token']) && !empty($resp['expires_in']) && $cache){
                // store response to cache
                $cache->set($this->cacheKey, $resp);
            }
        }

        // should be valid, default expiration is 30 days - 2592000 secs
        $this->accessToken = empty($resp['access_token']) ? '' : $resp['access_token'];

        if($this->accessToken && $refresh && !$forceNew && !empty($resp['refresh_token'])){
            // refresh but only if this is not just forced new token, makes no sense to refresh new token
            $data = [
                'grant_type' => 'refresh_token',
                'refresh_token' => $resp['refresh_token'],
            ];
            $options = [
                'headers' => [
                    'Authorization: Basic '.$this->basicAuthToken,
                    'Content-type: application/x-www-form-urlencoded',
            ]];
            $resp = $this->doHttpRequest('/oauth2/token', $data, $options);
            if(!empty($resp['access_token']) && !empty($resp['expires_in']) && $cache){
                $cache->set($this->cacheKey, $resp);
                $this->accessToken = $resp['access_token'];
            }
        }

        return $this->accessToken;
    }

    /**
    * Return info about bearer token validity - expires_in [secs], token_type, ..
    */
    public function verifyToken()
    {
        $info = [];
        $accessToken = $this->getAccessToken();

        if($accessToken){
            $options = [
                'method' => 'GET', // (!) may not be POST, or returns "invalid_client"
                'headers' => [
                    'Authorization: Bearer '.$accessToken,
                    'Content-type: application/x-www-form-urlencoded',
            ]];
            $info = $this->doHttpRequest('/oauth2/token', [], $options);
        }

        return $info;
    }

    /**
    * Return user account info - id, primary_email, emails[0] .., first_name, last_name, ..
    */
    public function getUserInfo()
    {
        $info = [];
        $accessToken = $this->getAccessToken();

        if($accessToken){
            $options = [
                'method' => 'GET', // (!) may not be POST, or returns "invalid_client"
                'headers' => [
                    'Authorization: Bearer '.$accessToken,
                    'Content-type: application/json',
            ]];
            $info = $this->doHttpRequest('/user', [], $options);
        }

        return $info;
    }

    /**
    * Uploads a file to user's account and returns unique id of the uploaded document.
    * Accepts .doc, .docx, .pdf, .xls, .xlsx, .ppt, .pptx and .png file types
    * @param string $path Abs. path to file
    */
    public function uploadFile($path)
    {
        if(!is_file($path)){
            throw new \Exception("File not found in [{$path}].");
        }

        if(!$this->getAccessToken()){
            throw new \Exception("Invalid access token.");
        }

        // based on https://stackoverflow.com/questions/4003989/upload-a-file-using-file-get-contents
        $boundary = '-----------------------'.microtime(true);
        $ext = strtolower( pathinfo($path, PATHINFO_EXTENSION) );
        $file_contents = file_get_contents($path);

        if(!in_array($ext, ['pdf', 'docx', 'xlsx', 'png'])){
            throw new \Exception("File type [{$ext}] not allowed for uploading.");
        }

        // build multipart stream
        $data =  "--{$boundary}\r\n"
        // (!) important - must include filename=\"".basename($path)."\" - to detect file type
        // also will store file name in dashboard/documents
          ."Content-Disposition: form-data; name=\"file\"; filename=\"".basename($path)."\"; \r\n\r\n"
          .$file_contents."\r\n"
          ."--{$boundary}--\r\n";

        $options = [
            'headers' => [
                'Authorization: Bearer '.$this->accessToken,
                'Content-type: multipart/form-data; boundary='.$boundary,
        ]];

        // resp e.g. {'id' => "3b323840975b9a*********"}
        return $this->doHttpRequest('/document', $data, $options);
    }

    /**
    * HTTP/HTTPS request without SSL verification and without any dependency
    * https://stackoverflow.com/questions/11319520/php-posting-json-via-file-get-contents
    * @param string $url
    * @param string|array $data
    * @param string $options e.g. timeout => 5, method => 'GET', content, header, ..
    * @param bool $tryJson If TRUE, try to convert response string into JSON
    */
    protected function doHttpRequest($url, $data = null, array $options = [], $tryJson = true)
    {
        $options = array_change_key_case($options, CASE_LOWER);

        // default Signnow API headers
        $headers = [
            'Accept: application/json',
        ];

        if(!empty($options['headers'])){
            $headers = array_merge($headers, $options['headers']);
            unset($options['headers']);
        }

        $http = [
            'timeout' => 5, // 5 secs
            'method' => 'POST',
            'header' => $headers,
        ];

        if($data){
            $http['content'] = $data;
        }

        // explicitly defined HTTP section
        if(!empty($options['http'])){
            $http = $options['http'] + $http;
            unset($options['http']);
        }

        $ssl = [
            // disable certificate check if needed in sandbox
            //'verify_peer' => DEBUG_MODE ? false : true,
            //'verify_peer_name' => DEBUG_MODE ? false : true,
        ];

        // explicitly defined SSL section
        if(!empty($options['ssl'])){
            $ssl = $options['ssl'] + $ssl;
            unset($options['ssl']);
        }

        // merge remaining HTTP options
        if(!empty($options)){
            $http = $options + $http;
        }

        // build e.g. POST FORM data - must be "Content-type: application/x-www-form-urlencoded"
        if(!empty($http['content']) && is_array($http['content'])){
            $http['content'] = http_build_query($http['content']);
        }

        $ctx = stream_context_create([
            'http' => $http,
            'ssl' => $ssl,
        ]);

        // convert relative URL to absolute
        if(false === stripos($url, '://')){
            $url = rtrim($this->urlEndpoint, ' \/') .'/'. ltrim($url, ' \/');
        }

        $response = file_get_contents($url, false, $ctx);

        // attempt converting to JSON
        if($tryJson && $response && is_string($response) && !!($tmp = json_decode($response, true))){
            $response = $tmp;
        }

        return $response;
    }
}