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 APNs with .p8 key #166

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ This is an easy to use package to send push notification.
* GCM
* FCM
* APN
* APN with .P8 key

## Installation

Expand Down Expand Up @@ -67,7 +68,14 @@ The default configuration parameters for **APN** are:
* ```passFile => __DIR__ . '/iosCertificates/yourKey.pem' //Optional```
* ```dry_run => false```

(Make sure to set `dry_run` to `true` if you're using development *.pem certificate, and `false` for production)
The default configuration parameters for **APN P8** are:

* ```key => __DIR__ . '/key.p8'```
* ```keyId => 'MyKeyId'```
* ```teamId => 'MyAppleTeamId'```
* ```dry_run => false```

(Make sure to set `dry_run` to `true` if you're using development token, and `false` for production)

Also you can update those values and add more dynamically
```php
Expand Down Expand Up @@ -111,6 +119,9 @@ For APN Service:
```php
$push = new PushNotification('apn');
```
```php
$push = new PushNotification('apnp8');
```

For FCM Service:
```php
Expand Down
337 changes: 337 additions & 0 deletions src/ApnP8.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,337 @@
<?php
namespace Edujugon\PushNotification;

use Edujugon\PushNotification\Contracts\PushServiceInterface;
use Illuminate\Support\Arr;

class ApnP8 extends PushService implements PushServiceInterface
{
const APNS_DEVELOPMENT_SERVER = 'https://api.development.push.apple.com';
const APNS_PRODUCTION_SERVER = 'https://api.push.apple.com';
const APNS_PORT = 443;
const APNS_PATH_SCHEMA = '/3/device/{token}';

/**
* Number of concurrent requests to multiplex in the same connection.
*
* @var int
*/
private $nbConcurrentRequests = 20;

/**
* Number of maximum concurrent connections established to the APNS servers.
*
* @var int
*/
private $maxConcurrentConnections = 1;

/**
* Flag to know if we should automatically close connections to the APNS servers or keep them alive.
*
* @var bool
*/
private $autoCloseConnections = true;

/**
* Current curl_multi handle instance.
*
* @var resource
*/
private $curlMultiHandle;

/**
* Apn constructor.
*/
public function __construct()
{
if (!defined('CURL_HTTP_VERSION_2')) {
define('CURL_HTTP_VERSION_2', 3);
}

$this->url = self::APNS_PRODUCTION_SERVER;

$this->config = $this->initializeConfig('apnp8');
}

/**
* Provide the unregistered tokens of the notification sent.
*
* @param array $devices_token
* @return array
*/
public function getUnregisteredDeviceTokens(array $devices_token)
{
$tokens = [];

if (!empty($this->feedback->tokenFailList)) {
$tokens = $this->feedback->tokenFailList;
}
if (!empty($this->feedback->apnsFeedback)) {
$tokens = array_merge($tokens, Arr::pluck($this->feedback->apnsFeedback, 'devtoken'));
}

return $tokens;
}

/**
* Send Push Notification
* @param array $deviceTokens
* @param array $message
* @return \stdClass APN Response
*/
public function send(array $deviceTokens, array $message)
{
if (false == $this->existKey()) {
return $this->feedback;
}

$responseCollection = [
'success' => true,
'error' => '',
'results' => [],
];

if (!$this->curlMultiHandle) {
$this->curlMultiHandle = curl_multi_init();

if (!defined('CURLPIPE_MULTIPLEX')) {
define('CURLPIPE_MULTIPLEX', 2);
}

curl_multi_setopt($this->curlMultiHandle, CURLMOPT_PIPELINING, CURLPIPE_MULTIPLEX);
if (defined('CURLMOPT_MAX_HOST_CONNECTIONS')) {
curl_multi_setopt($this->curlMultiHandle, CURLMOPT_MAX_HOST_CONNECTIONS, $this->maxConcurrentConnections);
}
}

$mh = $this->curlMultiHandle;
$errors = [];

$i = 0;
while (!empty($deviceTokens) && $i++ < $this->nbConcurrentRequests) {
$deviceToken = array_pop($deviceTokens);
curl_multi_add_handle($mh, $this->prepareHandle($deviceToken, $message));
}

// Clear out curl handle buffer
do {
$execrun = curl_multi_exec($mh, $running);
} while ($execrun === CURLM_CALL_MULTI_PERFORM);

// Continue processing while we have active curl handles
while ($running > 0 && $execrun === CURLM_OK) {
// Block until data is available
$select_fd = curl_multi_select($mh);
// If select returns -1 while running, wait 250 microseconds before continuing
// Using curl_multi_timeout would be better but it isn't available in PHP yet
// https://php.net/manual/en/function.curl-multi-select.php#115381
if ($running && $select_fd === -1) {
usleep(250);
}

// Continue to wait for more data if needed
do {
$execrun = curl_multi_exec($mh, $running);
} while ($execrun === CURLM_CALL_MULTI_PERFORM);

// Start reading results
while ($done = curl_multi_info_read($mh)) {
$handle = $done['handle'];

$result = curl_multi_getcontent($handle);

// find out which token the response is about
$token = curl_getinfo($handle, CURLINFO_PRIVATE);

$responseParts = explode("\r\n\r\n", $result, 2);
$headers = '';
$body = '';
if (isset($responseParts[0])) {
$headers = $responseParts[0];
}
if (isset($responseParts[1])) {
$body = $responseParts[1];
}

$statusCode = curl_getinfo($handle, CURLINFO_HTTP_CODE);
if ($statusCode === 0) {
$responseCollection['success'] = false;

$responseCollection['error'] = [
'status' => $statusCode,
'headers' => $headers,
'body' => curl_error($handle),
'token' => $token
];
continue;
}

$responseCollection['success'] = $responseCollection['success'] && $statusCode == 200;

$responseCollection['results'][] = [
'status' => $statusCode,
'headers' => $headers,
'body' => (string)$body,
'token' => $token
];
curl_multi_remove_handle($mh, $handle);
curl_close($handle);

if (!empty($deviceTokens)) {
$deviceToken = array_pop($deviceTokens);
curl_multi_add_handle($mh, $this->prepareHandle($deviceToken, $message));
$running++;
}
}
}

if ($this->autoCloseConnections) {
curl_multi_close($mh);
$this->curlMultiHandle = null;
}

//Set the global feedback
$this->setFeedback(json_decode(json_encode($responseCollection)));

return $responseCollection;
}

/**
* Get Url for APNs production server.
*
* @param Notification $notification
* @return string
*/
private function getProductionUrl(string $deviceToken)
{
return self::APNS_PRODUCTION_SERVER . $this->getUrlPath($deviceToken);
}

/**
* Get Url for APNs sandbox server.
*
* @param Notification $notification
* @return string
*/
private function getSandboxUrl(string $deviceToken)
{
return self::APNS_DEVELOPMENT_SERVER . $this->getUrlPath($deviceToken);
}

/**
* Get Url path.
*
* @param Notification $notification
* @return mixed
*/
private function getUrlPath(string $deviceToken)
{
return str_replace("{token}", $deviceToken, self::APNS_PATH_SCHEMA);
}

/**
* Decorate headers
*
* @return array
*/
public function decorateHeaders(array $headers): array
{
$decoratedHeaders = [];
foreach ($headers as $name => $value) {
$decoratedHeaders[] = $name . ': ' . $value;
}
return $decoratedHeaders;
}

/**
* @param $token
* @param array $message
* @param $request
* @param array $deviceTokens
*/
public function prepareHandle($deviceToken, array $message)
{
$uri = false === $this->config['dry_run'] ? $this->getProductionUrl($deviceToken) : $this->getSandboxUrl($deviceToken);
$headers = $message['headers'];
$headers['authorization'] = "bearer {$this->generateJwt()}";
unset($message['headers']);
$body = json_encode($message);

$options = [
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2,
CURLOPT_URL => $uri,
CURLOPT_PORT => self::APNS_PORT,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HEADER => true,
CURLOPT_SSL_VERIFYPEER => true
];

$ch = curl_init();

curl_setopt_array($ch, $options);
if (!empty($headers)) {
curl_setopt($ch, CURLOPT_HTTPHEADER, $this->decorateHeaders($headers));
}

// store device token to identify response
curl_setopt($ch, CURLOPT_PRIVATE, $deviceToken);

return $ch;
}

protected function generateJwt() {
$key = openssl_pkey_get_private('file://'.$this->config['key']);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest to not access the file system here to avoid any complications at runtime for the following reasons:

  • Directory structure can look different in other projects
  • Certificates should be placed in Laravel's /storage folder and read from there using storage_path() helper

Suggestion: Could you move the openssl_pkey_get_private method into the config.php file and access it from the storage path?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was just following the folder structure that exists nowadays.

There is the iosCertificates folder right there, I didn't place the .p8 file into the folder because it wasn't technically a certificate, but there would be no problem placing it "by default" inside a new folder. This route can be set in the config anyway, in case another project has a different structure.

$header = ['alg' => 'ES256','kid' => $this->config['keyId']];
$claims = ['iss' => $this->config['teamId'],'iat' => time()];

$header_encoded = $this->base64($header);
$claims_encoded = $this->base64($claims);

$signature = '';
openssl_sign($header_encoded . '.' . $claims_encoded, $signature, $key, 'sha256');
return $header_encoded . '.' . $claims_encoded . '.' . base64_encode($signature);
}

protected function base64($data) {
return rtrim(strtr(base64_encode(json_encode($data)), '+/', '-_'), '=');
}

/**
* Set the feedback with no exist any key.
*
* @return mixed|void
*/
private function messageNoExistKey()
{
$response = [
'success' => false,
'error' => "Please, add your APN key to the iosKeys folder." . PHP_EOL
];

$this->setFeedback(json_decode(json_encode($response)));
}

/**
* Check if the key file exist.
* @return bool
*/
private function existKey()
{
if (isset($this->config['key'])) {
$key = $this->config['key'];
if (!file_exists($key)) {
$this->messageNoExistKey();
return false;
}

return true;
}

$this->messageNoExistKey();
return false;
}

}
6 changes: 6 additions & 0 deletions src/Config/config.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,10 @@
'passFile' => __DIR__ . '/iosCertificates/yourKey.pem', //Optional
'dry_run' => true,
],
'apnp8' => [
'key' => __DIR__ . '/apns-key.p8',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

openssl_pkey_get_private(storage_path('/apns-key.p8'))

'keyId' => 'My_Key_Id',
'teamId' => 'My_Apple_Team_Id',
'dry_run' => true,
],
];
1 change: 1 addition & 0 deletions src/PushNotification.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class PushNotification
*/
protected $servicesList = [
'gcm' => Gcm::class,
'apnp8' => ApnP8::class,
'apn' => Apn::class,
'fcm' => Fcm::class
];
Expand Down
Loading