В данной статье описываются некоторые особенности верификации платежей 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/
Добавить комментарий