Статьи

Цифровые билеты с PHP и Apple Passbook

Почему мы, воины PHP, должны вообще заботиться о Passbook от Apple? Ну, во-первых, потому что Apple сделала эту технологию открытой (ну, вроде…), во-вторых, потому что она может использоваться вне устройств iOS, и в-третьих, потому что она включает в себя много известных и любимых технологий, таких как JSON и RESTful API. Я также добавил бы, что это очень интересная технология, но это мое личное мнение.

В этой статье я покажу вам, как я создал пример веб-приложения, которое создает и распространяет пропуски в форме «членской карты PHPMaster». Это не полнофункциональный продукт, но хорошая база для более серьезного использования в реальных условиях. Вы можете скачать демо-код для статьи с GitHub.

Обратите внимание: чтобы выполнить код этой статьи, вы (или ваш клиент) должны быть зарегистрированы в одной из платных программ для разработчиков Apple iOS, которая позволяет вам создавать необходимые сертификаты для подписи ваших пропусков. По этой причине я также сделал онлайн-приложение для вас.

Что такое пропуск и как его использовать?

Пропуск — это документ с цифровой подписью, такой как билет, членский билет или купон, который можно распространять по электронной почте или через Интернет. Его можно открыть с помощью приложения Passbook на iOS или с помощью приложения, такого как PassWallet на Android. В iOS пропуск также можно связать с сопутствующим приложением, например с приложением Eventbrite , которое позволяет вам покупать билеты и добавлять их в Passbook вашего устройства, но это не обязательно.

Спецификации Apple описывают четыре основных типа проходов:

  • Посадочные талоны
  • Билеты на мероприятия
  • Розничные купоны
  • Магазин карт

Есть также универсальный тип прохода, который охватывает другие цели, и этот тип прохода я использую здесь.

Каждый проход содержит различные биты данных. Некоторые, такие как имя участника и штрих-код, отображаются визуально на «лицевой стороне» пропуска. Другие данные расположены на «обороте» прохода и запускаются значком «i». Проходы также могут содержать некоторую скрытую информацию, такую ​​как время и местоположение, которые могут использоваться для отображения соответствующих уведомлений.

На странице разработчиков Apple Passbook вы можете найти официальные руководства и некоторые примеры кода.

Концепция приложения: членская карта PHPMaster

Пример приложения имеет две основные функции:

  • Создание и хранение пропусков
  • Реализация API регистрации / отмены регистрации веб-службы

passbook001

Основная форма запрашивает у пользователя полное имя, адрес электронной почты и дополнительную избранную функцию. Изображение участника выбирается с использованием Gravatar API . Код бэкэнда затем сохраняет предоставленные данные в базу данных, генерирует пропуск и отправляет копию на электронную почту подписчика.

passbook002

Пропуск содержит URL-адрес и коды доступа для веб-службы, поэтому, когда подписчик добавляет пропуск в свою коллекцию, приложение Passbook вызывает предварительно определенный URL-адрес регистрации, который должен быть реализован для проверки и сохранения предоставленных данных для последующего использования.

Структура приложения

Поскольку ядро ​​этой статьи состоит в понимании кода, связанного с передачей, я начну с базы хорошо известных компонентов с открытым исходным кодом, доступных на GitHub и использующих Composer.

Приложение построено с использованием Slim Framework, поскольку оно легкое и хорошо подходит как для разработки RESTful, так и для приложений с веб-интерфейсом. Используется стандартный шаблонный движок Slim для PHP, а также DateTimeLogWriter из пакета Slim Extras.

Слой данных и модели покрыт Idiorm и Paris . Idiorm заботится о работе с базами данных, а Paris предоставляет простой и понятный интерфейс модели. Это позволяет нам работать с объектом Pass и Subscriber и вызывать, например, $pass->pack() для генерации пакета pass.

Пройти определение

Первый шаг — определить проход; как это будет выглядеть и какие данные будут содержать?

passbook003

Имея эту информацию под рукой, мы можем создать каталог шаблонов пропусков, содержащий основные ресурсы:

  шаблоны / проходит / PHPMaster.raw /
     logo.png
     icon.png
     pass.json 

