2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

cordova-plugin-purchase用の消耗型課金レシート検証APIをLaravelで実装する

Last updated at Posted at 2020-12-01

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)を検証することで、レシートが改ざんされていないかチェックできます。
リクエストで送られてきたreceiptsignatureをGoogle Play Consoleから取得できるRSA公開鍵で検証します。

公開鍵のBase64文字列はGoogle Play Consoleから取得します。
image.png

取得した文字列を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での実装例です。

api.php
Route::post('/verify-purchase', 'Api\PurchaseController@verifyReceipt');
PurchaseController.php
<?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;
    }
}
.env
BUNDLE_ID=com.example.billingtest
PRODUCT_IDS=coins100,coins200
PUBKEY="-----BEGIN PUBLIC KEY-----
****************************************************************
****************************************************************
****************************************************************
****************************************************************
****************************************************************
****************************************************************
********
-----END PUBLIC KEY-----"

仕様通りできていればcordova-plugin-purchaseから呼び出せるはずです。

2
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?