Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
lubosdz opened this issue May 16, 2023 · 2 comments
Open

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

lubosdz opened this issue May 16, 2023 · 2 comments

Comments

@lubosdz
Copy link

lubosdz commented May 16, 2023

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
Copy link
Author

lubosdz commented May 23, 2023

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 - signnow/SignNowNodeSDK#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
Copy link
Author

lubosdz commented May 25, 2023

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 = '[email protected]',
		$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;
	}
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant