Pull to refresh

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

Reading time 13 min
Views 36K
Платежи через 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%. А подавляющим большинством случаев невозможности продления подписки являются случаи отсутсвия денежных средств на счетах пользователей
Tags:
Hubs:
+28
Comments 33
Comments Comments 33

Articles