iTunes In-App Purchases со стороны сервера

от автора

Платежи через iTunes фактические лидеры по монетизации контента, предоставляемого мобильными приложениями. В одном из известных мне приложений доход от них в 3 раза превышает доход от Google Play пользователей при том, что посещаемость последних в 1.5 раза выше. Таким образом, с одного пользователя iTunes можно получить вплоть до 5 раз больше денег, чем с одного пользователя Google Play. Данный аргумент достаточен для интеграции платежей iTunes в мобильные приложения.

В данной статье описываются некоторые особенности верификации платежей iTunes (в т.ч. и подписок) с серверной стороны, которые, как мне показалось, не достаточно освещены в существующих статьях.

В соответствии с руководством разработчика предлагается две схемы верификации платежных транзакций: простая, при которой подтверждение транзакции происходит в результате взаимодействия мобильного приложения и App Store, и сложная. Во втором случае вводится дополнительный этап подтверждения с собственного сервера посредством обращения к сервису iTunes Connect. Факт успешного подтверждения платежной транзакции через iTunes Connect считается достаточным для верификации платежа.
К минусам простой верификации можно отнести подорванное доверие. К плюсам сложной относятся удобство работы с подписками, возможность начисления мирских благ и хранения перечня продуктов на стороне сервера. Последние два пункта особенно актуальны, когда приходится ждать обновления приложения в App Store неделю. А может и несколько недель, если вдруг решите порадовать пользователей соблазнительным продуктом в предверии иноверного рождества. О безопасности я даже не говорю — всё достаточно наглядно на следующем графике:

Так в системе мониторинга платежных запросов абстрактного приложения может выглядеть вполне рядовые сутки. Синим цветом представлено общее количество запросов на верификацию платежа. Зеленым — запросы, которые реально прошли через App Store. А красным — вредоносные запросы. Страшно представить, какую упущенную выгоду может получить приложение, если будет игнорировать серверное подтверждение платежа. Процентое отношение данных из графика представлено в следующей таблице:

Особенность запроса Процент
Неподтверждаемые. Фальшивые платежи, визуально состоящие из данных, похожих на корректные. Но может в них поле какое отсутствует, число строкой представлено или ещё какая-нибудь отличительная особенность, никак не позволяющая верифицировать платеж 0.7%
Повторы. Запросы со стороны клиента с верифицированным платежем, но присланные повторно через какое-то время 1%
Платежи крекеров (типа, iAP Cracker и т.п.). Посылают на верификацию платежи, сформулированные для подтверждения ими же самими 9.3%
Поддельные. Верифицируемые через iTunes платежи других приложений 79%
Подтвержденные. Реально честные покупки. Их цифры сходятся с цифрами покупок через аккаунт 10%

На самом деле, большинство вредоносных запросов можно определить собственными силами без траты траффика на обращение к сервису верификации. Платеж iTunes предсталвляется т.н. рецепт. Рецепт — это кодированный в base64 JSON-объект данных платежной транзакции. Для верификации платежа или подписки через сервис App Store нужно передать их рецепт, который сообщает клиентское приложение. В ответ получите статус рецепта и некоторые данные платежа.

Рассмотрим корректный рецепт (здесь и далее данные корректных рецептов слегка изменены):

$ php -r "var_dump(base64_decode('Re4LRece1PT='));" string(2453) "{ 	"signature" = "8iN4rY5iGNaTUrE=="; 	"purchase-info" = "PuRCh45e1nf0RM4tIoN=="; 	"pod" = "22"; 	"signing-status" = "0"; }" $ php -r "var_dump(base64_decode('PuRCh45e1nf0RM4tIoN=='));" string(784) "{ 	"original-purchase-date-pst" = "2013-02-18 10:05:51 America/Los_Angeles"; 	"purchase-date-ms" = "1361210751012"; 	"unique-identifier" = "aun1que1dent1f1er"; 	"original-transaction-id" = "1234567890"; 	"bvrs" = "220"; 	"app-item-id" = "123"; 	"transaction-id" = "1234567890"; 	"quantity" = "1"; 	"original-purchase-date-ms" = "1361210751012"; 	"unique-vendor-identifier" = "VEND0R-1DENT1F1ER"; 	"item-id" = "456"; 	"version-external-identifier" = "789"; 	"product-id" = "com.example.application.product.1"; 	"purchase-date" = "2013-02-18 18:05:51 Etc/GMT"; 	"original-purchase-date" = "2013-02-18 18:05:51 Etc/GMT"; 	"bid" = "com.example.application"; 	"purchase-date-pst" = "2013-02-18 10:05:51 America/Los_Angeles"; }" 

Рецепт состоит из данных покупки, подписи и пары служебных полей. Подпись бинарна и закодирована base64. Данные покупки также закодированы и представляют собой JSON-объект с множеством полей. Наиболее интересными считаю два поля: product-id — идентификатор приобретаемого продукта и bid — идентификатор приложения.

Лидеры выборки вредоносных запросов — поддельные запросы — выглядят примерно так:

$ php -r "var_dump(base64_decode('CHuZH0iRECE1pt=='));" string(2281) "{ 	"signature" = "8iN4rY5iGNaTUrE=="; 	"purchase-info" = "4n0THeRPuRCh45e1nf0RM4tIoN=="; 	"pod" = "17"; 	"signing-status" = "0"; }" $ php -r "var_dump(base64_decode('4n0THeRPuRCh45e1nf0RM4tIoN=='));" string(656) "{ 	"original-purchase-date-pst" = "2012-07-12 05:54:35 America/Los_Angeles"; 	"purchase-date-ms" = "1342097675882"; 	"original-transaction-id" = "170000029449420"; 	"bvrs" = "1.4"; 	"app-item-id" = "450542233"; 	"transaction-id" = "170000029449420"; 	"quantity" = "1"; 	"original-purchase-date-ms" = "1342097675882"; 	"item-id" = "534185042"; 	"version-external-identifier" = "9051236"; 	"product-id" = "com.zeptolab.ctrbonus.superpower1"; 	"purchase-date" = "2012-07-12 12:54:35 Etc/GMT"; 	"original-purchase-date" = "2012-07-12 12:54:35 Etc/GMT"; 	"bid" = "com.zeptolab.ctrexperiments"; 	"purchase-date-pst" = "2012-07-12 05:54:35 America/Los_Angeles"; }" 

Вполне приличный рецепт. Только не от нашего приложения. Если выполнить обращение к iTunes Connect, получим подтверждение данного платежа:

