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

Cordovaで課金処理を行うためのプラグイン、[cordova-plugin-purchase](https://github.com/j3k0/cordova-plugin-purchase)には各プラットフォームのレシートを検証するためのAPI呼び出し処理を追加することができます。
https://github.com/j3k0/cordova-plugin-purchase/blob/master/doc/api.md#receipt-validation

このレシート検証APIをLaravelで作ってみます。

課金を実装したCordovaアプリ自体の作成はこちらの記事でまとめています。
[Cordova（Monaca）でアプリ内課金を実装する](https://qiita.com/danishi/items/10316e3f710e6a9fedf7)

# APIのリクエスト、レスポンス仕様
プラグイン側でリクエストレスポンスの仕様が決められています。
https://github.com/j3k0/cordova-plugin-purchase/blob/master/doc/api.md#validator

## リクエスト

#### URL
好きなURLを設定できます。
標準以外のパラメータを渡したい場合はパスパラメータなどを利用しましょう。

#### メソッド
POST

#### リクエストボディ
```javascript
{
  "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
```javascript
"transaction" : {
    "appStoreReceipt":"appStoreReceiptString", // BASE64エンコーディングされたレシート情報
    "id" : "idString", // トランザクションID
    "original_transaction_id":"transactionIdString", // 購読型の時にセットされる
    "type": "ios-appstore" // ストアの識別
}
```

##### Android
```javascript
"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
#### レスポンスボディ

```javascript
{
    "ok" : true,
    "data" : {
        "transaction" : { // リクエストボディのトランザクションをセット }
    }
}
```

### 失敗（unverified）
#### レスポンスコード
200または200以外でも失敗と判断される
#### レスポンスボディ

```javascript
{
    "ok" : false,
    "data" : { // エラーコード
        "code" : 6778003
    },
    "error" : { // エラーメッセージ。好きに設定できる。
        "message" : "The subscription is expired."
    }
}
```

アプリ側でハンドルするためにエラーコードは以下が定義されています。

```javascript

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から取得します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96507/766087ad-5f6b-b600-6a6e-52bfcebbdce3.png)

取得した文字列を`public.txt`に保存して下記コマンドを実行してPEM形式にします。

```bash
$ 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での実装例です。

```php:api.php
Route::post('/verify-purchase', 'Api\PurchaseController@verifyReceipt');
```

```php: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;
    }
}
```

```txt:.env
BUNDLE_ID=com.example.billingtest
PRODUCT_IDS=coins100,coins200
PUBKEY="-----BEGIN PUBLIC KEY-----
****************************************************************
****************************************************************
****************************************************************
****************************************************************
****************************************************************
****************************************************************
********
-----END PUBLIC KEY-----"
```

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