Каждое из изображений может иметь соответствующую ImageName @2x.png которую можно использовать на устройствах ImageName @2x.png , если таковые имеются. Логотип отображается в левом верхнем углу прохода, а значок отображается в окне push-уведомлений и почтовом приложении iOS. pass.json содержит данные для пропуска.

В pass.json teamIdentifier назначается нам Apple, когда мы подписываемся на Программу разработчика. passTypeIdentifier — это уникальный идентификатор обратного домена для нашего типа пропуска, например, pass.com.phpmaster.membership-test (требуется префикс «.pass»). Поле serialNumber должно быть уникальным для каждого прохода, поэтому хорошим значением для использования является первичный ключ из таблицы базы данных, в которой хранятся проходы.

generic ключ указывает используемый тип прохода и содержит конкретные поля для прохода. У каждого типа прохода есть набор primaryFields , secondaryFields primaryFields и auxiliaryFields primaryFields , отображаемых в разных местах на лицевой стороне; он также может иметь несколько backFields отображаемых на обратной стороне. Каждое поле имеет метку (отображается публично), ключ (для внутренней ссылки) и значение.

webServiceURL и authenticationToken определяются позже в приложении и заполняются базовым URL-адресом и учетными данными доступа, используемыми устройством для регистрации на нашем сервере.

Сертификаты

Следующим шагом является получение сертификата, используемого для генерации пропусков. Панель инициализации Apple Developer используется для запроса сертификатов, и в вики PassKit есть хороший видеоурок . Вам нужен определенный сертификат для каждого типа пропуска; общее имя сертификата должно соответствовать passTypeIdentifier прохода.

Подпись каждого пропуска должна также включать публичный промежуточный сертификат Apple WWDR .

Каталог и расположение файлов

Вот как выглядит макет приложения:

passbook004

Каталог bin содержит утилиту командной строки signpass.php . Эта программа является портом PHP консольных утилит Apple, написанных на Objective-C и Ruby. Его можно использовать для проверки прохода или для его создания из заданного каталога. Он использует библиотеку PassSigner, которую я подробно опишу позже.

Каталог config содержит файлы конфигурации. Сделайте копию образца и назовите ее в соответствии с вашей средой (stage, Dev, Prod и т. Д.) Чтобы определить пользовательскую среду, создайте текстовый файл с именем .mode внутри корневого каталога, содержащий желаемое имя вашей среды. Массив $config['db'] в файле конфигурации используется Idiorm, а массив $config['app'] передается непосредственно конструктору приложения Slim.

Каталог data содержит файлы журнала, сгенерированные проходы, файл дампа SQL и каталог Certificate который должен содержать общедоступный сертификат Apple и сертификат для вашего прохода, оба в формате PEM. Сертификаты распространяются в формате CER или P12 и могут быть преобразованы с помощью утилиты openssl следующим образом:

  openssl pkcs12 -в данных / Certificate / PHPMaster.p12 -в данных / Certificate / PHPMaster.pem 

Каталог templates содержит файлы templates внешнего интерфейса. Есть две страницы, home ( main.php ) и страница результатов ( pass.php ), которые совместно используют элементы header и footer. Форма подписки имеет свой собственный файл ( form.php ).

Подкаталог pass содержит шаблоны для каждого типа passes . Шаблоны каждого прохода организованы в каталоги с именем PassTypeOrName .raw содержащие необходимые файлы.

Пользовательский код библиотеки находится в lib/MyPassbook . Внутри этого каталога есть:

  • библиотека PassSigner, отвечающая за подписание и проверку пропусков
  • классы моделей, построенные на базовых объектах Subscriber , Pass и Device

DevicePass — это промежуточный класс, необходимый Парижу, который управляет отношениями «многие ко многим» между устройствами и проходами; как определено в спецификациях Passbook, каждое устройство может содержать много проходов, и копия каждого прохода может храниться на многих устройствах.

public каталог приложения содержит файлы контроллера. index.php является основным контроллером. Контроллер включает в себя include/bootstrap.php который отвечает за запуск приложения, загрузку файла конфигурации, настройку некоторых параметров по умолчанию, подключение ORM к базе данных и запуск средства записи журнала. В частности, скрипт также устанавливает некоторый код в ловушку события slim.before, используемую для захвата специального HTTP-заголовка Authentication.

Файл install.php — это простой и быстрый скрипт, который сбрасывает приложение; вероятно, нет необходимости говорить, что его нужно удалить после того, как вы все настроите!

Также должен быть файл .htaccess который перенаправляет все нефайловые URI в index.php и добавляет пользовательский тип MIME для наших проходов:

  Приложение AddType / vnd.apple.pkpass pkpass 

Это позволяет Safari открыть проход с помощью утилиты PassViewer.

Написание заявки

Наиболее важными компонентами приложения являются класс PassSigner и фронт-контроллер index.php . Paris дает нам возможность использовать классы моделей, чтобы базовая логика базы данных была прозрачной и управлялась Idiorm. Создать класс модели так же просто, как:

 <?php class Subscriber extends Model { } 

Необходимо создать три класса: Pass , Device и Subscriber .

Основные методы, такие как create() , find_many() , delete() и т. Д., find_many() от базового класса. Мы можем изменить основные свойства (например, связанную таблицу базы данных или идентификатор столбца), усовершенствовать методы по умолчанию и добавить пользовательские методы по мере необходимости.

Я добавил три метода в класс Subscriber :

  • pass() — реализует отношение один-к-одному с объектом Pass и возвращает этот объект.
  • create() — переопределяет метод create() по умолчанию, позволяющий передавать массив данных.
  • createPass() — создать объект Pass связанный с текущим подписчиком.

И это шесть методов, которые я добавил в класс Pass :

  • subscriber() — реализовать отношение один-к-одному с объектом Subscriber и вернуть этот объект.
  • devices() — определяет отношение «многие ко многим» с объектом Device . У него есть соответствующий метод passes() в классе Device .
  • create() — аналогично методу create() класса Subscriber .
  • delete() — переопределить метод по умолчанию; удалить связанные объекты « Devices и « Subscriber .
  • filename() — вычислить и вернуть имя файла для передачи на диск.
  • pack() — сохранить пароль на диск с помощью библиотеки PassSigner.

Библиотека PassSigner

PassSigner предоставляет два основных открытых метода для управления PassSigner :

  • signPass() — взять каталог, содержащий данные для одного прохода и данные сертификата, и создать сжатый файл PKPASS.
  • verifyPassSignature() берет файл PKPASS и проверяет его структуру и подпись.

Есть также два служебных метода rcopy() и rrmdir() для рекурсивного копирования и удаления каталогов, а также некоторые внутренние защищенные методы. Библиотека широко использует функции проверки исключений, и два важных метода проверяют наличие сертификата Apple, который должен быть определен константой APPLE_CERTIFICATE с APPLE_CERTIFICATE к сертификату.

Чтобы подписать проход, файл с именем manifest.json должен быть создан с использованием содержимого каталога прохода. Этот файл должен быть подписан вашим сертификатом и содержать публичный сертификат Apple. Данные подписи сохраняются в файле с именем signature .

Допустим, у нас есть каталог, содержащий данные о пропусках:

  Данные / проходит / PassForUser001.raw
     info.png
     logo.png
     thumbnail.png
     pass.json 

Первым шагом является рекурсивное копирование всего содержимого во временный каталог (например, /tmp/PassForUser001 ). Убедитесь, что нет «ненужных» файлов, которые не должны быть включены, например, надоедливые файлы .DS_Store если вы используете OSX. Если какой-либо из этих файлов включен в заархивированный проход, но отсутствует в манифесте, этот пропуск недействителен.

Затем создайте ассоциативный массив для хранения данных манифеста. Для каждого файла в каталоге проходов есть элемент, относительный путь — это ключ элемента, а значение — хеш файла SHA1. Массив кодируется и сохраняется как manifest.json в каталоге pass.

 <?php $manifestData = array(); $files = new DirectoryIterator($passPath); foreach ($files as $file) { if ($file->isDot() || $file->getFilename() == ".DS_Store") { continue; } $key = trim(str_replace($passPath, "", $file->getPathName()), "/"); $manifestData[$key] = sha1_file($file->getPathName()); } 

Теперь для подписи … в основном, мы подписываем файл manifest.json используя наш сертификат и пароль, включая сертификат Apple WWDR. Двоичный вывод записывается в signature . Однако здесь есть проблема: бинарный параметр для функций openssl_* PHP openssl_* и генерирует текстовую подпись. Я обошел это, вызвав вместо этого команду оболочки из PHP:

 <?php $signCommand = sprintf( "openssl smime -binary -sign -certfile %s -signer %s -inkey %s -in %s -out %s -outform DER -passin pass:%s", APPLE_CERTIFICATE, $certPath, $certPath, $manifestPath, $signaturePath, $certPassword ); $signResult = false; $signOut = array(); // needed but unused exec($signCommand, $signOut, $signResult); unset($signOut); 

Если опция $zip включена (по умолчанию она включена), проход упаковывается с использованием класса ZipArchive .

Чтобы проверить передачу, verifyPassSignature() извлекает данный проход во временный каталог, а затем вызывает внутренние методы validateManifest() и validateSignature() . Оба метода должны возвращать true.

 <?php foreach ($files as $file) { ... // Check that each file is present in the manifest if (!array_key_exists((string)$file, $manifestData)) { throw new Exception("No entry in manifest for file '{$file}'"), 506); } // Check SHA1 hash for each file $hash = sha1_file($file->getPathname()); if ($hash == false || $hash != $manifestData[(string)$file]) { throw new Exception( sprintf( "For file %s, manifest's listed SHA1 hash %s doesn't match computed hash, %s", $file, $manifestData[(string)$file], $hash ), 507 ); } } 

Манифест действителен, если каталог проходов содержит только файлы, объявленные в манифесте, сам манифест и файл подписи. Если файл отсутствует или файлов нет в списке, значит, пароль недействителен. Кроме того, хэш SHA1 для каждого файла должен совпадать с хешем, сохраненным в манифесте.

Подпись также проверяется с помощью openssl в три этапа. Сначала мы проверяем содержимое манифеста с его подписью. Опция -noverify указывает OpenSSL пропустить проверку сертификата подписавшего.

 $verifyCommand = sprintf( 'openssl smime -verify -binary -inform DER -in %s -content %s -noverify 2> /dev/null', $signaturePath, $manifestPath ); 

Затем сертификаты извлекаются из подписи:

 <?php $exportCommand = sprintf( 'openssl smime -pk7out -in %s -content %s -inform DER -outform PEM', $signaturePath, $manifestPath ); 

Вывод команды передается во внутренний parseCertificates() который я не буду здесь обсуждать. Он использует сочетание OpenSSL и регулярных выражений для извлечения сертификатов из заданных данных в массив ассоциативных массивов, который используется для цикла проверки сертификатов:

 <?php $certs = self::parseCertificates($pemData); $foundWWDRCert = false; for ($i = 0; $i < count($certs); $i++) { $commonName = $certs[$i]['subject']['CN']; if ($commonName == APPLE_CERTIFICATE_CN) { $foundWWDRCert = true; } } 

Контроллер индекса

Контроллер index.php отвечает за предоставление как веб-приложения с графическим интерфейсом, так и RESTful API. Он состоит из двух URI: домашней страницы через GET и той же страницы через POST. Первая страница отображает только пустую форму, но вторая более интересна.

Чтобы убедиться, что каждый адрес электронной почты уникален, он спрашивает Париж, который в свою очередь проверяет базу данных:

 <?php $subscriber = Model::factory('MyPassbookSubscriber') ->where_equal('email', $memberMail) ->find_one(); if ($subscriber !== false) { $errors['membermail'] = sprintf("The email address '%s' is not available", $memberMail); } 

URL-адрес подписчика выбирается из Gravatar:

 <?php if (empty($errors['membermail'])) { $memberThumbnail = "https://www.gravatar.com/avatar/" . md5(strtolower(trim($memberMail))) . "?s=60"; } 

И теперь он может создать нового подписчика:

 <?php try { $subscriber = Model::factory('MyPassbookSubscriber')->create( array( 'name' => $memberName, 'email' => $memberMail, 'created' => date('Ymd H:i:s', $memberSubscription), 'function' => $memberFavFunction, 'picture' => $memberThumbnail ) ); $subscriber->save(); } catch (Exception $e) { $errors['subscriber'] = "Error creating subscriber profile"; } 

Файл шаблона JSON прохода загружается в $data и все заполнители заполняются реальными данными пользователя и приложения:

 <?php $data['serialNumber'] = $subscriber->id; $data['webServiceURL'] = sprintf('https://%s/%s/', $env['SERVER_NAME'], $app->request()->getRootUri()); $data['authenticationToken'] = md5($subscriber->id); $data['barcode']['message'] = $subscriber->id; $data['generic']['primaryFields'][0]['value'] = $subscriber->name; $data['generic']['secondaryFields'][0]['value'] = date('Y', $memberSubscription); $data['generic']['auxiliaryFields'][0]['value'] = $subscriber->function . '()'; $data['generic']['backFields'][0]['value'] = $subscriber->id; $data['generic']['backFields'][1]['value'] = $subscriber->created; $data['generic']['backFields'][2]['value'] = $subscriber->email; 

Используя методы пользовательской модели, новый $pass создается с помощью $subscriber->createPass() и, если все правильно, $pass может упаковать себя.

 <?php if ($pass = $subscriber->createPass($app->config('passes.passType'), $data)) { $pass->pack( $app->config('passes.path'), $app->config('passes.store'), $app->config('passes.certfile'), $app->config('passes.certpass') ); ... } else { $errors['pass'] = 'Unable to create pass'; } - <?php if ($pass = $subscriber->createPass($app->config('passes.passType'), $data)) { $pass->pack( $app->config('passes.path'), $app->config('passes.store'), $app->config('passes.certfile'), $app->config('passes.certpass') ); ... } else { $errors['pass'] = 'Unable to create pass'; } 

Метод pack() прост: он создает временную копию каталога шаблонов, добавляет специфичные для прохода файлы и вызывает PassSigner::signPass() в рабочем каталоге. Вновь созданный проход затем отправляется по электронной почте, а pass.php шаблона pass.php представляется пользователю по ссылке для скачивания.

Собираюсь отдыхать

Поскольку URL-адрес веб-службы встроен в проход, спецификации Apple говорят, что мы должны реализовать как минимум две конечные точки для регистрации и отмены регистрации устройств. URI всегда / v1 / devices /: deviceId / registrations /: passTypeId /: serialNo и вызывается через POST для регистрации и DELETE для отмены регистрации.

Идентификатор устройства, идентификатор типа пропуска и серийный номер пропуска принимаются из URI, а токен аутентификации находится в заголовке авторизации. Авторизационный токен должен присутствовать в заголовке и быть связан с передачей для успешного завершения, а ответ 401 должен быть возвращен в случае неавторизованного доступа.

В процессе регистрации Push-токен отправляется как тело запроса в формате JSON: {"pushToken": <pushToken> } . Процесс регистрации создает новый объект Device в базе данных и заполняет предоставленные поля Device ID и Push Token. Затем создается объект DevicePass для хранения отношений между устройством и проходом.

Если нам нужно обновить проход, мы используем идентификатор устройства и push-токен, чтобы отправить запрос push-уведомления на Apple Push-сервер (и, очевидно, также необходимо реализовать механизмы обновления).

URI незарегистрированного получает все те же данные, кроме Push-токена. Все ссылки для данного прохода удаляются, включая устройства и связи.

Вывод

Мы многое рассмотрели в этой статье, и еще есть что исследовать об управлении Pass. Не стесняйтесь начинать с библиотеки PassSigner и строить поверх нее. Вы можете легко создавать всевозможные приложения Pass для себя или своих клиентов. Удачного кодирования!

Изображение через Fotolia