Cordovaで課金処理を行うためのプラグイン、cordova-plugin-purchaseには各プラットフォームのレシートを検証するためのAPI呼び出し処理を追加することができます。
https://github.com/j3k0/cordova-plugin-purchase/blob/master/doc/api.md#receipt-validation
このレシート検証APIをLaravelで作ってみます。
課金を実装したCordovaアプリ自体の作成はこちらの記事でまとめています。
Cordova(Monaca)でアプリ内課金を実装する
APIのリクエスト、レスポンス仕様
プラグイン側でリクエストレスポンスの仕様が決められています。
https://github.com/j3k0/cordova-plugin-purchase/blob/master/doc/api.md#validator
リクエスト
URL
好きなURLを設定できます。
標準以外のパラメータを渡したい場合はパスパラメータなどを利用しましょう。
メソッド
POST
リクエストボディ
{
"additionalData" : null,
"alias" : "monthly1",
"currency" : "USD",
"description" : "Monthly subscription",
"id" : "subscription.monthly",
"loaded" : true,
"price" : "$12.99",
"priceMicros" : 12990000,
"state" : "approved",
"title" : "The Monthly Subscription Title",
"transaction" : { // 各ストアのレシート情報が入る },
"type" : "paid subscription",
"valid" : true
}
transactionの中身は各ストア事にこのようになります。
https://github.com/j3k0/cordova-plugin-purchase/blob/master/doc/api.md#transactions
iOS
"transaction" : {
"appStoreReceipt":"appStoreReceiptString", // BASE64エンコーディングされたレシート情報
"id" : "idString", // トランザクションID
"original_transaction_id":"transactionIdString", // 購読型の時にセットされる
"type": "ios-appstore" // ストアの識別
}
Android
"transaction" : {
"developerPayload" : undefined, // オプション
"id" : "idString", // トランザクションID
"purchaseToken" : "purchaseTokenString",
// レシート情報
"receipt" : "{\"autoRenewing\":true,\"orderId\":\"orderIdString\",\"packageName\":\"com.mycompany\",\"purchaseTime\":1555217574101,\"purchaseState\":0,\"purchaseToken\":\"purchaseTokenString\"}",
"signature" : "signatureString", // 署名
"type": "android-playstore" // ストアの識別
}
レスポンス
返すレスポンスによって呼び出し元に戻った時に.verified()
に入るか.unverified()
に入るか決まります。
成功(verified)
レスポンスコード
200
レスポンスボディ
{
"ok" : true,
"data" : {
"transaction" : { // リクエストボディのトランザクションをセット }
}
}
失敗(unverified)
レスポンスコード
200または200以外でも失敗と判断される
レスポンスボディ
{
"ok" : false,
"data" : { // エラーコード
"code" : 6778003
},
"error" : { // エラーメッセージ。好きに設定できる。
"message" : "The subscription is expired."
}
}
アプリ側でハンドルするためにエラーコードは以下が定義されています。
store.INVALID_PAYLOAD = 6778001;
store.CONNECTION_FAILED = 6778002;
store.PURCHASE_EXPIRED = 6778003;
store.PURCHASE_CONSUMED = 6778004;
store.INTERNAL_ERROR = 6778005;
store.NEED_MORE_DATA = 6778006;
消耗型課金の検証
消耗型(Androidなら消費型)の課金に関して、各プラットフォーム側でレシートの検証方法が用意されています。
プラットフォームごとの検証
iOS(App Store)
iOSの場合はApp Storeの用意するAPIにレシートを送信することで検証することができます。
https://developer.apple.com/documentation/appstorereceipts/verifyreceipt
環境 | エンドポイント | メソッド |
---|---|---|
Sandbox | https://sandbox.itunes.apple.com/verifyReceipt | POST |
Production | https://buy.itunes.apple.com/verifyReceipt | POST |
Sandboxアカウントで発行したレシートはSandboxのエンドポイントに送信しなければいけません。
本番のエンドポイントにSandboxのレシートを送信すると「{"status":21007}
」が返ります。
レスポンスのステータスが0なら正しいレシート。
戻されたバンドルIDが正しいかなどのチェックを追加で行います。
Android(Google Play)
Androidの場合は、レシートの署名(signature)を検証することで、レシートが改ざんされていないかチェックできます。
リクエストで送られてきたreceipt
とsignature
をGoogle Play Consoleから取得できるRSA公開鍵で検証します。
公開鍵のBase64文字列はGoogle Play Consoleから取得します。
取得した文字列をpublic.txt
に保存して下記コマンドを実行してPEM形式にします。
$ base64 -d public.txt > public.der
$ openssl rsa -inform DER -outform PEM -pubin -in public.der -out public.pem
writing RSA key
$ cat public.pem
-----BEGIN PUBLIC KEY-----
****************************************************************
****************************************************************
****************************************************************
****************************************************************
****************************************************************
****************************************************************
********
-----END PUBLIC KEY-----
これで検証が行えます。
その他の検証
その他にも以下を検証しておいた方がよさそうです。
- バンドルID(パッケージ名)がアプリの識別子と一致すること
- プロダクトIDが存在する商品のIDであること
- トランザクションIDがまだ処理されていないこと
- アプリのビジネスロジックに関するサーバサイドチェック
APIの実装例
Laravelでの実装例です。
Route::post('/verify-purchase', 'Api\PurchaseController@verifyReceipt');
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Log;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
class PurchaseController extends Controller
{
private $bundle_id;
private $product_ids;
private $pubkey;
private $error_codes = [
"INVALID_PAYLOAD" => 6778001,
"CONNECTION_FAILED" => 6778002,
"PURCHASE_EXPIRED" => 6778003,
"PURCHASE_CONSUMED" => 6778004,
"INTERNAL_ERROR" => 6778005,
"NEED_MORE_DATA" => 6778006,
];
public function __construct()
{
$this->bundle_id = env('BUNDLE_ID');
$this->product_ids = env('PRODUCT_IDS');
$this->pubkey = env('PUBKEY');
}
public function verifyReceipt(Request $request)
{
$success_response = [
'ok' => true,
'data' => [
'transaction' => null,
],
];
$error_response = [
'ok' => false,
'data' => [
'code' => $this->error_codes['INVALID_PAYLOAD'],
],
'error' => [
'message' => "invalid receipt"
],
];
// Validation check
$validator = Validator::make($request->all(), [
'id' => "required|string|in:{$this->product_ids}",
'transaction' => 'required|array',
'transaction.type' => 'required|in:ios-appstore,android-playstore',
]);
// Validation error
if($validator->fails()) {
$errors = $validator->errors()->all();
Log::notice($errors);
$error_response['error']['message'] = $errors;
return $error_response;
}
$product_id = $request->input('id');
$transaction = $request->input('transaction');
Log::info($transaction);
$success_response['data']['transaction'] = $transaction;
// Verify each platform
switch($transaction['type']){
case 'ios-appstore':
Log::info('Verify App Store.');
if(!$this->verifyAppStore($transaction)){
$error_response['error']['message'] = 'Verify App Store Failed.';
return $error_response;
}
break;
case 'android-playstore':
Log::info('Verify Google Play.');
if(!$this->verifyGooglePlay($transaction)){
$error_response['error']['message'] = 'Verify Google Play Failed.';
return $error_response;
}
break;
}
return $success_response;
}
/**
* Verify App Store receipt
* @param array $transaction
* @return boolean
*/
private function verifyAppStore($transaction){
// endpoint
$production_url = 'https://buy.itunes.apple.com/verifyReceipt';
$sandbox_url = 'https://sandbox.itunes.apple.com/verifyReceipt';
$params = [
'verify' => false,
'headers' => [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'receipt-data' => $transaction['appStoreReceipt'],
],
];
$http_client = new Client();
// Production
try {
Log::info('Send iOS receipt production.');
$response = $http_client->request('POST', $production_url, $params);
if($response->getStatusCode() !== 200) {
Log::notice('Response not 200 OK.');
return false;
}
$body = json_decode($response->getBody()->getContents(), true);
Log::info($body);
}catch(ClientException $e) {
Log::error($e->getMessage());
return false;
}
// Sandbox
if($body['status'] === 21007) {
Log::info('Send iOS receipt sandbox');
try {
$response = $http_client->request('POST', $sandbox_url, $params);
if($response->getStatusCode() !== 200) {
Log::notice('Response not 200 OK.');
return false;
}
$body = json_decode($response->getBody()->getContents(), true);
Log::info($body);
}catch(ClientException $e) {
Log::error($e->getMessage());
return false;
}
}
if ($body['status'] !== 0) {
Log::notice('Receipt status not 0.');
return false;
}
// Check bundle id
if ($body['receipt']['bundle_id'] !== $this->bundle_id) {
Log::notice('Invalid bundle id.');
return false;
}
return true;
}
/**
* Verify Google Play receipt
* @param array $transaction
* @return boolean
*/
private function verifyGooglePlay($transaction){
$receipt = $transaction['receipt'];
$signature = $transaction['signature'];
// RSA public key generation
$pubkey = openssl_get_publickey($this->pubkey);
// Base64 decode signature
$signature = base64_decode($signature);
// Signature verification
$result = (int)openssl_verify($receipt, $signature, $pubkey, OPENSSL_ALGO_SHA1);
if($result !== 1){
Log::notice('Signature invalid.');
return false;
}
openssl_free_key($pubkey);
// Check package name
$receipt = json_decode($transaction['receipt']);
if($receipt->packageName !== $this->bundle_id) {
Log::notice('Invalid package id.');
return false;
}
return true;
}
}
BUNDLE_ID=com.example.billingtest
PRODUCT_IDS=coins100,coins200
PUBKEY="-----BEGIN PUBLIC KEY-----
****************************************************************
****************************************************************
****************************************************************
****************************************************************
****************************************************************
****************************************************************
********
-----END PUBLIC KEY-----"
仕様通りできていればcordova-plugin-purchaseから呼び出せるはずです。