$ wget 'https://buy.itunes.apple.com/verifyReceipt' -q --post-data='{"receipt-data":"CHuZH0iRECE1pt=="}' -O - {"receipt":{"original_purchase_date_pst":"2012-07-12 05:54:35 America/Los_Angeles", "purchase_date_ms":"1342097675882", "original_transaction_id":"170000029449420", "original_purchase_date_ms":"1342097675882", "app_item_id":"450542233", "transaction_id":"170000029449420", "quantity":"1", "bvrs":"1.4", "version_external_identifier":"9051236", "bid":"com.zeptolab.ctrexperiments", "product_id":"com.zeptolab.ctrbonus.superpower1", "purchase_date":"2012-07-12 12:54:35 Etc/GMT", "purchase_date_pst":"2012-07-12 05:54:35 America/Los_Angeles", "original_purchase_d 

В принципе, могли бы не проверять. Можно съэкономить 80% траффика к iTunes путем сравнения product-id и bid с допустимыми в нашем приложении ещё на стадии получения рецепта от клиентского приложения.

Рецепты, создаваемые крекерами довольно-таки примитивны: Y29tLnVydXMuaWFwLjk2NjU3Mjkw. Дешифруем, получаем com.urus.iap.96657290. Очевидно, что здесь ни о какой структуре рецепта даже речи не идет — ни подписи, ни данных покупки. Подобные рецепты можно смело отвергать. iTunes на такой рецепт вернет ошибку 21002.

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

Cамое малое зло из выборки — неподтверждаемые рецепты. Ниже представлен пример одного:

$ php -r "var_dump(base64_decode('P0dDe1NyRECE1pt=='));" string(613) "{"signing-status"="0";"purchase-info"="P0dDe1N0e1NF0==";"pid"="143";"signature"="1POdP1sD4jEe5t=";}" $ php -r "var_dump(base64_decode('P0dDe1N0e1NF0=='));" string(388) "{"unique-identifier"="an0theru1que1dent1f1er";"purchase-date"="2012-02-18 19:23:27 Etc/GMT";"original-transaction-id"="0123456789";"quantity"="1";"original-purchase-date"="2012-02-18 19:23:27 Etc/GMT";"bvrs"="123";"product-id"="com.example.application.product.1";"item-id"="456";"transaction-id"="0123456789";"bid"="com.example.application";}" 

По сравнению с корректным рецептом, в данном случае замента экономия на пробелах, но это не повод отвергать рецепт — ведь его составляет и кодирует клиентское приложение. А так рецепт выглядит корректно: есть правильные идентификаторы продукта и приложения, правдоподобные данные платежа, подпись. Нужно посылать запрос в iTunes (хорошо, что таких запросов всего 0.7% от общего числа и 7% от числа полезных запросов). iTunes ответит кодом 21002.

На картинке ниже представлен алгоритм верификации рецептов, полученных от клиентского приложения, на стороне собственного сервера:

Непосредственно для верификации через iTunes предлагаю для использования небольшую и удобную библиотеку. Данная библиотека позволяет верифицировать в том числе и обновляемые подписки. Запросить информацию по подписке можно тем же запросом верификации, указав при инициализации клиента секретный пароль.

$AppStore = new \AppStore\Client\AppStoreClient(); $AppStore->setPassword('secret shared password')     ->setSandbox((bool) mt_rand(0,1)); $Status = $AppStore->verifyReceipt('5t4TUs=='); 

iTunes вернет нам данные ответа в следующем виде

object(AppStore\Client\Response\RenewableStatus)#7 (4) {   ["latestReceipt":"AppStore\Client\Response\RenewableStatus":private]=>   string(3460) "5t4TUs=="   ["LatestReceiptInfo":"AppStore\Client\Response\RenewableStatus":private]=>   object(AppStore\Client\Response\RenewableReceipt)#8 (11) {     ["expiresDate":"AppStore\Client\Response\RenewableReceipt":private]=>     string(13) "1363547483000"     ["quantity":"AppStore\Client\Response\Receipt":private]=>     int(1)     ["productId":"AppStore\Client\Response\Receipt":private]=>     string(35) "com.example.application.product.2"     ["transactionId":"AppStore\Client\Response\Receipt":private]=>     string(15) "0987654321"     ["purchaseDate":"AppStore\Client\Response\Receipt":private]=>     string(27) "2013-02-18 20:11:23 Etc/GMT"     ["originalTransactionId":"AppStore\Client\Response\Receipt":private]=>     string(15) "9078563412"     ["originalPurchaseDate":"AppStore\Client\Response\Receipt":private]=>     string(27) "2013-01-18 20:11:25 Etc/GMT"     ["appItemId":"AppStore\Client\Response\Receipt":private]=>     string(9) "456"     ["versionExternalIdentifier":"AppStore\Client\Response\Receipt":private]=>     string(0) ""     ["bid":"AppStore\Client\Response\Receipt":private]=>     string(19) "com.example.application"     ["bvrs":"AppStore\Client\Response\Receipt":private]=>     string(3) "123"   }   ["status":"AppStore\Client\Response\Status":private]=>   int(0)   ["Receipt":"AppStore\Client\Response\Status":private]=>   object(AppStore\Client\Response\RenewableReceipt)#9 (11) {     ["expiresDate":"AppStore\Client\Response\RenewableReceipt":private]=>     string(13) "1363547483000"     ["quantity":"AppStore\Client\Response\Receipt":private]=>     int(1)     ["productId":"AppStore\Client\Response\Receipt":private]=>     string(35) "com.example.application.product.2"     ["transactionId":"AppStore\Client\Response\Receipt":private]=>     string(15) "0987654321"     ["purchaseDate":"AppStore\Client\Response\Receipt":private]=>     string(27) "2013-02-18 20:11:23 Etc/GMT"     ["originalTransactionId":"AppStore\Client\Response\Receipt":private]=>     string(15) "9078563412"     ["originalPurchaseDate":"AppStore\Client\Response\Receipt":private]=>     string(27) "2013-01-18 20:11:25 Etc/GMT"     ["appItemId":"AppStore\Client\Response\Receipt":private]=>     string(9) "456"     ["versionExternalIdentifier":"AppStore\Client\Response\Receipt":private]=>     string(0) ""     ["bid":"AppStore\Client\Response\Receipt":private]=>     string(19) "com.example.application"     ["bvrs":"AppStore\Client\Response\Receipt":private]=>     string(3) "123"   } } 

В отличие от подписок Google Play, iTunes создает новый рецепт подписки на каждый период оплаты. Примерно за сутки до начала следующего платежного периода, iTunes пытается снять деньги со счета пользователя, хотя я видел жалобу, что деньги за продление подписки были списаны за 48 часов до начала нового платежного периода. Если попытка ещё не проводилась и пока не прошла успешно данные, представленные в latest_receipt совпадают с данными исходного рецепта, как в примере выше. В случае успешного продления подписки, данные автоматической покупки будут представлены в поле latest_receipt_info, закодированный рецепт в поле latest_receipt

object(AppStore\Client\Response\RenewableStatus)#7 (4) {   ["latestReceipt":"AppStore\Client\Response\RenewableStatus":private]=>   string(3460) "ReNEW481E5t4TUs=="   ["LatestReceiptInfo":"AppStore\Client\Response\RenewableStatus":private]=>   object(AppStore\Client\Response\RenewableReceipt)#8 (11) {     ["expiresDate":"AppStore\Client\Response\RenewableReceipt":private]=>     string(13) "1363547483000"     ["quantity":"AppStore\Client\Response\Receipt":private]=>     int(1)     ["productId":"AppStore\Client\Response\Receipt":private]=>     string(35) "com.example.application.product.2"     ["transactionId":"AppStore\Client\Response\Receipt":private]=>     string(15) "0987654321"     ["purchaseDate":"AppStore\Client\Response\Receipt":private]=>     string(27) "2013-02-18 20:11:23 Etc/GMT"     ["originalTransactionId":"AppStore\Client\Response\Receipt":private]=>     string(15) "9078563412"     ["originalPurchaseDate":"AppStore\Client\Response\Receipt":private]=>     string(27) "2013-01-18 20:11:25 Etc/GMT"     ["appItemId":"AppStore\Client\Response\Receipt":private]=>     string(9) "456"     ["versionExternalIdentifier":"AppStore\Client\Response\Receipt":private]=>     string(0) ""     ["bid":"AppStore\Client\Response\Receipt":private]=>     string(19) "com.example.application"     ["bvrs":"AppStore\Client\Response\Receipt":private]=>     string(3) "123"   }   ["status":"AppStore\Client\Response\Status":private]=>   int(0)   ["Receipt":"AppStore\Client\Response\Status":private]=>   object(AppStore\Client\Response\RenewableReceipt)#9 (11) {     ["expiresDate":"AppStore\Client\Response\RenewableReceipt":private]=>     string(13) "1361131883894"     ["quantity":"AppStore\Client\Response\Receipt":private]=>     int(1)     ["productId":"AppStore\Client\Response\Receipt":private]=>     string(35) "com.example.application.product.2"     ["transactionId":"AppStore\Client\Response\Receipt":private]=>     string(15) "0987654312"     ["purchaseDate":"AppStore\Client\Response\Receipt":private]=>     string(27) "2013-01-18 20:11:23 Etc/GMT"     ["originalTransactionId":"AppStore\Client\Response\Receipt":private]=>     string(15) "9078563412"     ["originalPurchaseDate":"AppStore\Client\Response\Receipt":private]=>     string(27) "2013-01-18 20:11:25 Etc/GMT"     ["appItemId":"AppStore\Client\Response\Receipt":private]=>     string(9) "456"     ["versionExternalIdentifier":"AppStore\Client\Response\Receipt":private]=>     string(0) ""     ["bid":"AppStore\Client\Response\Receipt":private]=>     string(19) "com.example.application"     ["bvrs":"AppStore\Client\Response\Receipt":private]=>     string(3) "123"   } } 

В случае, если продлить подписку не представилось возможным, возвращается статус ответа 21006

$AppStore = new \AppStore\Client\AppStoreClient(); $AppStore->setPassword('secret shared password')     ->setSandbox((bool) mt_rand(0,1)); try {     $Status = $AppStore->verifyReceipt('ExP1ReD5t4TUs=='); } catch (\AppStore\Client\Response\ExpiredSubscriptionException $ex) {     var_dump($ex->getStatus()); } 
object(AppStore\Client\Response\RenewableStatus)#7 (4) {   ["latestReceipt":"AppStore\Client\Response\RenewableStatus":private]=>   string(0) ""   ["LatestReceiptInfo":"AppStore\Client\Response\RenewableStatus":private]=>   NULL   ["status":"AppStore\Client\Response\Status":private]=>   int(21006)   ["Receipt":"AppStore\Client\Response\Status":private]=>   object(AppStore\Client\Response\RenewableReceipt)#8 (11) {     ["expiresDate":"AppStore\Client\Response\RenewableReceipt":private]=>     string(13) "1361208738953"     ["quantity":"AppStore\Client\Response\Receipt":private]=>     int(1)     ["productId":"AppStore\Client\Response\Receipt":private]=>     string(35) "com.example.application.product.2"     ["transactionId":"AppStore\Client\Response\Receipt":private]=>     string(15) "2143658709"     ["purchaseDate":"AppStore\Client\Response\Receipt":private]=>     string(27) "2013-01-18 17:32:18 Etc/GMT"     ["originalTransactionId":"AppStore\Client\Response\Receipt":private]=>     string(15) "2143658709"     ["originalPurchaseDate":"AppStore\Client\Response\Receipt":private]=>     string(27) "2013-01-18 17:32:19 Etc/GMT"     ["appItemId":"AppStore\Client\Response\Receipt":private]=>     string(9) "456"     ["versionExternalIdentifier":"AppStore\Client\Response\Receipt":private]=>     string(0) ""     ["bid":"AppStore\Client\Response\Receipt":private]=>     string(19) "com.example.application"     ["bvrs":"AppStore\Client\Response\Receipt":private]=>     string(3) "123"   } } 

Предлагаю следующую схему обработки подписок iTunes на стороне сервера:

Описание:

  • buy — покупка подписки на стороне клиента
  • verify — верификация данных подписки на стороне сервера по алгоритму, предложенному выше
  • queue — очередь данных верифицированных подписок
  • periodical verification — периодическая проверка подписок. Если подписка была продлена, записываем обновленный рецепт обратно в очередь для последующих проверок

По моим данным ~60% подписок iTunes продлевается. Для подписок Google Play эта величина составляет ~40%. А подавляющим большинством случаев невозможности продления подписки являются случаи отсутсвия денежных средств на счетах пользователей

ссылка на оригинал статьи http://habrahabr.ru/post/162335/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *