Платежи через 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 AppStoreClientAppStoreClient();
$AppStore->setPassword('secret shared password')
->setSandbox((bool) mt_rand(0,1));
$Status = $AppStore->verifyReceipt('5t4TUs==');
iTunes вернет нам данные ответа в следующем виде
object(AppStoreClientResponseRenewableStatus)#7 (4) {
["latestReceipt":"AppStoreClientResponseRenewableStatus":private]=>
string(3460) "5t4TUs=="
["LatestReceiptInfo":"AppStoreClientResponseRenewableStatus":private]=>
object(AppStoreClientResponseRenewableReceipt)#8 (11) {
["expiresDate":"AppStoreClientResponseRenewableReceipt":private]=>
string(13) "1363547483000"
["quantity":"AppStoreClientResponseReceipt":private]=>
int(1)
["productId":"AppStoreClientResponseReceipt":private]=>
string(35) "com.example.application.product.2"
["transactionId":"AppStoreClientResponseReceipt":private]=>
string(15) "0987654321"
["purchaseDate":"AppStoreClientResponseReceipt":private]=>
string(27) "2013-02-18 20:11:23 Etc/GMT"
["originalTransactionId":"AppStoreClientResponseReceipt":private]=>
string(15) "9078563412"
["originalPurchaseDate":"AppStoreClientResponseReceipt":private]=>
string(27) "2013-01-18 20:11:25 Etc/GMT"
["appItemId":"AppStoreClientResponseReceipt":private]=>
string(9) "456"
["versionExternalIdentifier":"AppStoreClientResponseReceipt":private]=>
string(0) ""
["bid":"AppStoreClientResponseReceipt":private]=>
string(19) "com.example.application"
["bvrs":"AppStoreClientResponseReceipt":private]=>
string(3) "123"
}
["status":"AppStoreClientResponseStatus":private]=>
int(0)
["Receipt":"AppStoreClientResponseStatus":private]=>
object(AppStoreClientResponseRenewableReceipt)#9 (11) {
["expiresDate":"AppStoreClientResponseRenewableReceipt":private]=>
string(13) "1363547483000"
["quantity":"AppStoreClientResponseReceipt":private]=>
int(1)
["productId":"AppStoreClientResponseReceipt":private]=>
string(35) "com.example.application.product.2"
["transactionId":"AppStoreClientResponseReceipt":private]=>
string(15) "0987654321"
["purchaseDate":"AppStoreClientResponseReceipt":private]=>
string(27) "2013-02-18 20:11:23 Etc/GMT"
["originalTransactionId":"AppStoreClientResponseReceipt":private]=>
string(15) "9078563412"
["originalPurchaseDate":"AppStoreClientResponseReceipt":private]=>
string(27) "2013-01-18 20:11:25 Etc/GMT"
["appItemId":"AppStoreClientResponseReceipt":private]=>
string(9) "456"
["versionExternalIdentifier":"AppStoreClientResponseReceipt":private]=>
string(0) ""
["bid":"AppStoreClientResponseReceipt":private]=>
string(19) "com.example.application"
["bvrs":"AppStoreClientResponseReceipt":private]=>
string(3) "123"
}
}
В отличие от подписок Google Play, iTunes создает новый рецепт подписки на каждый период оплаты. Примерно за сутки до начала следующего платежного периода, iTunes пытается снять деньги со счета пользователя, хотя я видел жалобу, что деньги за продление подписки были списаны за 48 часов до начала нового платежного периода. Если попытка ещё не проводилась и пока не прошла успешно данные, представленные в latest_receipt совпадают с данными исходного рецепта, как в примере выше. В случае успешного продления подписки, данные автоматической покупки будут представлены в поле latest_receipt_info, закодированный рецепт в поле latest_receipt
object(AppStoreClientResponseRenewableStatus)#7 (4) {
["latestReceipt":"AppStoreClientResponseRenewableStatus":private]=>
string(3460) "ReNEW481E5t4TUs=="
["LatestReceiptInfo":"AppStoreClientResponseRenewableStatus":private]=>
object(AppStoreClientResponseRenewableReceipt)#8 (11) {
["expiresDate":"AppStoreClientResponseRenewableReceipt":private]=>
string(13) "1363547483000"
["quantity":"AppStoreClientResponseReceipt":private]=>
int(1)
["productId":"AppStoreClientResponseReceipt":private]=>
string(35) "com.example.application.product.2"
["transactionId":"AppStoreClientResponseReceipt":private]=>
string(15) "0987654321"
["purchaseDate":"AppStoreClientResponseReceipt":private]=>
string(27) "2013-02-18 20:11:23 Etc/GMT"
["originalTransactionId":"AppStoreClientResponseReceipt":private]=>
string(15) "9078563412"
["originalPurchaseDate":"AppStoreClientResponseReceipt":private]=>
string(27) "2013-01-18 20:11:25 Etc/GMT"
["appItemId":"AppStoreClientResponseReceipt":private]=>
string(9) "456"
["versionExternalIdentifier":"AppStoreClientResponseReceipt":private]=>
string(0) ""
["bid":"AppStoreClientResponseReceipt":private]=>
string(19) "com.example.application"
["bvrs":"AppStoreClientResponseReceipt":private]=>
string(3) "123"
}
["status":"AppStoreClientResponseStatus":private]=>
int(0)
["Receipt":"AppStoreClientResponseStatus":private]=>
object(AppStoreClientResponseRenewableReceipt)#9 (11) {
["expiresDate":"AppStoreClientResponseRenewableReceipt":private]=>
string(13) "1361131883894"
["quantity":"AppStoreClientResponseReceipt":private]=>
int(1)
["productId":"AppStoreClientResponseReceipt":private]=>
string(35) "com.example.application.product.2"
["transactionId":"AppStoreClientResponseReceipt":private]=>
string(15) "0987654312"
["purchaseDate":"AppStoreClientResponseReceipt":private]=>
string(27) "2013-01-18 20:11:23 Etc/GMT"
["originalTransactionId":"AppStoreClientResponseReceipt":private]=>
string(15) "9078563412"
["originalPurchaseDate":"AppStoreClientResponseReceipt":private]=>
string(27) "2013-01-18 20:11:25 Etc/GMT"
["appItemId":"AppStoreClientResponseReceipt":private]=>
string(9) "456"
["versionExternalIdentifier":"AppStoreClientResponseReceipt":private]=>
string(0) ""
["bid":"AppStoreClientResponseReceipt":private]=>
string(19) "com.example.application"
["bvrs":"AppStoreClientResponseReceipt":private]=>
string(3) "123"
}
}
В случае, если продлить подписку не представилось возможным, возвращается статус ответа 21006
$AppStore = new AppStoreClientAppStoreClient();
$AppStore->setPassword('secret shared password')
->setSandbox((bool) mt_rand(0,1));
try {
$Status = $AppStore->verifyReceipt('ExP1ReD5t4TUs==');
} catch (AppStoreClientResponseExpiredSubscriptionException $ex) {
var_dump($ex->getStatus());
}
object(AppStoreClientResponseRenewableStatus)#7 (4) {
["latestReceipt":"AppStoreClientResponseRenewableStatus":private]=>
string(0) ""
["LatestReceiptInfo":"AppStoreClientResponseRenewableStatus":private]=>
NULL
["status":"AppStoreClientResponseStatus":private]=>
int(21006)
["Receipt":"AppStoreClientResponseStatus":private]=>
object(AppStoreClientResponseRenewableReceipt)#8 (11) {
["expiresDate":"AppStoreClientResponseRenewableReceipt":private]=>
string(13) "1361208738953"
["quantity":"AppStoreClientResponseReceipt":private]=>
int(1)
["productId":"AppStoreClientResponseReceipt":private]=>
string(35) "com.example.application.product.2"
["transactionId":"AppStoreClientResponseReceipt":private]=>
string(15) "2143658709"
["purchaseDate":"AppStoreClientResponseReceipt":private]=>
string(27) "2013-01-18 17:32:18 Etc/GMT"
["originalTransactionId":"AppStoreClientResponseReceipt":private]=>
string(15) "2143658709"
["originalPurchaseDate":"AppStoreClientResponseReceipt":private]=>
string(27) "2013-01-18 17:32:19 Etc/GMT"
["appItemId":"AppStoreClientResponseReceipt":private]=>
string(9) "456"
["versionExternalIdentifier":"AppStoreClientResponseReceipt":private]=>
string(0) ""
["bid":"AppStoreClientResponseReceipt":private]=>
string(19) "com.example.application"
["bvrs":"AppStoreClientResponseReceipt":private]=>
string(3) "123"
}
}
Предлагаю следующую схему обработки подписок iTunes на стороне сервера:
Описание:
- buy — покупка подписки на стороне клиента
- verify — верификация данных подписки на стороне сервера по алгоритму, предложенному выше
- queue — очередь данных верифицированных подписок
- periodical verification — периодическая проверка подписок. Если подписка была продлена, записываем обновленный рецепт обратно в очередь для последующих проверок
По моим данным ~60% подписок iTunes продлевается. Для подписок Google Play эта величина составляет ~40%. А подавляющим большинством случаев невозможности продления подписки являются случаи отсутсвия денежных средств на счетах пользователей
Автор: alxmsl